From 15cfe24768b1f7c0b436498e4f8ce09df463a3ca Mon Sep 17 00:00:00 2001 From: kanywst Date: Wed, 13 May 2026 03:45:41 +0900 Subject: [PATCH 1/2] feat(authzen): Search APIs in a candidate-set variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements AuthZEN 1.0 §5.3 Search APIs as three endpoints: POST /access/v1/search/subject — body has `subjects` list POST /access/v1/search/resource — body has `resources` list POST /access/v1/search/action — body has `actions` list Each endpoint takes an explicit candidate list for the dimension being searched plus a fully-specified `(subject, action, resource)` triple where the other two slots are concrete, runs every candidate through the same PDP path as POST /access/v1/evaluation, and returns those whose decision is `allow`. Pagination is offset/size with an `offset:N` continuation token (opaque tokens add no value when the caller already supplies the candidate list). Each candidate emits one audit-log row tagged with `search: {dimension, index}` so operators can grep by API surface. Why not the spec's pattern shape: AuthZEN §5.3.2 says the PDP MUST error when it cannot resolve the search space. Cedar (omega's default PDP) has no global principal / resource / action directory; a `subject: {type: "user"}` request with no id would either need an entity store on the omega side or it would 400. Candidate-set is the honest, useful refinement of "MUST error" and matches what callers typically have on hand anyway (a list of users in this tenant, a list of paths to check, etc.). An entity-store mode is tracked as a follow-up. Per-candidate cap is `MaxSearchCandidates = 100`, same rationale as `MaxBatchEvaluations` — each candidate holds the AppendAudit mutex for one round, so unbounded fan-in is a DoS surface against the hash-chain writer. Discovery doc at /.well-known/authzen-configuration now advertises all three endpoints. Moves docs/conformance-authzen.md §4.3 from `deferred` to `partial` and updates §8 to reflect the new fields. OpenAPI gets routes + schemas. Five new tests cover the allow path for each dimension, plus empty-list and oversized-list rejection. --- CHANGELOG.md | 18 ++ api/openapi.yaml | 217 +++++++++++++++++++ docs/conformance-authzen.md | 4 +- internal/server/api/http.go | 343 ++++++++++++++++++++++++++++++- internal/server/api/http_test.go | 158 ++++++++++++++ 5 files changed, 732 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e3119d..81f02fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,24 @@ changes (see [SECURITY.md](SECURITY.md)). `--ca-step-ca-provisioner`, `--ca-step-ca-provisioner-key-file`, `--ca-step-ca-ca-cert`. Validates the Plugin pattern on a second upstream signer. +- `POST /access/v1/search/{subject,resource,action}` — AuthZEN 1.0 + §5.3 Search APIs in a candidate-set variant. Each endpoint takes + an explicit list of candidates for the dimension being searched + (`subjects` / `resources` / `actions`) plus the two other + fully-specified dimensions, runs every candidate through the same + Cedar PDP path as `POST /access/v1/evaluation`, and returns those + whose decision is `allow`. The spec's pattern shape + (`subject: {type: "user"}` with no id) is rejected because Cedar + has no global principal directory and §5.3.2 expressly tells PDPs + to error when they cannot resolve the search space - a + candidate-list refinement is the honest take on that contract. + Pagination via offset/size with an `offset:N` continuation token, + per-candidate cap of `MaxSearchCandidates = 100` (same rationale + as the batch endpoint), audit row per evaluation tagged with the + search dimension + index. The discovery document at + `/.well-known/authzen-configuration` now advertises all three + endpoints. Moves `docs/conformance-authzen.md` §4.3 from + `deferred` to `partial`. - Federation pump now consumes peers via `GET /v1/spiffe-bundle` (SPIFFE Trust Domain Format), falling back to `GET /v1/bundle` PEM when the peer returns 404 - so a freshly-built omega still diff --git a/api/openapi.yaml b/api/openapi.yaml index c51e3f9..845c961 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -448,6 +448,93 @@ paths: "503": $ref: "#/components/responses/NotLeader" + /access/v1/search/subject: + post: + tags: [authzen, leader-only] + operationId: searchSubject + summary: AuthZEN 1.0 Subject Search (candidate-set variant). + description: | + Searches a caller-supplied list of subject candidates against + a fully specified `(action, resource)` pair and returns those + whose decision is `allow`. Omega does not maintain a global + principal directory, so the caller supplies the candidates + explicitly; the spec's §5.3.2 expressly allows the PDP to + error when it cannot resolve the search space, and a + candidate list is a strict refinement of that contract. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SubjectSearchRequest" + responses: + "200": + description: Matched subjects. + content: + application/json: + schema: + $ref: "#/components/schemas/SubjectSearchResponse" + "400": + $ref: "#/components/responses/BadRequest" + "503": + $ref: "#/components/responses/NotLeader" + + /access/v1/search/resource: + post: + tags: [authzen, leader-only] + operationId: searchResource + summary: AuthZEN 1.0 Resource Search (candidate-set variant). + description: | + Searches a caller-supplied list of resource candidates against + a fully specified `(subject, action)` pair and returns those + whose decision is `allow`. Same candidate-set rationale as + Subject Search. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResourceSearchRequest" + responses: + "200": + description: Matched resources. + content: + application/json: + schema: + $ref: "#/components/schemas/ResourceSearchResponse" + "400": + $ref: "#/components/responses/BadRequest" + "503": + $ref: "#/components/responses/NotLeader" + + /access/v1/search/action: + post: + tags: [authzen, leader-only] + operationId: searchAction + summary: AuthZEN 1.0 Action Search (candidate-set variant). + description: | + Searches a caller-supplied list of action candidates against + a fully specified `(subject, resource)` pair and returns those + whose decision is `allow`. Same candidate-set rationale as + Subject Search. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ActionSearchRequest" + responses: + "200": + description: Matched actions. + content: + application/json: + schema: + $ref: "#/components/schemas/ActionSearchResponse" + "400": + $ref: "#/components/responses/BadRequest" + "503": + $ref: "#/components/responses/NotLeader" + /v1/oidc/exchange: post: tags: [oidc-federation, leader-only] @@ -867,6 +954,9 @@ components: - policy_decision_point - access_evaluation_endpoint - access_evaluations_endpoint + - subject_search_endpoint + - resource_search_endpoint + - action_search_endpoint properties: policy_decision_point: type: string @@ -880,6 +970,133 @@ components: type: string format: uri description: Absolute URL of the batch endpoint `POST /access/v1/evaluations`. + subject_search_endpoint: + type: string + format: uri + description: Absolute URL of `POST /access/v1/search/subject`. + resource_search_endpoint: + type: string + format: uri + description: Absolute URL of `POST /access/v1/search/resource`. + action_search_endpoint: + type: string + format: uri + description: Absolute URL of `POST /access/v1/search/action`. + + SearchPage: + type: object + additionalProperties: false + properties: + size: + type: integer + description: Page size echoed back (or selected by omega when omitted). + offset: + type: integer + description: Offset into the matched-candidate list. + next_token: + type: string + description: | + Opaque continuation token (`offset:N` today). Present + only when more matches remain. + + SubjectSearchRequest: + type: object + additionalProperties: false + required: [subjects, action, resource] + properties: + subjects: + type: array + description: | + Caller-supplied candidate subjects to test. omega does not + enumerate principals on its own; the spec's §5.3.2 allows + the PDP to error when it cannot resolve the search space, + and a candidate list refines that contract into something + useful. + items: + $ref: "#/components/schemas/EvalEntity" + action: + $ref: "#/components/schemas/EvalAction" + resource: + $ref: "#/components/schemas/EvalEntity" + context: + type: object + additionalProperties: true + page: + $ref: "#/components/schemas/SearchPage" + + SubjectSearchResponse: + type: object + additionalProperties: false + required: [results] + properties: + results: + type: array + items: + $ref: "#/components/schemas/EvalEntity" + page: + $ref: "#/components/schemas/SearchPage" + + ResourceSearchRequest: + type: object + additionalProperties: false + required: [resources, subject, action] + properties: + resources: + type: array + items: + $ref: "#/components/schemas/EvalEntity" + subject: + $ref: "#/components/schemas/EvalEntity" + action: + $ref: "#/components/schemas/EvalAction" + context: + type: object + additionalProperties: true + page: + $ref: "#/components/schemas/SearchPage" + + ResourceSearchResponse: + type: object + additionalProperties: false + required: [results] + properties: + results: + type: array + items: + $ref: "#/components/schemas/EvalEntity" + page: + $ref: "#/components/schemas/SearchPage" + + ActionSearchRequest: + type: object + additionalProperties: false + required: [actions, subject, resource] + properties: + actions: + type: array + items: + $ref: "#/components/schemas/EvalAction" + subject: + $ref: "#/components/schemas/EvalEntity" + resource: + $ref: "#/components/schemas/EvalEntity" + context: + type: object + additionalProperties: true + page: + $ref: "#/components/schemas/SearchPage" + + ActionSearchResponse: + type: object + additionalProperties: false + required: [results] + properties: + results: + type: array + items: + $ref: "#/components/schemas/EvalAction" + page: + $ref: "#/components/schemas/SearchPage" OIDCDiscoveryResponse: type: object diff --git a/docs/conformance-authzen.md b/docs/conformance-authzen.md index 04176db..7bbc20b 100644 --- a/docs/conformance-authzen.md +++ b/docs/conformance-authzen.md @@ -39,7 +39,7 @@ Spec version audited: | --- | --- | --- | --- | | 4.1 | A request carries exactly one subject, one action, one resource | implemented | enforced by the JSON schema in `api/openapi.yaml` (single-decision endpoint) | | 4.2 | Batch requests use top-level defaults + per-entry overrides | implemented | merged in `mergeBatchEval`; missing required field after merge returns 400 | -| 4.3 | Search requests partially specify the request | deferred | the Search APIs in §5.3 are not yet implemented | +| 4.3 | Search requests partially specify the request | partial | `POST /access/v1/search/{subject,resource,action}` ship in a candidate-set variant: the dimension being searched arrives as an explicit list (`subjects`/`resources`/`actions`) and the other two are fully specified. The spec's `{type: "user"}` pattern shape with no id is rejected because omega's PDP (Cedar) has no global principal directory and §5.3.2 expressly tells PDPs to error when they cannot resolve the search space. A full enumeration mode would require an entity store on the omega side, tracked as a follow-up | ## §5 — Endpoints @@ -91,7 +91,7 @@ Spec version audited: | Section | Requirement | Status | omega notes | | --- | --- | --- | --- | -| 8 | Discovery document advertises supported endpoints | implemented | `GET /.well-known/authzen-configuration` returns `policy_decision_point` + `access_evaluation_endpoint` + `access_evaluations_endpoint`. The PDP base is `--issuer-url` (canonical, validated `https`); the handler returns `404` when `--issuer-url` is not set so the PDP base cannot be sourced from a spoofed `Host` header. The three Search API endpoints are intentionally omitted because omega does not implement them - per §8 an absent field signals "not implemented" | +| 8 | Discovery document advertises supported endpoints | implemented | `GET /.well-known/authzen-configuration` returns the five endpoint fields: `policy_decision_point`, `access_evaluation_endpoint`, `access_evaluations_endpoint`, `subject_search_endpoint`, `resource_search_endpoint`, `action_search_endpoint`. The PDP base is `--issuer-url` (canonical, validated `https`); the handler returns `404` when `--issuer-url` is not set so the PDP base cannot be sourced from a spoofed `Host` header | ## §9 — Security considerations diff --git a/internal/server/api/http.go b/internal/server/api/http.go index 83242a5..1569cb8 100644 --- a/internal/server/api/http.go +++ b/internal/server/api/http.go @@ -134,6 +134,9 @@ func (s *Server) Handler() http.Handler { handle("GET /v1/spiffe-bundle", s.getSPIFFEBundle) handle("POST /access/v1/evaluation", leaderOnly(s.evaluateAccess)) handle("POST /access/v1/evaluations", leaderOnly(s.evaluateAccessBatch)) + handle("POST /access/v1/search/subject", leaderOnly(s.searchSubject)) + handle("POST /access/v1/search/resource", leaderOnly(s.searchResource)) + handle("POST /access/v1/search/action", leaderOnly(s.searchAction)) handle("GET /v1/audit", s.listAudit) handle("GET /v1/audit/verify", s.verifyAudit) handle("POST /v1/svid/jwt", leaderOnly(s.issueJWTSVID)) @@ -298,16 +301,19 @@ func (s *Server) getOIDCDiscovery(w http.ResponseWriter, _ *http.Request) { // AuthzenDiscoveryResponse is the discovery document advertised at // /.well-known/authzen-configuration. The shape matches OpenID AuthZEN -// 1.0 §8: `policy_decision_point` is the PDP base, and only the -// endpoints Omega actually implements are advertised. The three Search -// API endpoints (`subject_search_endpoint`, `resource_search_endpoint`, -// `action_search_endpoint`) are deliberately omitted - per §8 an -// absent field signals the endpoint is not implemented, which is the -// honest answer until Omega ships them. +// 1.0 §8: `policy_decision_point` is the PDP base; only the endpoints +// Omega actually implements are advertised. The three Search API +// endpoints (`subject_search_endpoint`, `resource_search_endpoint`, +// `action_search_endpoint`) are present because Omega ships +// candidate-set-based Search; the deviation from the spec's pattern +// shape is documented on the search handlers themselves. type AuthzenDiscoveryResponse struct { PolicyDecisionPoint string `json:"policy_decision_point"` AccessEvaluationEndpoint string `json:"access_evaluation_endpoint"` AccessEvaluationsEndpoint string `json:"access_evaluations_endpoint"` + SubjectSearchEndpoint string `json:"subject_search_endpoint"` + ResourceSearchEndpoint string `json:"resource_search_endpoint"` + ActionSearchEndpoint string `json:"action_search_endpoint"` } func (s *Server) getAuthzenDiscovery(w http.ResponseWriter, _ *http.Request) { @@ -327,6 +333,9 @@ func (s *Server) getAuthzenDiscovery(w http.ResponseWriter, _ *http.Request) { PolicyDecisionPoint: base, AccessEvaluationEndpoint: base + "/access/v1/evaluation", AccessEvaluationsEndpoint: base + "/access/v1/evaluations", + SubjectSearchEndpoint: base + "/access/v1/search/subject", + ResourceSearchEndpoint: base + "/access/v1/search/resource", + ActionSearchEndpoint: base + "/access/v1/search/action", }) } @@ -795,6 +804,328 @@ func mergeBatchEval(top BatchEvalRequest, sub BatchEvalSubrequest) (policy.EvalR }, nil } +// AuthZEN 1.0 §5.3 Search APIs. +// +// The spec's Search request describes a partially-specified target +// (e.g. `subject: {type: "user"}` with no id) and leaves candidate +// enumeration to the PDP. omega's default PDP is Cedar, which has no +// global principal / resource / action directory - omega therefore +// cannot enumerate candidates on its own and the spec (§5.3.2) +// explicitly tells PDPs to error when they cannot resolve a search. +// +// To make the endpoints useful in practice rather than just spec- +// compliantly returning errors, omega accepts an explicit candidate +// list in the request: +// +// { +// "candidates": [ {entity}, ... ], // the dimension being searched +// "subject": {entity}, // the two other dimensions are +// "action": {action}, // fully specified +// "resource": {entity}, +// "context": {...}, +// "page": {"size": N, "offset": M} +// } +// +// For each candidate, omega builds a complete EvalRequest, runs it +// through the same PDP path as `POST /access/v1/evaluation`, and +// returns only those candidates whose decision is `allow`. Pagination +// is offset/size; opaque tokens add no value when the caller already +// supplies the candidate list. +// +// Every candidate evaluation is audited the same way single +// evaluations are, so the hash chain records one row per decision +// the PDP made on behalf of this Search. + +// SearchPage controls the optional offset/size pagination on Search +// responses. The spec defines opaque `next_token` pagination; we use +// offsets because omega's candidate set is supplied by the caller and +// is already enumerable. +type SearchPage struct { + Size int `json:"size,omitempty"` + Offset int `json:"offset,omitempty"` + NextToken string `json:"next_token,omitempty"` +} + +// MaxSearchCandidates caps the per-request candidate list. Each +// candidate runs a full PDP evaluation and emits one audit row, so +// the bound prevents a single Search from monopolising the hash-chain +// writer. Same rationale as MaxBatchEvaluations; same value. +const MaxSearchCandidates = 100 + +// SubjectSearchRequest is the request body for +// `POST /access/v1/search/subject`. `subjects` is the candidate list +// of subjects to test against the (action, resource) pair. +type SubjectSearchRequest struct { + Subjects []policy.Entity `json:"subjects"` + Action policy.Action `json:"action"` + Resource policy.Entity `json:"resource"` + Context map[string]any `json:"context,omitempty"` + Page *SearchPage `json:"page,omitempty"` +} + +// ResourceSearchRequest mirrors SubjectSearchRequest with the search +// dimension on `resources` instead. +type ResourceSearchRequest struct { + Resources []policy.Entity `json:"resources"` + Subject policy.Entity `json:"subject"` + Action policy.Action `json:"action"` + Context map[string]any `json:"context,omitempty"` + Page *SearchPage `json:"page,omitempty"` +} + +// ActionSearchRequest searches over a candidate list of action names +// against the same (subject, resource) pair. +type ActionSearchRequest struct { + Actions []policy.Action `json:"actions"` + Subject policy.Entity `json:"subject"` + Resource policy.Entity `json:"resource"` + Context map[string]any `json:"context,omitempty"` + Page *SearchPage `json:"page,omitempty"` +} + +// SubjectSearchResponse / ResourceSearchResponse / ActionSearchResponse +// each carry the matched candidates. AuthZEN §5.3.4 names the field +// `results`. +type SubjectSearchResponse struct { + Results []policy.Entity `json:"results"` + Page *SearchPage `json:"page,omitempty"` +} + +type ResourceSearchResponse struct { + Results []policy.Entity `json:"results"` + Page *SearchPage `json:"page,omitempty"` +} + +type ActionSearchResponse struct { + Results []policy.Action `json:"results"` + Page *SearchPage `json:"page,omitempty"` +} + +func (s *Server) searchSubject(w http.ResponseWriter, r *http.Request) { + var req SubjectSearchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid body: %w", err)) + return + } + if len(req.Subjects) == 0 { + writeErr(w, http.StatusBadRequest, errors.New("subjects: candidate list is required (omega's PDP cannot enumerate principals)")) + return + } + if len(req.Subjects) > MaxSearchCandidates { + writeErr(w, http.StatusBadRequest, + fmt.Errorf("subjects: too many candidates: %d (max %d); fan out on the client", len(req.Subjects), MaxSearchCandidates)) + return + } + ctx, span := tracer.Start(r.Context(), "policy.SearchSubject", + trace.WithAttributes( + attribute.Int("authzen.search.candidates", len(req.Subjects)), + attribute.String("authzen.action", req.Action.Name), + attribute.String("authzen.resource.type", req.Resource.Type), + attribute.String("authzen.resource.id", req.Resource.ID), + ), + ) + defer span.End() + + var matched []policy.Entity + for i := range req.Subjects { + eval := policy.EvalRequest{ + Subject: req.Subjects[i], + Action: req.Action, + Resource: req.Resource, + Context: req.Context, + } + decision, ok := s.evaluateForSearch(ctx, eval, span, "subject", i) + if !ok { + writeErr(w, http.StatusBadRequest, fmt.Errorf("subjects[%d]: evaluation failed", i)) + return + } + if decision { + matched = append(matched, req.Subjects[i]) + } + } + results, page := paginate(matched, req.Page) + writeJSON(w, http.StatusOK, SubjectSearchResponse{Results: results, Page: page}) +} + +func (s *Server) searchResource(w http.ResponseWriter, r *http.Request) { + var req ResourceSearchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid body: %w", err)) + return + } + if len(req.Resources) == 0 { + writeErr(w, http.StatusBadRequest, errors.New("resources: candidate list is required")) + return + } + if len(req.Resources) > MaxSearchCandidates { + writeErr(w, http.StatusBadRequest, + fmt.Errorf("resources: too many candidates: %d (max %d); fan out on the client", len(req.Resources), MaxSearchCandidates)) + return + } + ctx, span := tracer.Start(r.Context(), "policy.SearchResource", + trace.WithAttributes( + attribute.Int("authzen.search.candidates", len(req.Resources)), + attribute.String("authzen.subject.id", req.Subject.ID), + attribute.String("authzen.subject.type", req.Subject.Type), + attribute.String("authzen.action", req.Action.Name), + ), + ) + defer span.End() + + var matched []policy.Entity + for i := range req.Resources { + eval := policy.EvalRequest{ + Subject: req.Subject, + Action: req.Action, + Resource: req.Resources[i], + Context: req.Context, + } + decision, ok := s.evaluateForSearch(ctx, eval, span, "resource", i) + if !ok { + writeErr(w, http.StatusBadRequest, fmt.Errorf("resources[%d]: evaluation failed", i)) + return + } + if decision { + matched = append(matched, req.Resources[i]) + } + } + results, page := paginate(matched, req.Page) + writeJSON(w, http.StatusOK, ResourceSearchResponse{Results: results, Page: page}) +} + +func (s *Server) searchAction(w http.ResponseWriter, r *http.Request) { + var req ActionSearchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid body: %w", err)) + return + } + if len(req.Actions) == 0 { + writeErr(w, http.StatusBadRequest, errors.New("actions: candidate list is required")) + return + } + if len(req.Actions) > MaxSearchCandidates { + writeErr(w, http.StatusBadRequest, + fmt.Errorf("actions: too many candidates: %d (max %d); fan out on the client", len(req.Actions), MaxSearchCandidates)) + return + } + ctx, span := tracer.Start(r.Context(), "policy.SearchAction", + trace.WithAttributes( + attribute.Int("authzen.search.candidates", len(req.Actions)), + attribute.String("authzen.subject.id", req.Subject.ID), + attribute.String("authzen.resource.type", req.Resource.Type), + attribute.String("authzen.resource.id", req.Resource.ID), + ), + ) + defer span.End() + + matched := make([]policy.Action, 0, len(req.Actions)) + for i := range req.Actions { + eval := policy.EvalRequest{ + Subject: req.Subject, + Action: req.Actions[i], + Resource: req.Resource, + Context: req.Context, + } + decision, ok := s.evaluateForSearch(ctx, eval, span, "action", i) + if !ok { + writeErr(w, http.StatusBadRequest, fmt.Errorf("actions[%d]: evaluation failed", i)) + return + } + if decision { + matched = append(matched, req.Actions[i]) + } + } + results, page := paginateActions(matched, req.Page) + writeJSON(w, http.StatusOK, ActionSearchResponse{Results: results, Page: page}) +} + +// evaluateForSearch runs one candidate through the PDP and emits an +// audit row. Returns (decision, ok). On evaluation error it records +// the failure on the parent span and returns false so the caller can +// short-circuit with a 400. The audit kind is the same `access.evaluate` +// the single and batch endpoints use, with a `search` discriminator +// in the payload so operators can grep by API surface. +func (s *Server) evaluateForSearch(ctx context.Context, eval policy.EvalRequest, span trace.Span, dimension string, index int) (bool, bool) { + start := time.Now() + resp, err := s.policy.Evaluate(eval) + metrics.DecisionLatency.Observe(time.Since(start).Seconds()) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "evaluate") + return false, false + } + decision := "deny" + if resp.Decision { + decision = "allow" + } + metrics.Decisions.WithLabelValues(decision).Inc() + s.audit(ctx, storage.AuditEvent{ + Kind: "access.evaluate", + Subject: eval.Subject.ID, + Decision: decision, + Payload: mustJSON(map[string]any{ + "request": eval, + "response": resp, + "search": map[string]any{"dimension": dimension, "index": index}, + }), + }) + return resp.Decision, true +} + +// paginate applies the request's offset/size to the matched-entity +// list and returns the slice plus the page envelope echoed back to +// the caller (with a next_token marker when more results remain). +func paginate(matched []policy.Entity, page *SearchPage) ([]policy.Entity, *SearchPage) { + offset, size := 0, len(matched) + if page != nil { + if page.Offset > 0 { + offset = page.Offset + } + if page.Size > 0 && page.Size < size-offset { + size = page.Size + } else { + size = max(0, len(matched)-offset) + } + } + if offset >= len(matched) { + return []policy.Entity{}, &SearchPage{Size: 0, Offset: offset} + } + end := min(offset+size, len(matched)) + out := SearchPage{Size: size, Offset: offset} + if end < len(matched) { + out.NextToken = fmt.Sprintf("offset:%d", end) + } + if matched[offset:end] == nil { + return []policy.Entity{}, &out + } + return matched[offset:end], &out +} + +// paginateActions is the policy.Action variant. The trivial element +// type difference does not justify generics here. +func paginateActions(matched []policy.Action, page *SearchPage) ([]policy.Action, *SearchPage) { + offset, size := 0, len(matched) + if page != nil { + if page.Offset > 0 { + offset = page.Offset + } + if page.Size > 0 && page.Size < size-offset { + size = page.Size + } else { + size = max(0, len(matched)-offset) + } + } + if offset >= len(matched) { + return []policy.Action{}, &SearchPage{Size: 0, Offset: offset} + } + end := min(offset+size, len(matched)) + out := SearchPage{Size: size, Offset: offset} + if end < len(matched) { + out.NextToken = fmt.Sprintf("offset:%d", end) + } + return matched[offset:end], &out +} + func (s *Server) listAudit(w http.ResponseWriter, r *http.Request) { since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) diff --git a/internal/server/api/http_test.go b/internal/server/api/http_test.go index 2e37ef6..d9d6453 100644 --- a/internal/server/api/http_test.go +++ b/internal/server/api/http_test.go @@ -354,6 +354,164 @@ func TestHTTPAccessEvaluationsRejectsIncompleteSubrequest(t *testing.T) { } } +func TestHTTPSearchSubjectFiltersCandidates(t *testing.T) { + pdp := policy.New() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "p.cedar"), []byte(`permit ( + principal == Spiffe::"spiffe://omega.local/alice", + action == Action::"GET", + resource == HttpPath::"/api/foo" +); +`), 0o644); err != nil { + t.Fatalf("write policy: %v", err) + } + if err := pdp.LoadDir(dir); err != nil { + t.Fatalf("load policy: %v", err) + } + srv := newTestServerWithPolicy(t, pdp) + + body := []byte(`{ + "subjects": [ + {"type":"Spiffe","id":"spiffe://omega.local/alice"}, + {"type":"Spiffe","id":"spiffe://omega.local/bob"} + ], + "action": {"name":"GET"}, + "resource": {"type":"HttpPath","id":"/api/foo"} +}`) + resp, err := http.Post(srv.URL+"/access/v1/search/subject", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("post: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status: got %d want 200 (body=%s)", resp.StatusCode, raw) + } + var out api.SubjectSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out.Results) != 1 || out.Results[0].ID != "spiffe://omega.local/alice" { + t.Errorf("results: got %v want [alice]", out.Results) + } +} + +func TestHTTPSearchResourceFiltersCandidates(t *testing.T) { + pdp := policy.New() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "p.cedar"), []byte(`permit ( + principal == Spiffe::"spiffe://omega.local/alice", + action == Action::"GET", + resource == HttpPath::"/api/foo" +); +`), 0o644); err != nil { + t.Fatalf("write policy: %v", err) + } + if err := pdp.LoadDir(dir); err != nil { + t.Fatalf("load policy: %v", err) + } + srv := newTestServerWithPolicy(t, pdp) + + body := []byte(`{ + "resources": [ + {"type":"HttpPath","id":"/api/foo"}, + {"type":"HttpPath","id":"/api/bar"} + ], + "subject": {"type":"Spiffe","id":"spiffe://omega.local/alice"}, + "action": {"name":"GET"} +}`) + resp, err := http.Post(srv.URL+"/access/v1/search/resource", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("post: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status: got %d want 200 (body=%s)", resp.StatusCode, raw) + } + var out api.ResourceSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out.Results) != 1 || out.Results[0].ID != "/api/foo" { + t.Errorf("results: got %v want [/api/foo]", out.Results) + } +} + +func TestHTTPSearchActionFiltersCandidates(t *testing.T) { + pdp := policy.New() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "p.cedar"), []byte(`permit ( + principal == Spiffe::"spiffe://omega.local/alice", + action == Action::"GET", + resource == HttpPath::"/api/foo" +); +`), 0o644); err != nil { + t.Fatalf("write policy: %v", err) + } + if err := pdp.LoadDir(dir); err != nil { + t.Fatalf("load policy: %v", err) + } + srv := newTestServerWithPolicy(t, pdp) + + body := []byte(`{ + "actions": [{"name":"GET"}, {"name":"DELETE"}], + "subject": {"type":"Spiffe","id":"spiffe://omega.local/alice"}, + "resource": {"type":"HttpPath","id":"/api/foo"} +}`) + resp, err := http.Post(srv.URL+"/access/v1/search/action", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("post: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status: got %d want 200 (body=%s)", resp.StatusCode, raw) + } + var out api.ActionSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + if len(out.Results) != 1 || out.Results[0].Name != "GET" { + t.Errorf("results: got %v want [GET]", out.Results) + } +} + +func TestHTTPSearchSubjectRejectsEmptyCandidateList(t *testing.T) { + srv := newTestServer(t) + body := []byte(`{"subjects":[],"action":{"name":"GET"},"resource":{"type":"HttpPath","id":"/"}}`) + resp, err := http.Post(srv.URL+"/access/v1/search/subject", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("post: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status: got %d want 400", resp.StatusCode) + } +} + +func TestHTTPSearchSubjectRejectsOversizedCandidateList(t *testing.T) { + srv := newTestServer(t) + n := api.MaxSearchCandidates + 1 + subs := make([]string, n) + for i := range subs { + subs[i] = `{"type":"Spiffe","id":"spiffe://omega.local/x"}` + } + body := []byte(`{ + "subjects": [` + strings.Join(subs, ",") + `], + "action": {"name":"GET"}, + "resource": {"type":"HttpPath","id":"/"} +}`) + resp, err := http.Post(srv.URL+"/access/v1/search/subject", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("post: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status: got %d want 400", resp.StatusCode) + } +} + func TestHTTPAccessEvaluationBadRequest(t *testing.T) { srv := newTestServer(t) body := []byte(`{"subject":{"type":"","id":""},"action":{"name":""},"resource":{"type":"","id":""}}`) From 1c41a23cc68273c3fa376b03028539f02d1b3af1 Mon Sep 17 00:00:00 2001 From: kanywst Date: Wed, 13 May 2026 03:54:30 +0900 Subject: [PATCH 2/2] authzen-search: maxItems on OpenAPI, pre-alloc matched, generic paginate Seven gemini mediums on the original PR, all valid: - OpenAPI subjects / resources / actions arrays now declare `maxItems: 100` so the schema lines up with the runtime MaxSearchCandidates cap. A schema validator that runs ahead of omega now catches an oversized request the same way omega's handler would. - searchSubject and searchResource pre-allocate `matched` with the full candidate-list capacity (searchAction already did). Avoids multiple slice re-allocations during the loop. - paginate/paginateActions were nearly identical; folded into one generic `paginate[T any]` and dropped the duplicate. Drops a redundant `matched[offset:end] == nil` guard that only existed in the entity copy. --- api/openapi.yaml | 6 +++++- internal/server/api/http.go | 42 +++++++------------------------------ 2 files changed, 13 insertions(+), 35 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 845c961..276b505 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1006,12 +1006,14 @@ components: properties: subjects: type: array + maxItems: 100 description: | Caller-supplied candidate subjects to test. omega does not enumerate principals on its own; the spec's §5.3.2 allows the PDP to error when it cannot resolve the search space, and a candidate list refines that contract into something - useful. + useful. Capped at 100 (`MaxSearchCandidates`) for the + same hash-chain-mutex reason as the batch endpoint. items: $ref: "#/components/schemas/EvalEntity" action: @@ -1043,6 +1045,7 @@ components: properties: resources: type: array + maxItems: 100 items: $ref: "#/components/schemas/EvalEntity" subject: @@ -1074,6 +1077,7 @@ components: properties: actions: type: array + maxItems: 100 items: $ref: "#/components/schemas/EvalAction" subject: diff --git a/internal/server/api/http.go b/internal/server/api/http.go index 1569cb8..324d8df 100644 --- a/internal/server/api/http.go +++ b/internal/server/api/http.go @@ -926,7 +926,7 @@ func (s *Server) searchSubject(w http.ResponseWriter, r *http.Request) { ) defer span.End() - var matched []policy.Entity + matched := make([]policy.Entity, 0, len(req.Subjects)) for i := range req.Subjects { eval := policy.EvalRequest{ Subject: req.Subjects[i], @@ -972,7 +972,7 @@ func (s *Server) searchResource(w http.ResponseWriter, r *http.Request) { ) defer span.End() - var matched []policy.Entity + matched := make([]policy.Entity, 0, len(req.Resources)) for i := range req.Resources { eval := policy.EvalRequest{ Subject: req.Subject, @@ -1035,7 +1035,7 @@ func (s *Server) searchAction(w http.ResponseWriter, r *http.Request) { matched = append(matched, req.Actions[i]) } } - results, page := paginateActions(matched, req.Page) + results, page := paginate(matched, req.Page) writeJSON(w, http.StatusOK, ActionSearchResponse{Results: results, Page: page}) } @@ -1072,38 +1072,12 @@ func (s *Server) evaluateForSearch(ctx context.Context, eval policy.EvalRequest, return resp.Decision, true } -// paginate applies the request's offset/size to the matched-entity +// paginate applies the request's offset/size to a matched-candidate // list and returns the slice plus the page envelope echoed back to // the caller (with a next_token marker when more results remain). -func paginate(matched []policy.Entity, page *SearchPage) ([]policy.Entity, *SearchPage) { - offset, size := 0, len(matched) - if page != nil { - if page.Offset > 0 { - offset = page.Offset - } - if page.Size > 0 && page.Size < size-offset { - size = page.Size - } else { - size = max(0, len(matched)-offset) - } - } - if offset >= len(matched) { - return []policy.Entity{}, &SearchPage{Size: 0, Offset: offset} - } - end := min(offset+size, len(matched)) - out := SearchPage{Size: size, Offset: offset} - if end < len(matched) { - out.NextToken = fmt.Sprintf("offset:%d", end) - } - if matched[offset:end] == nil { - return []policy.Entity{}, &out - } - return matched[offset:end], &out -} - -// paginateActions is the policy.Action variant. The trivial element -// type difference does not justify generics here. -func paginateActions(matched []policy.Action, page *SearchPage) ([]policy.Action, *SearchPage) { +// Generic over the candidate element type so subject/resource/action +// search all share one implementation. +func paginate[T any](matched []T, page *SearchPage) ([]T, *SearchPage) { offset, size := 0, len(matched) if page != nil { if page.Offset > 0 { @@ -1116,7 +1090,7 @@ func paginateActions(matched []policy.Action, page *SearchPage) ([]policy.Action } } if offset >= len(matched) { - return []policy.Action{}, &SearchPage{Size: 0, Offset: offset} + return []T{}, &SearchPage{Size: 0, Offset: offset} } end := min(offset+size, len(matched)) out := SearchPage{Size: size, Offset: offset}