Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions internal/api/handler/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,34 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB
// Central authorization: construct the configured PDP once and wrap it in the single
// PEP. Routes enforce access via pep.Authorize(resource, action) instead of ad-hoc
// checks. The builtin driver reproduces the prior access rules with no behavior change.
authzDriver := ""
authzOpts := authz.Options{}
failMode := authz.FailClosed
if config.Authz != nil {
authzDriver = config.Authz.Driver
authzOpts.Driver = config.Authz.Driver
authzOpts.Endpoint = config.Authz.Endpoint
authzOpts.CacheTTL = config.Authz.CacheTTL
failMode = authz.ParseFailMode(config.Authz.FailMode)
}
pdp, err := authz.Open(authz.Options{Driver: authzDriver}, authz.Deps{DB: db, Config: config, Logger: logger})
pdp, err := authz.Open(authzOpts, authz.Deps{DB: db, Config: config, Logger: logger})
if err != nil {
logger.Fatalw("Failed to initialize authorization PDP", "driver", authzDriver, "error", err)
logger.Fatalw("Failed to initialize authorization PDP", "driver", authzOpts.Driver, "error", err)
}
pep := middleware.NewPEP(pdp, failMode, logger)
authzManifest, err := authz.DefaultManifest()
if err != nil {
logger.Fatalw("Failed to load authorization manifest", "error", err)
}

healthHandler := NewHealthHandler(logger, db)
healthHandler := NewHealthHandler(logger, db).WithPDP(pdp)
healthHandler.Register(server.API().Group("/health"))

// /me/permissions: the authenticated subject's allowed (resource, action) pairs via a
// single batch PDP call, so the UI can hide actions the user can't perform (BCH-1318).
permissionsHandler := NewPermissionsHandler(pdp, authzManifest, failMode, logger)
meGroup := server.API().Group("/me")
meGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey))
permissionsHandler.Register(meGroup)

filterHandler := NewFilterHandler(logger, db)
filterGroup := server.API().Group("/filters")
filterGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey))
Expand Down
25 changes: 25 additions & 0 deletions internal/api/handler/health.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
package handler

import (
"context"
"database/sql"
"net/http"
"time"

"github.com/compliance-framework/api/internal/authz"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"gorm.io/gorm"
)

// pdpHealthTimeout bounds the PDP readiness probe so a hung PDP can't stall /health/ready.
const pdpHealthTimeout = 2 * time.Second

type HealthHandler struct {
db *gorm.DB
pdp authz.PDP
sugar *zap.SugaredLogger
}

Expand All @@ -25,6 +32,15 @@ func NewHealthHandler(sugar *zap.SugaredLogger, db *gorm.DB) *HealthHandler {
}
}

// WithPDP attaches the authorization PDP so readiness reflects the decision engine's
// availability (a remote AuthZen PDP being down makes the API not-ready). Returns the
// handler for chaining. The in-process builtin driver doesn't implement Healther, so it
// is treated as always healthy.
func (h *HealthHandler) WithPDP(pdp authz.PDP) *HealthHandler {
h.pdp = pdp
return h
}

func (h *HealthHandler) Register(api *echo.Group) {
api.GET("", h.Health)
api.GET("/ready", h.Ready)
Expand All @@ -46,6 +62,15 @@ func (h *HealthHandler) Ready(ctx echo.Context) error {
return ctx.JSON(http.StatusServiceUnavailable, healthResponse{Status: "unavailable"})
}

if checker, ok := h.pdp.(authz.Healther); ok {
hctx, cancel := context.WithTimeout(ctx.Request().Context(), pdpHealthTimeout)
defer cancel()
if err := checker.Health(hctx); err != nil {
h.sugar.Errorw("authz PDP health check failed", "err", err)
return ctx.JSON(http.StatusServiceUnavailable, healthResponse{Status: "unavailable"})
}
}

return ctx.JSON(http.StatusOK, healthResponse{Status: "ok"})
}

Expand Down
116 changes: 116 additions & 0 deletions internal/api/handler/permissions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package handler

import (
"errors"
"net/http"
"sort"

"github.com/compliance-framework/api/internal/api/middleware"
"github.com/compliance-framework/api/internal/authz"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)

// PermissionsHandler serves GET /me/permissions: the set of (resource, action) pairs the
// authenticated subject may perform, computed in a single batch PDP call over the manifest
// vocabulary. The UI uses it to hide actions the user can't take (BCH-1318). It holds
// facts only — no policy logic — and reuses the PEP's subject derivation.
type PermissionsHandler struct {
pdp authz.PDP
manifest *authz.Manifest
failMode authz.FailMode
logger *zap.SugaredLogger
}

// NewPermissionsHandler constructs the handler. A nil logger becomes a no-op; an empty
// fail mode defaults to fail-closed.
func NewPermissionsHandler(pdp authz.PDP, manifest *authz.Manifest, failMode authz.FailMode, logger *zap.SugaredLogger) *PermissionsHandler {
if logger == nil {
logger = zap.NewNop().Sugar()
}
if failMode == "" {
failMode = authz.FailClosed
}
return &PermissionsHandler{pdp: pdp, manifest: manifest, failMode: failMode, logger: logger}
}

// Register mounts the route on a group that already enforces authentication.
func (h *PermissionsHandler) Register(g *echo.Group) {
g.GET("/permissions", h.GetPermissions)
}

type permissionsSubject struct {
Type string `json:"type"`
ID string `json:"id"`
}

type permissionsResponse struct {
Subject permissionsSubject `json:"subject"`
Permissions map[string][]string `json:"permissions"`
}

// GetPermissions enumerates every manifest resource × action for the current subject,
// asks the PDP for all decisions in one batch, and returns the allowed map. Resources are
// always present (so the UI knows the full vocabulary) with their allowed actions; ordering
// is deterministic (resources sorted, actions in manifest order).
func (h *PermissionsHandler) GetPermissions(c echo.Context) error {
subject := middleware.SubjectFromContext(c)
subjectView := permissionsSubject{Type: subject.Type, ID: subject.ID}

resources := make([]string, 0, len(h.manifest.Resources))
for name := range h.manifest.Resources {
resources = append(resources, name)
}
sort.Strings(resources)

// Pre-seed every resource with an empty allow-list so the response shape is stable
// regardless of decisions or fail mode.
perms := make(map[string][]string, len(resources))
for _, r := range resources {
perms[r] = []string{}
}

type pair struct{ resource, action string }
reqs := make([]authz.EvalRequest, 0)
index := make([]pair, 0)
for _, resource := range resources {
for _, action := range h.manifest.Resources[resource].Actions {
// Type-level capability check (no resource instance / request context), which
// is exactly what UI hints need; instance-level checks stay on the PEP.
reqs = append(reqs, authz.EvalRequest{
Comment thread
ccf-lisa[bot] marked this conversation as resolved.
Subject: subject,
Action: action,
Resource: authz.Resource{Type: resource},
})
index = append(index, pair{resource, action})
}
}

decisions, err := h.pdp.Evaluations(c.Request().Context(), reqs)
if err != nil {
if errors.Is(err, authz.ErrUnavailable) {
h.logger.Warnw("authz PDP unavailable for /me/permissions", "failMode", h.failMode, "error", err)
if h.failMode == authz.FailOpen {
for _, r := range resources {
perms[r] = append([]string{}, h.manifest.Resources[r].Actions...)
}
}
// Fail closed leaves the pre-seeded empty lists (UI hides everything).
return c.JSON(http.StatusOK, permissionsResponse{Subject: subjectView, Permissions: perms})
}
h.logger.Errorw("authz evaluation failed for /me/permissions", "error", err)
return echo.NewHTTPError(http.StatusInternalServerError, "authorization error")
}
if len(decisions) != len(index) {
h.logger.Errorw("authz returned wrong decision count for /me/permissions",
"want", len(index), "got", len(decisions))
return echo.NewHTTPError(http.StatusInternalServerError, "authorization error")
}

for i, d := range decisions {
if d.Allow {
perms[index[i].resource] = append(perms[index[i].resource], index[i].action)
}
}
return c.JSON(http.StatusOK, permissionsResponse{Subject: subjectView, Permissions: perms})
}
160 changes: 160 additions & 0 deletions internal/api/handler/permissions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package handler

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/compliance-framework/api/internal/authn"
"github.com/compliance-framework/api/internal/authz"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// stubPDP answers Evaluations from an allow predicate (or an error), recording the batch
// size so the test can assert a single batched enumeration.
type stubPDP struct {
allow func(action string) bool
err error
lastBatch int
}

func (s *stubPDP) Evaluate(context.Context, authz.Subject, string, authz.Resource, map[string]any) (authz.Decision, error) {
return authz.Decision{}, nil
}

func (s *stubPDP) Evaluations(_ context.Context, reqs []authz.EvalRequest) ([]authz.Decision, error) {
s.lastBatch = len(reqs)
if s.err != nil {
return nil, s.err
}
out := make([]authz.Decision, len(reqs))
for i, r := range reqs {
out[i] = authz.Decision{Allow: s.allow(r.Action)}
}
return out, nil
}

func newPermissionsCtx(t *testing.T, subject string) (echo.Context, *httptest.ResponseRecorder) {
t.Helper()
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/me/permissions", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if subject != "" {
c.Set("user", &authn.UserClaims{RegisteredClaims: jwt.RegisteredClaims{Subject: subject}})
}
return c, rec
}

func manifestForTest(t *testing.T) *authz.Manifest {
t.Helper()
m, err := authz.DefaultManifest()
require.NoError(t, err)
return m
}

func decodeResp(t *testing.T, rec *httptest.ResponseRecorder) permissionsResponse {
t.Helper()
var resp permissionsResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
return resp
}

// TestPermissionsAllowedSubset: a PDP that only allows "read" yields exactly the read
// actions per resource, in one batched call, with every resource present in the response.
func TestPermissionsAllowedSubset(t *testing.T) {
m := manifestForTest(t)
pdp := &stubPDP{allow: func(action string) bool { return action == "read" }}
h := NewPermissionsHandler(pdp, m, authz.FailClosed, nil)

c, rec := newPermissionsCtx(t, "alice@acme.com")
require.NoError(t, h.GetPermissions(c))
assert.Equal(t, http.StatusOK, rec.Code)

resp := decodeResp(t, rec)
assert.Equal(t, "user", resp.Subject.Type)
assert.Equal(t, "alice@acme.com", resp.Subject.ID)

// Every manifest resource is present; only "read" survives where it's a declared action.
totalActions := 0
for name, def := range m.Resources {
got, ok := resp.Permissions[name]
require.Truef(t, ok, "resource %q missing from response", name)
totalActions += len(def.Actions)
hasRead := false
for _, a := range def.Actions {
if a == "read" {
hasRead = true
}
}
if hasRead {
assert.Equalf(t, []string{"read"}, got, "resource %q allowed actions", name)
} else {
assert.Emptyf(t, got, "resource %q should have no allowed actions", name)
}
}
assert.Equal(t, totalActions, pdp.lastBatch, "should enumerate every resource×action in one batch")
}

func TestPermissionsFailClosed(t *testing.T) {
m := manifestForTest(t)
pdp := &stubPDP{err: fmt.Errorf("pdp gone: %w", authz.ErrUnavailable)}
h := NewPermissionsHandler(pdp, m, authz.FailClosed, nil)

c, rec := newPermissionsCtx(t, "alice@acme.com")
require.NoError(t, h.GetPermissions(c))
assert.Equal(t, http.StatusOK, rec.Code)

resp := decodeResp(t, rec)
for name := range m.Resources {
assert.Emptyf(t, resp.Permissions[name], "fail-closed must deny %q", name)
}
}

func TestPermissionsFailOpen(t *testing.T) {
m := manifestForTest(t)
pdp := &stubPDP{err: fmt.Errorf("pdp gone: %w", authz.ErrUnavailable)}
h := NewPermissionsHandler(pdp, m, authz.FailOpen, nil)

c, rec := newPermissionsCtx(t, "alice@acme.com")
require.NoError(t, h.GetPermissions(c))
assert.Equal(t, http.StatusOK, rec.Code)

resp := decodeResp(t, rec)
for name, def := range m.Resources {
assert.ElementsMatchf(t, def.Actions, resp.Permissions[name],
"fail-open must advertise the full vocabulary for %q", name)
}
}

// TestPermissionsInternalError: a non-unavailable PDP error is a 500, not a silent allow.
func TestPermissionsInternalError(t *testing.T) {
m := manifestForTest(t)
pdp := &stubPDP{err: fmt.Errorf("boom")}
h := NewPermissionsHandler(pdp, m, authz.FailClosed, nil)

c, _ := newPermissionsCtx(t, "alice@acme.com")
err := h.GetPermissions(c)
require.Error(t, err)
he, ok := err.(*echo.HTTPError)
require.True(t, ok, "want *echo.HTTPError")
assert.Equal(t, http.StatusInternalServerError, he.Code)
}

// TestPermissionsAnonymousSubject: no claims → anonymous subject, still answered.
func TestPermissionsAnonymousSubject(t *testing.T) {
m := manifestForTest(t)
pdp := &stubPDP{allow: func(string) bool { return false }}
h := NewPermissionsHandler(pdp, m, authz.FailClosed, nil)

c, rec := newPermissionsCtx(t, "")
require.NoError(t, h.GetPermissions(c))
resp := decodeResp(t, rec)
assert.Equal(t, "anonymous", resp.Subject.Type)
}
11 changes: 6 additions & 5 deletions internal/api/middleware/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func NewPEP(pdp authz.PDP, failMode authz.FailMode, logger *zap.SugaredLogger) *
func (p *PEP) Authorize(resource, action string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
subject := subjectFromContext(c)
subject := SubjectFromContext(c)
res := authz.Resource{Type: resource, ID: c.Param("id")}
reqCtx := map[string]any{
"method": c.Request().Method,
Expand Down Expand Up @@ -71,11 +71,12 @@ func (p *PEP) Authorize(resource, action string) echo.MiddlewareFunc {
}
}

// subjectFromContext derives the authz Subject from the principal the authn middleware
// SubjectFromContext derives the authz Subject from the principal the authn middleware
// placed in the context: an authenticated user, an authenticated agent, or an anonymous
// subject on public-allowed routes. Attributes are intentionally minimal in Phase 1; the
// authoritative attribute surface is designed in BCH-1319.
func subjectFromContext(c echo.Context) authz.Subject {
// subject on public-allowed routes. It is the single source of subject derivation, shared
// by the PEP and the /me/permissions handler. Attributes are intentionally minimal in
// Phase 1; the authoritative attribute surface is designed in BCH-1319.
func SubjectFromContext(c echo.Context) authz.Subject {
if claims, ok := c.Get("user").(*authn.UserClaims); ok && claims != nil {
return authz.Subject{
Type: "user",
Expand Down
Loading
Loading