diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 9faa9727..2a0edb37 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -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)) diff --git a/internal/api/handler/health.go b/internal/api/handler/health.go index e38221c5..0451a9a5 100644 --- a/internal/api/handler/health.go +++ b/internal/api/handler/health.go @@ -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 } @@ -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) @@ -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"}) } diff --git a/internal/api/handler/permissions.go b/internal/api/handler/permissions.go new file mode 100644 index 00000000..42ec6ad6 --- /dev/null +++ b/internal/api/handler/permissions.go @@ -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{ + 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}) +} diff --git a/internal/api/handler/permissions_test.go b/internal/api/handler/permissions_test.go new file mode 100644 index 00000000..68419591 --- /dev/null +++ b/internal/api/handler/permissions_test.go @@ -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) +} diff --git a/internal/api/middleware/authorize.go b/internal/api/middleware/authorize.go index 469b45e5..3f7a2ad9 100644 --- a/internal/api/middleware/authorize.go +++ b/internal/api/middleware/authorize.go @@ -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, @@ -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", diff --git a/internal/authz/authzen.go b/internal/authz/authzen.go new file mode 100644 index 00000000..f8dca803 --- /dev/null +++ b/internal/authz/authzen.go @@ -0,0 +1,239 @@ +package authz + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "go.uber.org/zap" +) + +// DriverAuthzen is the name of the remote AuthZen HTTP driver: it turns any +// AuthZen-compliant PDP (Topaz, Axiomatics, PlainID, …) into CCF's decision engine by +// changing one config key. See the design plan §3.3 (Phase 3). +const DriverAuthzen = "authzen" + +// defaultAuthzenTimeout bounds a single PDP call so a hung PDP can't stall a request when +// the caller's context carries no deadline of its own. +const defaultAuthzenTimeout = 5 * time.Second + +func init() { + Register(DriverAuthzen, func(opts Options, deps Deps) (PDP, error) { + return NewAuthZen(opts.Endpoint, deps.Logger) + }) +} + +// AuthZen is a PDP that delegates decisions to a remote AuthZen Authorization API. It +// supplies facts only (the PEP and handlers build the tuple) and holds no policy logic. +// It is safe for concurrent use (the underlying http.Client is). +type AuthZen struct { + evalURL string // single Access Evaluation endpoint + evalsURL string // batch Access Evaluations endpoint + wellKnownURL string // AuthZen metadata, used for the health check + client *http.Client + logger *zap.SugaredLogger +} + +// NewAuthZen constructs the driver from the PDP's single-evaluation URL. The batch URL is +// derived by AuthZen convention; an empty or malformed endpoint is a startup error. +func NewAuthZen(endpoint string, logger *zap.SugaredLogger) (*AuthZen, error) { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + return nil, errors.New("authz: authzen driver requires authz.endpoint") + } + u, err := url.Parse(endpoint) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("authz: invalid authz.endpoint %q", endpoint) + } + if logger == nil { + logger = zap.NewNop().Sugar() + } + return &AuthZen{ + evalURL: endpoint, + evalsURL: deriveEvaluationsURL(endpoint), + wellKnownURL: u.Scheme + "://" + u.Host + "/.well-known/authzen-configuration", + client: &http.Client{Timeout: defaultAuthzenTimeout}, + logger: logger, + }, nil +} + +// deriveEvaluationsURL maps the single-evaluation URL to its batch sibling using the +// AuthZen path convention (/access/v1/evaluation → /access/v1/evaluations). It operates on +// the parsed path so a trailing slash or a query string doesn't defeat the match, and the +// query is preserved. PDPs that serve both at one path still work: any path that isn't a +// /evaluation suffix returns the original URL unchanged. +func deriveEvaluationsURL(eval string) string { + u, err := url.Parse(eval) + if err != nil { + return eval + } + p := strings.TrimSuffix(u.Path, "/") + if strings.HasSuffix(p, "/evaluation") { + u.Path = strings.TrimSuffix(p, "/evaluation") + "/evaluations" + return u.String() + } + return eval +} + +// --- AuthZen wire shapes (Authorization API) --- + +type authzenSubject struct { + Type string `json:"type"` + ID string `json:"id"` + Properties map[string]any `json:"properties,omitempty"` +} + +type authzenAction struct { + Name string `json:"name"` + Properties map[string]any `json:"properties,omitempty"` +} + +type authzenResource struct { + Type string `json:"type"` + ID string `json:"id"` + Properties map[string]any `json:"properties,omitempty"` +} + +type authzenEvaluation struct { + Subject authzenSubject `json:"subject"` + Action authzenAction `json:"action"` + Resource authzenResource `json:"resource"` + Context map[string]any `json:"context,omitempty"` +} + +type authzenDecisionResponse struct { + Decision bool `json:"decision"` + Context map[string]any `json:"context,omitempty"` +} + +type authzenEvaluationsRequest struct { + Evaluations []authzenEvaluation `json:"evaluations"` +} + +type authzenEvaluationsResponse struct { + Evaluations []authzenDecisionResponse `json:"evaluations"` +} + +func toEvaluation(s Subject, action string, r Resource, reqCtx map[string]any) authzenEvaluation { + return authzenEvaluation{ + Subject: authzenSubject{Type: s.Type, ID: s.ID, Properties: s.Props}, + Action: authzenAction{Name: action}, + Resource: authzenResource{Type: r.Type, ID: r.ID, Properties: r.Props}, + Context: reqCtx, + } +} + +// decisionFrom maps an AuthZen response to a CCF Decision. AuthZen carries an optional +// context object; a "reason" string there is surfaced for logging only. +func decisionFrom(resp authzenDecisionResponse) Decision { + reason := "" + if r, ok := resp.Context["reason"].(string); ok { + reason = r + } + return Decision{Allow: resp.Decision, Reason: reason} +} + +// Evaluate implements PDP via the AuthZen single Access Evaluation API. +func (a *AuthZen) Evaluate(ctx context.Context, s Subject, action string, r Resource, reqCtx map[string]any) (Decision, error) { + var resp authzenDecisionResponse + if err := a.post(ctx, a.evalURL, toEvaluation(s, action, r, reqCtx), &resp); err != nil { + return Decision{}, err + } + return decisionFrom(resp), nil +} + +// Evaluations implements PDP via the AuthZen batch Access Evaluations API — one HTTP call +// for the whole batch (list filtering / UI permission hints), not N calls. +func (a *AuthZen) Evaluations(ctx context.Context, reqs []EvalRequest) ([]Decision, error) { + if len(reqs) == 0 { + return []Decision{}, nil + } + body := authzenEvaluationsRequest{Evaluations: make([]authzenEvaluation, len(reqs))} + for i, req := range reqs { + body.Evaluations[i] = toEvaluation(req.Subject, req.Action, req.Resource, req.Context) + } + var resp authzenEvaluationsResponse + if err := a.post(ctx, a.evalsURL, body, &resp); err != nil { + return nil, err + } + if len(resp.Evaluations) != len(reqs) { + return nil, fmt.Errorf("authz: authzen returned %d decisions for %d requests", len(resp.Evaluations), len(reqs)) + } + out := make([]Decision, len(reqs)) + for i, d := range resp.Evaluations { + out[i] = decisionFrom(d) + } + return out, nil +} + +// Health implements Healther by probing the PDP's AuthZen metadata document. A reachable +// 2xx means the engine is up; anything else is reported as unavailable. +func (a *AuthZen) Health(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.wellKnownURL, nil) + if err != nil { + return fmt.Errorf("authz: build authzen health request: %w", err) + } + resp, err := a.client.Do(req) + if err != nil { + return fmt.Errorf("authz: authzen PDP unreachable (%v): %w", err, ErrUnavailable) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<10)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("authz: authzen PDP health returned %s: %w", resp.Status, ErrUnavailable) + } + return nil +} + +// post sends payload as JSON to endpoint and decodes a JSON response into out. It maps +// transport failures, timeouts, 429 and 5xx to ErrUnavailable (the PEP then applies the +// configured fail mode); other non-2xx statuses are plain errors (the PEP maps them to +// 500), since a compliant PDP returns its allow/deny verdict as a 200 body. +func (a *AuthZen) post(ctx context.Context, endpoint string, payload any, out any) error { + buf, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("authz: marshal authzen request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf)) + if err != nil { + return fmt.Errorf("authz: build authzen request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return fmt.Errorf("authz: authzen request to %s failed (%v): %w", endpoint, err, ErrUnavailable) + } + defer func() { _ = resp.Body.Close() }() + + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + // proceed to decode + case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500: + return fmt.Errorf("authz: authzen PDP %s returned %s: %w", endpoint, resp.Status, ErrUnavailable) + default: + return fmt.Errorf("authz: authzen PDP %s returned %s: %s", endpoint, resp.Status, readSnippet(resp.Body)) + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("authz: decode authzen response from %s: %w", endpoint, err) + } + // Drain any trailing bytes (e.g. a newline after the JSON value) so net/http can return + // the connection to the keep-alive pool instead of re-handshaking on the next decision. + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} + +// readSnippet returns a short, trimmed prefix of an error response body for logs. +func readSnippet(r io.Reader) string { + b, _ := io.ReadAll(io.LimitReader(r, 512)) + return strings.TrimSpace(string(b)) +} diff --git a/internal/authz/authzen_test.go b/internal/authz/authzen_test.go new file mode 100644 index 00000000..fd9f7019 --- /dev/null +++ b/internal/authz/authzen_test.go @@ -0,0 +1,334 @@ +package authz + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// decodeBody unmarshals the JSON request body the driver sent to the mock PDP. +func decodeBody(t *testing.T, r *http.Request, out any) { + t.Helper() + b, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read request body: %v", err) + } + if err := json.Unmarshal(b, out); err != nil { + t.Fatalf("unmarshal request body %q: %v", string(b), err) + } +} + +func newDriver(t *testing.T, endpoint string) *AuthZen { + t.Helper() + d, err := NewAuthZen(endpoint, nil) + if err != nil { + t.Fatalf("NewAuthZen(%q) error = %v", endpoint, err) + } + return d +} + +// TestNewAuthZenValidation locks in fail-fast construction: an empty or malformed endpoint +// is a startup error, and the batch URL is derived by AuthZen convention. +func TestNewAuthZenValidation(t *testing.T) { + if _, err := NewAuthZen("", nil); err == nil { + t.Error("NewAuthZen(\"\") error = nil, want error") + } + if _, err := NewAuthZen("not-a-url", nil); err == nil { + t.Error("NewAuthZen(\"not-a-url\") error = nil, want error") + } + d := newDriver(t, "https://pdp.internal/access/v1/evaluation") + if got, want := d.evalsURL, "https://pdp.internal/access/v1/evaluations"; got != want { + t.Errorf("evalsURL = %q, want %q", got, want) + } + if got, want := d.wellKnownURL, "https://pdp.internal/.well-known/authzen-configuration"; got != want { + t.Errorf("wellKnownURL = %q, want %q", got, want) + } + // A non-conventional path reuses the same URL for batch. + d2 := newDriver(t, "https://pdp.internal/decide") + if d2.evalsURL != "https://pdp.internal/decide" { + t.Errorf("evalsURL fallback = %q, want same as eval URL", d2.evalsURL) + } + // Near-miss inputs (trailing slash, query string) still derive the batch URL. + derive := []struct{ in, want string }{ + {"https://pdp.internal/access/v1/evaluation/", "https://pdp.internal/access/v1/evaluations"}, + {"https://pdp.internal/access/v1/evaluation?tenant=x", "https://pdp.internal/access/v1/evaluations?tenant=x"}, + } + for _, tc := range derive { + if got := newDriver(t, tc.in).evalsURL; got != tc.want { + t.Errorf("evalsURL for %q = %q, want %q", tc.in, got, tc.want) + } + } +} + +// TestEvaluateRequestShapeConformance asserts the wire request matches the AuthZen +// Authorization API: subject{type,id,properties}, action{name}, resource{type,id}, context. +func TestEvaluateRequestShapeConformance(t *testing.T) { + var got authzenEvaluation + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + decodeBody(t, r, &got) + _ = json.NewEncoder(w).Encode(authzenDecisionResponse{Decision: true}) + })) + defer srv.Close() + + d := newDriver(t, srv.URL+"/access/v1/evaluation") + dec, err := d.Evaluate(context.Background(), + Subject{Type: "user", ID: "alice@acme.com", Props: map[string]any{"given_name": "Alice"}}, + "read", + Resource{Type: "evidence", ID: "42"}, + map[string]any{"method": "GET"}) + if err != nil { + t.Fatalf("Evaluate error = %v", err) + } + if !dec.Allow { + t.Errorf("decision Allow = false, want true") + } + if got.Subject.Type != "user" || got.Subject.ID != "alice@acme.com" { + t.Errorf("subject = %+v, want type=user id=alice@acme.com", got.Subject) + } + if got.Subject.Properties["given_name"] != "Alice" { + t.Errorf("subject.properties.given_name = %v, want Alice", got.Subject.Properties["given_name"]) + } + if got.Action.Name != "read" { + t.Errorf("action.name = %q, want read", got.Action.Name) + } + if got.Resource.Type != "evidence" || got.Resource.ID != "42" { + t.Errorf("resource = %+v, want type=evidence id=42", got.Resource) + } + if got.Context["method"] != "GET" { + t.Errorf("context.method = %v, want GET", got.Context["method"]) + } +} + +// TestEvaluateDecisionMapping is an AuthZen-interop-style table: the canonical allow/deny +// shapes a compliant PDP returns map to the right CCF Decision, including a reason in the +// response context. +func TestEvaluateDecisionMapping(t *testing.T) { + cases := []struct { + name string + body string + wantAllow bool + wantReason string + }{ + {"allow", `{"decision":true}`, true, ""}, + {"deny", `{"decision":false}`, false, ""}, + {"deny with reason", `{"decision":false,"context":{"reason":"not an admin"}}`, false, "not an admin"}, + {"allow with extra context", `{"decision":true,"context":{"id":"req-1"}}`, true, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, tc.body) + })) + defer srv.Close() + d := newDriver(t, srv.URL+"/access/v1/evaluation") + dec, err := d.Evaluate(context.Background(), Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence"}, nil) + if err != nil { + t.Fatalf("Evaluate error = %v", err) + } + if dec.Allow != tc.wantAllow { + t.Errorf("Allow = %v, want %v", dec.Allow, tc.wantAllow) + } + if dec.Reason != tc.wantReason { + t.Errorf("Reason = %q, want %q", dec.Reason, tc.wantReason) + } + }) + } +} + +// TestEvaluationsBatch verifies the batch path: one HTTP call to the evaluations endpoint, +// requests serialized in order, and decisions mapped back positionally. +func TestEvaluationsBatch(t *testing.T) { + var calls int + var got authzenEvaluationsRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if !strings.HasSuffix(r.URL.Path, "/evaluations") { + t.Errorf("batch path = %q, want suffix /evaluations", r.URL.Path) + } + decodeBody(t, r, &got) + _ = json.NewEncoder(w).Encode(authzenEvaluationsResponse{Evaluations: []authzenDecisionResponse{ + {Decision: true}, {Decision: false}, {Decision: true}, + }}) + })) + defer srv.Close() + + d := newDriver(t, srv.URL+"/access/v1/evaluation") + reqs := []EvalRequest{ + {Subject: Subject{Type: "user", ID: "a"}, Action: "read", Resource: Resource{Type: "evidence"}}, + {Subject: Subject{Type: "user", ID: "a"}, Action: "delete", Resource: Resource{Type: "evidence"}}, + {Subject: Subject{Type: "user", ID: "a"}, Action: "read", Resource: Resource{Type: "catalog"}}, + } + decs, err := d.Evaluations(context.Background(), reqs) + if err != nil { + t.Fatalf("Evaluations error = %v", err) + } + if calls != 1 { + t.Errorf("HTTP calls = %d, want 1 (batched)", calls) + } + if len(got.Evaluations) != 3 { + t.Fatalf("sent %d evaluations, want 3", len(got.Evaluations)) + } + if got.Evaluations[1].Action.Name != "delete" { + t.Errorf("evaluations[1].action = %q, want delete", got.Evaluations[1].Action.Name) + } + want := []bool{true, false, true} + for i, w := range want { + if decs[i].Allow != w { + t.Errorf("decisions[%d].Allow = %v, want %v", i, decs[i].Allow, w) + } + } +} + +func TestEvaluationsEmptyReturnsNoCall(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Error("server should not be called for an empty batch") + })) + defer srv.Close() + d := newDriver(t, srv.URL+"/access/v1/evaluation") + decs, err := d.Evaluations(context.Background(), nil) + if err != nil || len(decs) != 0 { + t.Fatalf("Evaluations(nil) = (%v, %v), want ([], nil)", decs, err) + } +} + +func TestEvaluationsLengthMismatchErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // One decision for a two-request batch. + _ = json.NewEncoder(w).Encode(authzenEvaluationsResponse{Evaluations: []authzenDecisionResponse{{Decision: true}}}) + })) + defer srv.Close() + d := newDriver(t, srv.URL+"/access/v1/evaluation") + _, err := d.Evaluations(context.Background(), + []EvalRequest{{Action: "read"}, {Action: "delete"}}) + if err == nil { + t.Fatal("Evaluations error = nil, want length-mismatch error") + } + if errors.Is(err, ErrUnavailable) { + t.Error("length mismatch should not be ErrUnavailable") + } +} + +// TestErrorClassification locks in the fail-mode contract: transport failures, timeouts, +// 5xx and 429 wrap ErrUnavailable (PEP applies fail mode); other 4xx are plain errors +// (PEP → 500). +func TestErrorClassification(t *testing.T) { + t.Run("5xx is unavailable", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer srv.Close() + d := newDriver(t, srv.URL+"/access/v1/evaluation") + _, err := d.Evaluate(context.Background(), Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence"}, nil) + if !errors.Is(err, ErrUnavailable) { + t.Errorf("err = %v, want ErrUnavailable", err) + } + }) + t.Run("429 is unavailable", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + d := newDriver(t, srv.URL+"/access/v1/evaluation") + _, err := d.Evaluate(context.Background(), Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence"}, nil) + if !errors.Is(err, ErrUnavailable) { + t.Errorf("err = %v, want ErrUnavailable", err) + } + }) + t.Run("400 is a plain error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, "bad tuple") + })) + defer srv.Close() + d := newDriver(t, srv.URL+"/access/v1/evaluation") + _, err := d.Evaluate(context.Background(), Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence"}, nil) + if err == nil { + t.Fatal("err = nil, want error") + } + if errors.Is(err, ErrUnavailable) { + t.Error("400 should not be ErrUnavailable") + } + }) + t.Run("transport failure is unavailable", func(t *testing.T) { + // Point at a closed port: the dial fails. + d := newDriver(t, "http://127.0.0.1:1/access/v1/evaluation") + _, err := d.Evaluate(context.Background(), Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence"}, nil) + if !errors.Is(err, ErrUnavailable) { + t.Errorf("err = %v, want ErrUnavailable", err) + } + }) +} + +// TestContextDeadlineHonored proves the caller's context cancels an in-flight PDP call, +// surfaced as ErrUnavailable. +func TestContextDeadlineHonored(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + <-release // block until the test releases, after the context has expired + _ = json.NewEncoder(w).Encode(authzenDecisionResponse{Decision: true}) + })) + defer srv.Close() + defer close(release) + + d := newDriver(t, srv.URL+"/access/v1/evaluation") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + _, err := d.Evaluate(ctx, Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence"}, nil) + if !errors.Is(err, ErrUnavailable) { + t.Errorf("err = %v, want ErrUnavailable on context deadline", err) + } +} + +// TestHealth checks the readiness probe against the AuthZen well-known metadata endpoint. +func TestHealth(t *testing.T) { + var path string + healthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer healthy.Close() + d := newDriver(t, healthy.URL+"/access/v1/evaluation") + if err := d.Health(context.Background()); err != nil { + t.Errorf("Health error = %v, want nil", err) + } + if path != "/.well-known/authzen-configuration" { + t.Errorf("health probed %q, want /.well-known/authzen-configuration", path) + } + + down := newDriver(t, "http://127.0.0.1:1/access/v1/evaluation") + if err := down.Health(context.Background()); !errors.Is(err, ErrUnavailable) { + t.Errorf("Health(down) err = %v, want ErrUnavailable", err) + } +} + +// TestDriverRegistered confirms the authzen driver self-registers and Open builds it from +// config (and that a missing endpoint fails fast). +func TestDriverRegistered(t *testing.T) { + found := false + for _, name := range Drivers() { + if name == DriverAuthzen { + found = true + } + } + if !found { + t.Fatalf("Drivers() = %v, missing %q", Drivers(), DriverAuthzen) + } + if _, err := Open(Options{Driver: DriverAuthzen}, Deps{}); err == nil { + t.Error("Open(authzen) with no endpoint = nil error, want failure") + } + if _, err := Open(Options{Driver: DriverAuthzen, Endpoint: "https://pdp.internal/access/v1/evaluation"}, Deps{}); err != nil { + t.Errorf("Open(authzen) with endpoint error = %v", err) + } +} diff --git a/internal/authz/builtin.go b/internal/authz/builtin.go index d3a93906..4eb9e93b 100644 --- a/internal/authz/builtin.go +++ b/internal/authz/builtin.go @@ -59,10 +59,32 @@ func (b *Builtin) Evaluate(ctx context.Context, s Subject, _ string, r Resource, } } -// Evaluations implements PDP by evaluating each request independently, in order. +// Evaluations implements PDP by evaluating each request independently, in order. Admin +// decisions are memoized per subject for the batch: evaluateAdmin ignores the action and +// keys only on the subject, so a batch enumerating several admin.* actions (e.g. +// /me/permissions) would otherwise repeat the same user + SSO-link DB lookups once per +// action. The memo keeps the facts inside the builtin driver and preserves both ordering +// and the error path. func (b *Builtin) Evaluations(ctx context.Context, reqs []EvalRequest) ([]Decision, error) { + type adminKey struct{ subjectType, subjectID string } + adminMemo := map[adminKey]Decision{} + out := make([]Decision, len(reqs)) for i, req := range reqs { + if req.Resource.Type == ResourceAdmin { + key := adminKey{req.Subject.Type, req.Subject.ID} + if d, ok := adminMemo[key]; ok { + out[i] = d + continue + } + d, err := b.Evaluate(ctx, req.Subject, req.Action, req.Resource, req.Context) + if err != nil { + return nil, err + } + adminMemo[key] = d + out[i] = d + continue + } d, err := b.Evaluate(ctx, req.Subject, req.Action, req.Resource, req.Context) if err != nil { return nil, err diff --git a/internal/authz/builtin_admin_integration_test.go b/internal/authz/builtin_admin_integration_test.go index 1c5380f3..8385a35b 100644 --- a/internal/authz/builtin_admin_integration_test.go +++ b/internal/authz/builtin_admin_integration_test.go @@ -138,3 +138,35 @@ func TestBuiltinAdminDecisionMatrix(t *testing.T) { require.False(t, evalAdmin(t, db, ssoEnabledConfig([]string{"ccf-admins"}), "ghost@example.com").Allow) }) } + +// TestBuiltinEvaluationsMemoizesAdmin proves the per-batch admin memo: enumerating several +// admin.* actions for one subject (as /me/permissions does) resolves the admin facts with a +// single set of DB queries instead of one set per action. +func TestBuiltinEvaluationsMemoizesAdmin(t *testing.T) { + db := setupAuthzDB(t) + cfg := ssoEnabledConfig([]string{"ccf-admins"}) + user := createUser(t, db, "sso@example.com", "sso") + createSSOLink(t, db, user, "test", []string{"ccf-admins"}) + + var queries int + require.NoError(t, db.Callback().Query().After("gorm:query").Register("count_queries", func(*gorm.DB) { + queries++ + })) + + b := NewBuiltin(db, cfg, zap.NewNop().Sugar()) + adminActions := []string{"manage", "users.manage", "sso.manage", "settings.manage"} + reqs := make([]EvalRequest, len(adminActions)) + for i, a := range adminActions { + reqs[i] = EvalRequest{Subject: Subject{Type: "user", ID: "sso@example.com"}, Action: a, Resource: Resource{Type: ResourceAdmin}} + } + + decs, err := b.Evaluations(context.Background(), reqs) + require.NoError(t, err) + require.Len(t, decs, len(adminActions)) + for i, d := range decs { + require.Truef(t, d.Allow, "admin action %q should be allowed for an admin user", adminActions[i]) + } + // One admin resolution = user row + SSO link = 2 queries. Without the memo this batch + // would issue 8 (2 × 4 actions); the memo keeps it at the single-resolution cost. + require.LessOrEqualf(t, queries, 2, "admin facts should resolve once (<=2 queries), got %d", queries) +} diff --git a/internal/authz/cache.go b/internal/authz/cache.go new file mode 100644 index 00000000..ef665f9b --- /dev/null +++ b/internal/authz/cache.go @@ -0,0 +1,160 @@ +package authz + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sync" + "time" + + "go.uber.org/zap" +) + +// cachingPDP is a short-TTL decision cache wrapping any PDP, used to absorb the network +// hop of remote drivers (design §6). It caches only successful decisions; errors and +// unavailability are never cached. Safe for concurrent use. +type cachingPDP struct { + inner PDP + ttl time.Duration + logger *zap.SugaredLogger + + mu sync.Mutex + entries map[string]cacheEntry +} + +type cacheEntry struct { + decision Decision + expires time.Time +} + +// maxCacheEntries bounds the cache so a long-running process with high-cardinality +// (subject, resource) tuples can't grow it without limit. When the map reaches this size a +// put first sweeps expired entries; given the short TTL this is normally enough to keep it +// well under the cap (it's a soft cap, not a hard reservoir, so no LRU bookkeeping). +const maxCacheEntries = 4096 + +func newCachingPDP(inner PDP, ttl time.Duration, logger *zap.SugaredLogger) PDP { + if logger == nil { + logger = zap.NewNop().Sugar() + } + return &cachingPDP{ + inner: inner, + ttl: ttl, + logger: logger, + entries: make(map[string]cacheEntry), + } +} + +// cacheKey hashes the full decision tuple so anything that could change the decision — +// subject (incl. props), action, resource (incl. props), and context — changes the key. +// An unmarshalable tuple yields "" meaning "do not cache". +func cacheKey(s Subject, action string, r Resource, reqCtx map[string]any) string { + payload := struct { + S Subject `json:"s"` + A string `json:"a"` + R Resource `json:"r"` + C map[string]any `json:"c"` + }{s, action, r, reqCtx} + b, err := json.Marshal(payload) + if err != nil { + return "" + } + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func (c *cachingPDP) get(key string, now time.Time) (Decision, bool) { + if key == "" { + return Decision{}, false + } + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[key] + if !ok { + return Decision{}, false + } + if now.After(e.expires) { + delete(c.entries, key) + return Decision{}, false + } + return e.decision, true +} + +func (c *cachingPDP) put(key string, d Decision, now time.Time) { + if key == "" { + return + } + c.mu.Lock() + // Opportunistic eviction: only when we're at the cap, and only entries already past + // their TTL — bounded, cheap, and never drops a live decision. + if len(c.entries) >= maxCacheEntries { + for k, e := range c.entries { + if now.After(e.expires) { + delete(c.entries, k) + } + } + } + c.entries[key] = cacheEntry{decision: d, expires: now.Add(c.ttl)} + c.mu.Unlock() +} + +// Evaluate serves from cache when fresh, otherwise delegates and caches the result. +func (c *cachingPDP) Evaluate(ctx context.Context, s Subject, action string, r Resource, reqCtx map[string]any) (Decision, error) { + key := cacheKey(s, action, r, reqCtx) + if d, ok := c.get(key, time.Now()); ok { + return d, nil + } + d, err := c.inner.Evaluate(ctx, s, action, r, reqCtx) + if err != nil { + return d, err + } + c.put(key, d, time.Now()) + return d, nil +} + +// Evaluations serves cached entries directly and batches only the misses into a single +// inner call, preserving the "one batched call" benefit while still caching. +func (c *cachingPDP) Evaluations(ctx context.Context, reqs []EvalRequest) ([]Decision, error) { + now := time.Now() + out := make([]Decision, len(reqs)) + keys := make([]string, len(reqs)) + var missIdx []int + var missReqs []EvalRequest + for i, req := range reqs { + key := cacheKey(req.Subject, req.Action, req.Resource, req.Context) + keys[i] = key + if d, ok := c.get(key, now); ok { + out[i] = d + continue + } + missIdx = append(missIdx, i) + missReqs = append(missReqs, req) + } + if len(missReqs) == 0 { + return out, nil + } + decisions, err := c.inner.Evaluations(ctx, missReqs) + if err != nil { + return nil, err + } + if len(decisions) != len(missReqs) { + return nil, fmt.Errorf("authz: cache: inner returned %d decisions for %d requests", len(decisions), len(missReqs)) + } + now = time.Now() + for j, idx := range missIdx { + out[idx] = decisions[j] + c.put(keys[idx], decisions[j], now) + } + return out, nil +} + +// Health forwards to the inner PDP when it supports it, so wrapping in a cache doesn't +// hide the remote PDP's health from the readiness check. +func (c *cachingPDP) Health(ctx context.Context) error { + if h, ok := c.inner.(Healther); ok { + return h.Health(ctx) + } + return nil +} diff --git a/internal/authz/cache_test.go b/internal/authz/cache_test.go new file mode 100644 index 00000000..3330f638 --- /dev/null +++ b/internal/authz/cache_test.go @@ -0,0 +1,185 @@ +package authz + +import ( + "context" + "errors" + "fmt" + "testing" + "time" +) + +// countingPDP records call counts and batch sizes so tests can prove caching behavior. It +// deliberately does NOT implement Healther. +type countingPDP struct { + evalCalls int + batchCalls int + batchSizes []int + allow map[string]bool // keyed by action + err error +} + +func (f *countingPDP) Evaluate(_ context.Context, _ Subject, action string, _ Resource, _ map[string]any) (Decision, error) { + f.evalCalls++ + if f.err != nil { + return Decision{}, f.err + } + return Decision{Allow: f.allow[action]}, nil +} + +func (f *countingPDP) Evaluations(_ context.Context, reqs []EvalRequest) ([]Decision, error) { + f.batchCalls++ + f.batchSizes = append(f.batchSizes, len(reqs)) + if f.err != nil { + return nil, f.err + } + out := make([]Decision, len(reqs)) + for i, r := range reqs { + out[i] = Decision{Allow: f.allow[r.Action]} + } + return out, nil +} + +// healthyCountingPDP adds Healther so we can test forwarding through the cache. +type healthyCountingPDP struct { + countingPDP + healthErr error +} + +func (f *healthyCountingPDP) Health(context.Context) error { return f.healthErr } + +func TestCacheEvaluateHitsAvoidInnerCall(t *testing.T) { + inner := &countingPDP{allow: map[string]bool{"read": true}} + c := newCachingPDP(inner, time.Minute, nil) + + for i := 0; i < 3; i++ { + dec, err := c.Evaluate(context.Background(), Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence", ID: "1"}, nil) + if err != nil || !dec.Allow { + t.Fatalf("Evaluate #%d = (%+v, %v), want allow", i, dec, err) + } + } + if inner.evalCalls != 1 { + t.Errorf("inner Evaluate calls = %d, want 1 (cached)", inner.evalCalls) + } +} + +func TestCacheKeyVariesByTuple(t *testing.T) { + inner := &countingPDP{allow: map[string]bool{"read": true, "delete": true}} + c := newCachingPDP(inner, time.Minute, nil) + ctx := context.Background() + // Different action, different resource id, and different subject must all miss. + _, _ = c.Evaluate(ctx, Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence", ID: "1"}, nil) + _, _ = c.Evaluate(ctx, Subject{Type: "user", ID: "a"}, "delete", Resource{Type: "evidence", ID: "1"}, nil) + _, _ = c.Evaluate(ctx, Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence", ID: "2"}, nil) + _, _ = c.Evaluate(ctx, Subject{Type: "user", ID: "b"}, "read", Resource{Type: "evidence", ID: "1"}, nil) + if inner.evalCalls != 4 { + t.Errorf("inner Evaluate calls = %d, want 4 (distinct tuples)", inner.evalCalls) + } +} + +func TestCacheExpiry(t *testing.T) { + inner := &countingPDP{allow: map[string]bool{"read": true}} + c := newCachingPDP(inner, time.Millisecond, nil) + ctx := context.Background() + if _, err := c.Evaluate(ctx, Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence"}, nil); err != nil { + t.Fatal(err) + } + time.Sleep(10 * time.Millisecond) // well past the 1ms TTL + if _, err := c.Evaluate(ctx, Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence"}, nil); err != nil { + t.Fatal(err) + } + if inner.evalCalls != 2 { + t.Errorf("inner Evaluate calls = %d, want 2 (entry expired)", inner.evalCalls) + } +} + +func TestCacheDoesNotCacheErrors(t *testing.T) { + inner := &countingPDP{err: errors.New("boom")} + c := newCachingPDP(inner, time.Minute, nil) + ctx := context.Background() + for i := 0; i < 2; i++ { + if _, err := c.Evaluate(ctx, Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence"}, nil); err == nil { + t.Fatal("Evaluate error = nil, want error") + } + } + if inner.evalCalls != 2 { + t.Errorf("inner Evaluate calls = %d, want 2 (errors not cached)", inner.evalCalls) + } +} + +// TestCacheBatchPartialMiss is the key batch property: cached entries are served locally, +// and only the misses go to the inner PDP in a single batched call. +func TestCacheBatchPartialMiss(t *testing.T) { + inner := &countingPDP{allow: map[string]bool{"read": true, "delete": false, "create": true}} + c := newCachingPDP(inner, time.Minute, nil) + ctx := context.Background() + + // Prime "read" via a single Evaluate. + if _, err := c.Evaluate(ctx, Subject{Type: "user", ID: "a"}, "read", Resource{Type: "evidence"}, nil); err != nil { + t.Fatal(err) + } + + reqs := []EvalRequest{ + {Subject: Subject{Type: "user", ID: "a"}, Action: "read", Resource: Resource{Type: "evidence"}}, // cached hit + {Subject: Subject{Type: "user", ID: "a"}, Action: "delete", Resource: Resource{Type: "evidence"}}, // miss + {Subject: Subject{Type: "user", ID: "a"}, Action: "create", Resource: Resource{Type: "evidence"}}, // miss + } + decs, err := c.Evaluations(ctx, reqs) + if err != nil { + t.Fatalf("Evaluations error = %v", err) + } + want := []bool{true, false, true} + for i, w := range want { + if decs[i].Allow != w { + t.Errorf("decisions[%d].Allow = %v, want %v", i, decs[i].Allow, w) + } + } + if inner.batchCalls != 1 { + t.Fatalf("inner batch calls = %d, want 1", inner.batchCalls) + } + if got := inner.batchSizes[0]; got != 2 { + t.Errorf("batched %d requests, want 2 (only the misses)", got) + } + + // A second identical batch is fully served from cache: no new inner call. + if _, err := c.Evaluations(ctx, reqs); err != nil { + t.Fatal(err) + } + if inner.batchCalls != 1 { + t.Errorf("inner batch calls after warm cache = %d, want 1", inner.batchCalls) + } +} + +// TestCacheEvictsExpiredAtCap proves the soft cap: once the map is full, putting a fresh +// entry sweeps the expired ones instead of growing without bound. +func TestCacheEvictsExpiredAtCap(t *testing.T) { + inner := &countingPDP{allow: map[string]bool{}} + c := newCachingPDP(inner, time.Minute, nil).(*cachingPDP) + + now := time.Now() + // Fill to the cap with already-expired entries (expires in the past). + for i := 0; i < maxCacheEntries; i++ { + c.entries[fmt.Sprintf("stale-%d", i)] = cacheEntry{expires: now.Add(-time.Hour)} + } + if len(c.entries) != maxCacheEntries { + t.Fatalf("preloaded %d entries, want %d", len(c.entries), maxCacheEntries) + } + // One put at the cap should sweep the expired entries before inserting. + c.put("fresh", Decision{Allow: true}, now) + if len(c.entries) != 1 { + t.Errorf("entries after sweep = %d, want 1 (all stale evicted, one fresh)", len(c.entries)) + } +} + +func TestCacheHealthForwarding(t *testing.T) { + // Inner without Healther: cache reports healthy. + plain := newCachingPDP(&countingPDP{}, time.Minute, nil) + if err := plain.(Healther).Health(context.Background()); err != nil { + t.Errorf("Health with non-Healther inner = %v, want nil", err) + } + // Inner with Healther: cache forwards the result. + sentinel := errors.New("pdp down") + withHealth := newCachingPDP(&healthyCountingPDP{healthErr: sentinel}, time.Minute, nil) + if err := withHealth.(Healther).Health(context.Background()); !errors.Is(err, sentinel) { + t.Errorf("Health forwarding = %v, want %v", err, sentinel) + } +} diff --git a/internal/authz/pdp.go b/internal/authz/pdp.go index 63d894ed..785efc3d 100644 --- a/internal/authz/pdp.go +++ b/internal/authz/pdp.go @@ -51,6 +51,13 @@ type PDP interface { Evaluations(ctx context.Context, reqs []EvalRequest) ([]Decision, error) } +// Healther is an optional capability a PDP may implement to report whether its decision +// engine is reachable. The readiness check surfaces it; in-process drivers that cannot +// fail (builtin) simply don't implement it and are treated as healthy. +type Healther interface { + Health(ctx context.Context) error +} + // Resource and action identifiers for the routes migrated in Phase 1, all declared in // manifest.yaml. ResourceEvidence with ActionCreate/ActionRead maps directly to the // evidence actions. ActionManage is the Phase-1 umbrella admin action: every admin route diff --git a/internal/authz/registry.go b/internal/authz/registry.go index 2db8987b..b91dcd44 100644 --- a/internal/authz/registry.go +++ b/internal/authz/registry.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" "sync" + "time" "github.com/compliance-framework/api/internal/config" "go.uber.org/zap" @@ -21,10 +22,13 @@ type Deps struct { Logger *zap.SugaredLogger } -// Options selects and configures the driver to open. Driver-specific settings (endpoint, -// cache TTL, ...) are added here as later-phase drivers land. +// Options selects and configures the driver to open. Endpoint is the remote PDP URL used +// by HTTP drivers (authzen); CacheTTL, when > 0, wraps the constructed PDP in a short-TTL +// decision cache to absorb the network hop. Both are ignored by the in-process builtin. type Options struct { - Driver string + Driver string + Endpoint string + CacheTTL time.Duration } // Factory constructs a PDP for a registered driver. @@ -79,5 +83,14 @@ func Open(opts Options, deps Deps) (PDP, error) { if _, err := DefaultManifest(); err != nil { return nil, fmt.Errorf("authz: load manifest: %w", err) } - return factory(opts, deps) + pdp, err := factory(opts, deps) + if err != nil { + return nil, err + } + // Optional short-TTL decision cache. Off by default; the in-process builtin gains + // nothing from it, but operators may enable it for remote PDPs to absorb the hop. + if opts.CacheTTL > 0 { + pdp = newCachingPDP(pdp, opts.CacheTTL, deps.Logger) + } + return pdp, nil } diff --git a/internal/config/authz.go b/internal/config/authz.go index 6907f53a..9a328cef 100644 --- a/internal/config/authz.go +++ b/internal/config/authz.go @@ -2,6 +2,7 @@ package config import ( "strings" + "time" "github.com/spf13/viper" ) @@ -14,11 +15,15 @@ const ( ) // AuthzConfig configures the central authorization layer. Driver selects the PDP engine -// (only "builtin" is implemented in Phase 1); FailMode controls how the PEP behaves when -// the PDP is unavailable ("closed" denies, "open" allows). +// ("builtin" in-process, or "authzen" for any remote AuthZen-compliant PDP); FailMode +// controls how the PEP behaves when the PDP is unavailable ("closed" denies, "open" +// allows). Endpoint is the remote PDP's single-evaluation URL (authzen driver only), and +// CacheTTL optionally caches decisions for that long to absorb the network hop (0 = off). type AuthzConfig struct { - Driver string `mapstructure:"driver" json:"driver"` - FailMode string `mapstructure:"fail_mode" json:"failMode"` + Driver string `mapstructure:"driver" json:"driver"` + FailMode string `mapstructure:"fail_mode" json:"failMode"` + Endpoint string `mapstructure:"endpoint" json:"endpoint"` + CacheTTL time.Duration `mapstructure:"cache_ttl" json:"cacheTtl"` } // LoadAuthzConfig reads authz settings from Viper, applying defaults. Any fail mode other @@ -32,5 +37,26 @@ func LoadAuthzConfig() *AuthzConfig { if failMode != "open" { failMode = DefaultAuthzFailMode } - return &AuthzConfig{Driver: driver, FailMode: failMode} + // Endpoint is a URL — keep its original casing, only trim surrounding quotes/space. + endpoint := strings.TrimSpace(stripQuotes(viper.GetString("authz_endpoint"))) + return &AuthzConfig{ + Driver: driver, + FailMode: failMode, + Endpoint: endpoint, + CacheTTL: parseAuthzCacheTTL(stripQuotes(viper.GetString("authz_cache_ttl"))), + } +} + +// parseAuthzCacheTTL parses a duration string (e.g. "5s"); an empty, invalid, or negative +// value disables the cache (0). +func parseAuthzCacheTTL(s string) time.Duration { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + d, err := time.ParseDuration(s) + if err != nil || d < 0 { + return 0 + } + return d }