diff --git a/docs/security/regression-suite.md b/docs/security/regression-suite.md new file mode 100644 index 0000000..bdd1ba6 --- /dev/null +++ b/docs/security/regression-suite.md @@ -0,0 +1,121 @@ +# Security regression suite + +Status: executable evidence for issue #28 at the current `main` contract. This +report covers repository components that exist. It does not qualify future +production storage, queue, worker, provider-adapter, telemetry-export, or +support-bundle implementations. + +## Public-route security matrix + +[`TestPublicRouteSecurityMatrix`](../../test/security/security_regression_test.go) +derives the complete operation set from the validated OpenAPI artifact. The +test fails if a public operation is added without a matrix row. Its checked-in +result is +[`security-regression-v1alpha1.golden.json`](../../test/security/testdata/security-regression-v1alpha1.golden.json). + +| Operation | Authenticated member | Authenticated outsider | Missing or invalid credential | +| --- | --- | --- | --- | +| `listWorkspaces` | `200`, own row returned; denied row removed before the one-item page | `200`, own row returned; denied row removed before the one-item page | `401` | +| `createWorkspace` | `403`, service-reserved tenant action | `403`, service-reserved tenant action | `401` | +| `getWorkspace` | `200` | `403` | `401` | +| `replaceWorkspace` | `202` | `403` | `401` | +| `deleteWorkspace` | Authorization passes; `409` protects the retained Policy child | `403` | `401` | +| `replaceWorkspaceStatus` | `403`, service-reserved tenant action | `403`, service-reserved tenant action | `401` | +| `getOperation` | `200` | `403` | `401` | + +Workspace creation and status replacement intentionally have no tenant allow +case. The closed authorization matrix reserves both actions. Treating a `2xx` +response as required evidence would weaken the security contract. Their +positive path proves that a valid credential reaches the authorization layer; +their invariant is a tenant `403` for every value in the closed four-role +registry. + +Every inheriting role must also pass the scoped list, Workspace read, and +Operation read cases. Rejected mutations compare both principals' visible +state and a recorder covering every committed resource/operation write and +issued identifier/version, including writes under previously unseen IDs. +Rejected authorized replacements with a valid key also make a corrected retry +on the same fixture with that exact key, then replay the accepted request. +The retry must create a persisted operation; the replay must return the same +receipt without any new store writes or identifier allocation. This observes +idempotency-ledger and replay-map effects that resource snapshots cannot see. +A dedicated regression covers stale preconditions with unchanged and changed +intent, malformed JSON, and malformed request IDs. +Accepted replacement receipts bind to the persisted Workspace generation and +version, and to the new Operation's identity, generation, canonical bytes, and +own ETag. Operation versions are independent of Workspace versions. + +## Retained untrusted-input and fuzz corpus + +The following fuzz targets run their seed corpus during ordinary `go test` and +remain available for time-bounded fuzzing: + +| Boundary | Target | Retained input classes | +| --- | --- | --- | +| Public HTTP routes | `FuzzReferencePublicBoundary` | alternate token carrier, encoded path, duplicate JSON member, unknown credential-like field, trailing JSON, invalid UTF-8, depth bomb, webhook/remote-reference shape, oversized body, invalid credential, minimized invalid idempotency key, malformed request ID, disallowed methods on every route family | +| Bearer extraction | `FuzzExtractBearer`, `FuzzCredentialTrailerScrubbing` | header/query/cookie/trailer ambiguity, divergent query views, body and trailer aliasing, arbitrary casing | +| OIDC/JWKS | deterministic negative corpus in `internal/adapters/identity/oidc` | non-canonical JWT segments, duplicate members, forbidden remote key headers, claim failures, redirect/cookie response state, malformed and over-bound keys | +| Resource admission | `FuzzAdmitRaw` | every resource kind, duplicate and unknown fields, invalid Unicode, depth/node/byte bounds, trailing values | +| Closed domain values | fuzz targets under `internal/core/domain` | invalid sum types, identifiers, hierarchy, role/action vocabulary, operations, conditions, audit canonicalization, credentials, and reconciliation digests | + +The public-boundary target sends malformed body seeds through the +member-authorized Workspace replacement route so they reach admission parsing. +It asserts panic freedom, bounded JSON responses, mandatory +no-store/nosniff/correlation headers, and bearer-canary absence. Every runtime +problem response must use unique JSON members and a closed code/status/title +tuple, bind its request ID and problem URNs, bind required retry body/header +metadata, stay below 1,024 bytes, contain at most one field violation, and +respect the field-path and text bounds. Valid client request IDs must be echoed +exactly; malformed supplied IDs must receive `400 validation-failed`. A `405` +requires one exact route-specific `Allow` header, and an authorized stale +precondition requires the current singleton ETag. Successful JSON must contain +valid UTF-8 and unique members; decoded canonical JSON is scanned alongside +wire bytes so escaped canaries cannot bypass the confidentiality checks. +The OpenAPI validator independently +rejects remote servers, remote references, and webhook expansion. No provider +response parser exists yet; provider-specific malicious-response corpora belong +with the adapters introduced by issues #38 and #41. + +Run the public-boundary fuzzer for one minute with: + +```sh +go test ./test/security -run '^$' -fuzz '^FuzzReferencePublicBoundary$' -fuzztime=1m +``` + +## Redaction report + +| Surface named by issue #28 | Implemented evidence | Result | +| --- | --- | --- | +| Plans and reconciliation state | `TestOpaqueValuesRejectGenericSerializationAndRedactDiagnostics` covers plans, evidence, effects, attempts, leases, queue/idempotency state, proofs, and `slog` output | Secret and identity canaries absent; generic serialization rejected | +| Credential state | `TestCredentialValuesRedactAndRejectSerialization`, `FuzzSourceMaterialSafety`, and broker lease/lifecycle tests cover source/session material, requests, brokers, leases, rotation, errors, and destruction | Raw material remains callback-bounded; diagnostics redact; serialization rejected | +| Identity and bearer state | Identity diagnostic/serialization tests, bearer canary tests, OIDC negative corpus, and the route matrix cover principals, request carriers, challenges, problems, and headers | Raw token and identity claims absent from output; rejected request carriers scrubbed | +| Audit and privileged state | `TestOpaqueRuntimeValuesForbidLossyGenericSerialization` and `TestAdministrationDiagnosticsAndSerializationAreSafe` cover audit/elevation values, nested containers, errors, formatting, and `slog` | Canaries absent; opaque runtime values reject lossy serialization | +| HTTP errors | Route matrix and `FuzzReferencePublicBoundary` exercise every public route plus malformed/bounded input | Only closed problem code/status/title tuples and bounded field paths are returned; bearer, workspace, Policy, Member, operation, resource-version, and identity canaries are absent from denied responses outside the required request-ID correlation fields; retained negative seeds must preserve their exact rejection status | +| Logs | Package redaction tests send every sensitive value through `fmt` and `slog` | Canaries absent from implemented logging surfaces | +| Traces | No tracer or trace exporter exists | Not applicable at this revision; issue #63 must add canaries with the first implementation | +| Metrics | No metrics registry or exporter exists | Not applicable at this revision; issue #63 must add label/value canaries and cardinality bounds | +| Support output | No support-bundle or diagnostic export exists | Not applicable at this revision; the implementing issue must add a closed allowlist and canary corpus | + +This report proves current executable behavior only. A future component does +not inherit an “issue #28 passed” claim: its pull request must extend this +matrix, fuzz corpus, and redaction table before merge. + +## Verification + +```sh +go test ./test/security -count=1 +go test ./test/security -run '^Test' -count=100 +go test ./internal/transport/http ./internal/adapters/identity/oidc \ + ./internal/core/domain/admission ./internal/core/domain/credential \ + ./internal/core/service/credentialbroker ./internal/core/domain/reconciliation \ + ./internal/core/domain/audit ./internal/core/domain/administration -count=1 +./hack/dev security +./hack/dev check +./hack/dev race +./hack/dev coverage +``` + +The OAuth token restrictions are cross-checked against +[RFC 9700](https://www.rfc-editor.org/rfc/rfc9700.html), and the retained +regression practice follows the verification intent of +[NIST SP 800-218 SSDF](https://csrc.nist.gov/pubs/sp/800/218/final). diff --git a/internal/core/domain/reconciliation/idempotency.go b/internal/core/domain/reconciliation/idempotency.go index a37268d..b746a95 100644 --- a/internal/core/domain/reconciliation/idempotency.go +++ b/internal/core/domain/reconciliation/idempotency.go @@ -153,7 +153,7 @@ func (ledger *IdempotencyLedger) Reserve( fingerprint RequestFingerprint, ) (Reservation, IdempotencyDisposition, error) { if ledger == nil || !ledger.initialized || !scope.initialized || !scope.digest.initialized || - !validIdempotencyKey(key) || !fingerprint.initialized { + ValidateIdempotencyKey(key) != nil || !fingerprint.initialized { return Reservation{}, "", ErrInvalidIdempotency } now, err := normalizeTime(databaseTime) @@ -417,9 +417,14 @@ func validateReservation(value Reservation) error { return nil } -func validIdempotencyKey(value string) bool { - return len(value) >= MinIdempotencyKeyBytes && len(value) <= MaxIdempotencyKeyBytes && - !strings.ContainsAny(value, "\r\n") && idempotencyKeyPattern.MatchString(value) +// ValidateIdempotencyKey enforces the public mutation-key grammar without +// reserving ledger state. +func ValidateIdempotencyKey(value string) error { + if len(value) < MinIdempotencyKeyBytes || len(value) > MaxIdempotencyKeyBytes || + strings.ContainsAny(value, "\r\n") || !idempotencyKeyPattern.MatchString(value) { + return ErrInvalidIdempotency + } + return nil } func validMutationMethod(value string) bool { diff --git a/internal/core/domain/reconciliation/idempotency_test.go b/internal/core/domain/reconciliation/idempotency_test.go index 1927e83..7967378 100644 --- a/internal/core/domain/reconciliation/idempotency_test.go +++ b/internal/core/domain/reconciliation/idempotency_test.go @@ -3,6 +3,7 @@ package reconciliation import ( "errors" "fmt" + "strings" "sync" "testing" "time" @@ -10,6 +11,32 @@ import ( const fixtureIdempotencyKey = "idem-0123456789ABC" +func TestValidateIdempotencyKey(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + value string + valid bool + }{ + {name: "minimum", value: "idem-0123456789A", valid: true}, + {name: "maximum", value: strings.Repeat("a", MaxIdempotencyKeyBytes), valid: true}, + {name: "too short", value: "0"}, + {name: "too long", value: strings.Repeat("a", MaxIdempotencyKeyBytes+1)}, + {name: "space", value: "idem-0123456789 A"}, + {name: "newline", value: "idem-0123456789\nA"}, + } { + t.Run(test.name, func(t *testing.T) { + err := ValidateIdempotencyKey(test.value) + if test.valid && err != nil { + t.Fatalf("ValidateIdempotencyKey(%q) error = %v", test.value, err) + } + if !test.valid && !errors.Is(err, ErrInvalidIdempotency) { + t.Fatalf("ValidateIdempotencyKey(%q) error = %v, want ErrInvalidIdempotency", test.value, err) + } + }) + } +} + func TestIdempotencyWindowIsFixedNonSlidingAtExactBoundary(t *testing.T) { t.Parallel() fixture := newPlanFixture(t, 1, 30, false) diff --git a/internal/core/service/reference/service.go b/internal/core/service/reference/service.go index 803bc07..2389395 100644 --- a/internal/core/service/reference/service.go +++ b/internal/core/service/reference/service.go @@ -628,7 +628,8 @@ func (service *Service) validateMutation( target, key string, ) error { if service == nil || ctx == nil || ctx.Err() != nil || identity.ValidatePrincipal(principal) != nil || - len(target) == 0 || len(target) > reconciliation.MaxEvidenceBytes || len(key) == 0 { + len(target) == 0 || len(target) > reconciliation.MaxEvidenceBytes || + reconciliation.ValidateIdempotencyKey(key) != nil { if ctx != nil && ctx.Err() != nil { return ctx.Err() } diff --git a/test/security/security_fuzz_test.go b/test/security/security_fuzz_test.go new file mode 100644 index 0000000..421f696 --- /dev/null +++ b/test/security/security_fuzz_test.go @@ -0,0 +1,225 @@ +package security_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/ArdurAI/veer/internal/core/domain/reconciliation" + "github.com/ArdurAI/veer/internal/core/domain/resource" +) + +const ( + fuzzWorkspaceTarget = "/__security_fixture_workspace__" + fuzzOperationTarget = "/__security_fixture_operation__" + fuzzWorkspaceResourceMatch = "__security_fixture_resource_version__" +) + +var retainedSecurityAuthenticationChallenges = map[string]string{ + "req-fuzz-query-token": `Bearer realm="veer", error="invalid_request"`, + "req-fuzz-missing-auth": `Bearer realm="veer"`, + "req-fuzz-invalid-auth": `Bearer realm="veer", error="invalid_token"`, +} + +type securityFuzzSeed struct { + method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string + body []byte + expectedStatus int +} + +func FuzzReferencePublicBoundary(f *testing.F) { + seeds := []securityFuzzSeed{ + {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-list", nil, 0}, + {"", "/api/v1alpha1/workspaces", "BeArer " + securityBearerCanary, "", "0", "0", "0", nil, http.StatusOK}, + {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", securityPolicyCanary, nil, 0}, + {http.MethodGet, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-workspace", nil, http.StatusOK}, + {http.MethodGet, fuzzOperationTarget, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-operation", nil, http.StatusOK}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-replace-00001", fuzzWorkspaceResourceMatch, "req-fuzz-replace", validWorkspaceBody("replacement"), http.StatusAccepted}, + {http.MethodPut, fuzzWorkspaceTarget, "BeArer " + securityBearerCanary, "applicAtion/json", "0", fuzzWorkspaceResourceMatch, "i", validWorkspaceBody("0"), http.StatusBadRequest}, + {http.MethodGet, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", strings.Repeat("a", 65), nil, http.StatusBadRequest}, + {http.MethodPatch, "/api/v1alpha1/workspaces", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-allow-list", nil, http.StatusMethodNotAllowed}, + {http.MethodPatch, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-allow-workspace", nil, http.StatusMethodNotAllowed}, + {http.MethodPatch, fuzzWorkspaceTarget + "/status", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-allow-status", nil, http.StatusMethodNotAllowed}, + {http.MethodPatch, fuzzOperationTarget, "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-allow-operation", nil, http.StatusMethodNotAllowed}, + {http.MethodGet, "/api/v1alpha1/workspaces?access_token=" + url.QueryEscape(invalidBearerCanary), "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-query-token", nil, http.StatusUnauthorized}, + {http.MethodGet, "/api/v1alpha1/workspaces/%2e%2e", "Bearer " + securityBearerCanary, "", "", "", "req-fuzz-encoded-path", nil, http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-duplicate-0001", fuzzWorkspaceResourceMatch, "req-fuzz-duplicate", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-unknown-0001", fuzzWorkspaceResourceMatch, "req-fuzz-unknown", []byte(`{"apiVersion":"v1alpha1","kind":"Workspace","metadata":{"displayName":"x"},"spec":{"credential":"` + invalidBearerCanary + `"}}`), http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-trailing-0001", fuzzWorkspaceResourceMatch, "req-fuzz-trailing", []byte(`{} {}`), http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-unicode-0001", fuzzWorkspaceResourceMatch, "req-fuzz-unicode", []byte{'{', '"', 0xff, '"', ':', '0', '}'}, http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-depth-0000001", fuzzWorkspaceResourceMatch, "req-fuzz-depth", []byte(strings.Repeat("[", 65) + "0" + strings.Repeat("]", 65)), http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-webhook-0001", fuzzWorkspaceResourceMatch, "req-fuzz-webhook", []byte(`{"webhooks":{"escape":{"post":{"requestBody":{"$ref":"https://attacker.invalid/schema"}}}}}`), http.StatusBadRequest}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-oversized-0001", fuzzWorkspaceResourceMatch, "req-fuzz-oversized", bytes.Repeat([]byte{'x'}, resource.MaxCanonicalBytes+1), http.StatusRequestEntityTooLarge}, + {http.MethodPut, fuzzWorkspaceTarget, "", "application/json", "fuzz-missing-auth-0001", fuzzWorkspaceResourceMatch, "req-fuzz-missing-auth", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusUnauthorized}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + invalidBearerCanary, "application/json", "fuzz-invalid-auth-0001", fuzzWorkspaceResourceMatch, "req-fuzz-invalid-auth", []byte(`{"apiVersion":"v1alpha1","apiVersion":"v2","kind":"Workspace","metadata":{"displayName":"x"},"spec":{}}`), http.StatusUnauthorized}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-stale-0000001", `"rv_security_stale_fuzz_0001"`, "req-fuzz-stale", validWorkspaceBody("replacement"), http.StatusPreconditionFailed}, + {http.MethodPut, fuzzWorkspaceTarget, "BeArer " + securityBearerCanary, "application/json", "fuzz-stale-case-0001", `"rv_security_stale_fuzz_0001"`, "req-fuzz-stale-case", validWorkspaceBody("replacement"), http.StatusPreconditionFailed}, + {http.MethodPut, fuzzWorkspaceTarget, "Bearer " + securityBearerCanary, "application/json", "fuzz-stale-space-001", `"rv_security_stale_fuzz_0001"`, "req-fuzz-stale-space", validWorkspaceBody("replacement"), http.StatusPreconditionFailed}, + {http.MethodDelete, "/api/v1alpha1/workspaces/wsp_0000000000000001", "Bearer " + invalidBearerCanary, "", "fuzz-delete-00001", `"rv_0000000000000001"`, "req-fuzz-delete", nil, 0}, + } + for _, seed := range seeds { + f.Add(seed.method, seed.target, seed.authorization, seed.contentType, seed.idempotencyKey, seed.ifMatch, seed.requestID, seed.body) + } + successResponseLimits := loadSecuritySuccessResponseLimits(f) + + f.Fuzz(func( + t *testing.T, + method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string, + body []byte, + ) { + if len(method) > 32 || len(target) > 4*1024 || len(authorization) > 16*1024 || + len(contentType) > 512 || len(idempotencyKey) > 2*1024 || len(ifMatch) > 512 || + len(requestID) > 512 || len(body) > 2*resource.MaxCanonicalBytes { + t.Skip() + } + retainedExpectedStatus := retainedSecurityFuzzExpectedStatus( + seeds, method, target, authorization, contentType, idempotencyKey, ifMatch, requestID, body, + ) + fixture := newSecurityFixture(t) + if target == fuzzWorkspaceTarget+"/status" { + target = "/api/v1alpha1/workspaces/" + fixture.workspaceID.String() + "/status" + } + if target == fuzzWorkspaceTarget { + target = "/api/v1alpha1/workspaces/" + fixture.workspaceID.String() + } + if target == fuzzOperationTarget { + target = "/api/v1alpha1/operations/" + fixture.operationID.String() + } + if ifMatch == fuzzWorkspaceResourceMatch { + ifMatch = `"` + fixture.resourceVersion + `"` + } + request, err := http.NewRequestWithContext(context.Background(), method, target, bytes.NewReader(body)) + if err != nil { + return + } + scheme, token, hasScheme := strings.Cut(authorization, " ") + fixtureAuthenticated := hasScheme && strings.EqualFold(scheme, "Bearer") && + strings.TrimLeft(token, " ") == securityBearerCanary + authorizedFixtureTarget := fixtureAuthenticated && request.Method == http.MethodPut && + request.URL.Path == "/api/v1alpha1/workspaces/"+fixture.workspaceID.String() && + request.URL.RawQuery == "" && request.URL.EscapedPath() == request.URL.Path + authorizedFixtureMutation := authorizedFixtureTarget && ifMatch == `"`+fixture.resourceVersion+`"` + request.RequestURI = target + for name, value := range map[string]string{ + "Authorization": authorization, + "Content-Type": contentType, + "Idempotency-Key": idempotencyKey, + "If-Match": ifMatch, + "Veer-Request-Id": requestID, + } { + if value != "" { + request.Header.Set(name, value) + } + } + var before securityRuntimeSnapshot + nonReadRequest := request.Method != http.MethodGet + if nonReadRequest { + before = observeSecurityRuntimeState(t, fixture, fixture.operationID) + } + response := httptest.NewRecorder() + fixture.memberHandler.ServeHTTP(response, request) + if nonReadRequest && response.Code >= http.StatusBadRequest { + after := observeSecurityRuntimeState(t, fixture, fixture.operationID) + if before.mutationState != after.mutationState { + t.Logf("rejected request status=%d body=%s", response.Code, response.Body.String()) + } + assertSecurityRuntimeStateUnchanged(t, before, after) + } + responseRequestIDs := response.Header().Values("Veer-Request-Id") + if len(responseRequestIDs) != 1 || responseRequestIDs[0] == "" { + t.Fatalf("response request ID values = %q, want exactly one non-empty value", responseRequestIDs) + } + if requestID != "" && securityRequestIDPattern.MatchString(requestID) && + responseRequestIDs[0] != requestID { + t.Fatalf( + "response request ID = %q, want echoed client value %q", + responseRequestIDs[0], requestID, + ) + } + if requestID != "" && !securityRequestIDPattern.MatchString(requestID) { + assertSecurityResponse(t, response, http.StatusBadRequest, "validation-failed") + } + if authorizedFixtureMutation && response.Code == http.StatusForbidden { + t.Fatalf("authorized fixture mutation was denied before reaching admission: body=%s", response.Body.String()) + } + if retainedExpectedStatus != 0 && response.Code != retainedExpectedStatus { + t.Fatalf( + "retained seed status = %d, want %d; body=%s", + response.Code, retainedExpectedStatus, response.Body.String(), + ) + } + if response.Code == http.StatusUnauthorized { + expectedChallenge := "" + if retainedExpectedStatus == http.StatusUnauthorized { + var exists bool + expectedChallenge, exists = retainedSecurityAuthenticationChallenges[requestID] + if !exists { + t.Fatalf("retained 401 seed %q has no expected authentication challenge", requestID) + } + } + assertSecurityAuthenticationChallenge(t, response, expectedChallenge) + } + + assertSingletonHeader(t, response, "Cache-Control", "no-store") + assertSingletonHeader(t, response, "X-Content-Type-Options", "nosniff") + switch { + case response.Code >= http.StatusOK && response.Code < http.StatusMultipleChoices: + assertSingletonHeader(t, response, "Content-Type", "application/json") + operationID := securityOperationIDForRequest(request.Method, request.URL.Path) + maximumBytes, exists := successResponseLimits[operationID] + if operationID == "" || !exists { + t.Fatalf("successful response has no matched OpenAPI operation limit: method=%q path=%q", request.Method, request.URL.Path) + } + if response.Body.Len() > maximumBytes || !json.Valid(response.Body.Bytes()) { + t.Fatalf( + "success response contract failed: operation=%s status=%d bytes=%d maximum=%d headers=%#v", + operationID, response.Code, response.Body.Len(), maximumBytes, response.Header(), + ) + } + assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + assertNoSuccessfulFixtureSecrets(t, response, fixture) + if operationID == "replaceWorkspace" { + receipt := assertMutationReceipt(t, response, fixture.workspaceID) + assertReplacementOperation(t, fixture, receipt) + } + case response.Code >= http.StatusBadRequest && response.Code <= 599: + assertSecurityProblemContract(t, response) + if response.Code == http.StatusMethodNotAllowed { + assertSecurityAllowHeader(t, response, request) + } + if response.Code == http.StatusPreconditionFailed && authorizedFixtureTarget { + assertNoFixtureCanaryWithCurrentETag(t, response, fixture) + } else { + assertNoFixtureCanary(t, response, fixture) + } + default: + t.Fatalf("unexpected HTTP status %d", response.Code) + } + assertNoBearerCanary(t, response) + if authorizedFixtureTarget && response.Code >= http.StatusBadRequest && + reconciliation.ValidateIdempotencyKey(idempotencyKey) == nil { + assertSecurityIdempotencyKeyReusable(t, fixture, idempotencyKey) + } + }) +} + +func retainedSecurityFuzzExpectedStatus( + seeds []securityFuzzSeed, + method, target, authorization, contentType, idempotencyKey, ifMatch, requestID string, + body []byte, +) int { + for _, seed := range seeds { + if seed.expectedStatus != 0 && method == seed.method && target == seed.target && + authorization == seed.authorization && contentType == seed.contentType && + idempotencyKey == seed.idempotencyKey && ifMatch == seed.ifMatch && requestID == seed.requestID && + bytes.Equal(body, seed.body) { + return seed.expectedStatus + } + } + return 0 +} diff --git a/test/security/security_regression_test.go b/test/security/security_regression_test.go new file mode 100644 index 0000000..0b0188f --- /dev/null +++ b/test/security/security_regression_test.go @@ -0,0 +1,1959 @@ +package security_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" + + "github.com/ArdurAI/veer/api/openapi" + "github.com/ArdurAI/veer/internal/adapters/store/memory" + "github.com/ArdurAI/veer/internal/core/domain/authorization" + "github.com/ArdurAI/veer/internal/core/domain/hierarchy" + "github.com/ArdurAI/veer/internal/core/domain/identity" + "github.com/ArdurAI/veer/internal/core/domain/isolation" + "github.com/ArdurAI/veer/internal/core/domain/resource" + "github.com/ArdurAI/veer/internal/core/ports" + "github.com/ArdurAI/veer/internal/core/service/reference" + "github.com/ArdurAI/veer/internal/core/service/referenceauthorization" + httptransport "github.com/ArdurAI/veer/internal/transport/http" +) + +const ( + securityBearerCanary = "security-route-bearer+canary" + invalidBearerCanary = "invalid-security-route-bearer+canary" + securityResourceCanary = "security-resource-confidential-canary" + securityOutsiderCanary = "security-outsider-workspace-confidential-canary" + securityPolicyCanary = "security-policy-confidential-canary" + securityMemberCanary = "mem_security_confidential_0001" + securityIdentityCanary = "security-identity-confidential-canary" + securityStaleVersionCanary = "rv_security_stale_0001" + maximumSecurityProblemBytes = 1_024 + maximumSecurityFieldPathSize = 96 +) + +type securityProblemExpectation struct { + status int + title string + requiresRetryAfter bool +} + +var ( + securityProblemCodePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + securityRequestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + securityFieldPathPattern = regexp.MustCompile(`^(/([^~/]|~0|~1)*)+$`) + securityProblemExpectations = map[string]securityProblemExpectation{ + "validation-failed": {status: http.StatusBadRequest, title: "Request validation failed"}, + "authentication-required": {status: http.StatusUnauthorized, title: "Authentication required"}, + "authorization-denied": {status: http.StatusForbidden, title: "Authorization denied"}, + "not-found": {status: http.StatusNotFound, title: "Resource not found"}, + "method-not-allowed": {status: http.StatusMethodNotAllowed, title: "Method not allowed"}, + "idempotency-key-reused": {status: http.StatusConflict, title: "Request conflicts with a prior mutation"}, + "uniqueness-conflict": {status: http.StatusConflict, title: "Resource uniqueness conflict"}, + "lifecycle-conflict": {status: http.StatusConflict, title: "Resource lifecycle conflict"}, + "policy-conflict": {status: http.StatusConflict, title: "Resource policy conflict"}, + "precondition-failed": {status: http.StatusPreconditionFailed, title: "Resource version is stale"}, + "request-too-large": {status: http.StatusRequestEntityTooLarge, title: "Request body is too large"}, + "unsupported-media-type": {status: http.StatusUnsupportedMediaType, title: "Unsupported request media type"}, + "precondition-required": {status: http.StatusPreconditionRequired, title: "Mutation precondition required"}, + "rate-limited": { + status: http.StatusTooManyRequests, title: "Request rate limited", requiresRetryAfter: true, + }, + "internal-failure": {status: http.StatusInternalServerError, title: "Internal failure"}, + "unavailable": { + status: http.StatusServiceUnavailable, title: "Service temporarily unavailable", requiresRetryAfter: true, + }, + } +) + +type securityRoute struct { + operationID string + method string + pathTemplate string + target func(securityFixture) string + body func(securityFixture) []byte + headers func(securityFixture) map[string]string + memberStatus int + memberOutcome string + memberProblemCode string + outsiderStatus int + outsiderOutcome string + outsiderProblemCode string + allRolesDenied bool + workspaceAdminOnly bool + staleOutsiderCheck bool + assertMemberBody func(*testing.T, securityFixture, *httptest.ResponseRecorder) + assertOutsiderBody func(*testing.T, securityFixture, *httptest.ResponseRecorder) +} + +type securityFixture struct { + memberHandler http.Handler + outsiderHandler http.Handler + workspaceID resource.ID + outsiderWorkspaceID resource.ID + resourceVersion string + outsiderResourceVersion string + operationID resource.ID + outsiderOperationID resource.ID + policyID resource.ID + memberID resource.ID + runtimeState *securityRuntimeStateRecorder + store ports.ReferenceStore +} + +type securityRuntimeSnapshot struct { + member securityPrincipalRuntimeSnapshot + outsider securityPrincipalRuntimeSnapshot + mutationState string +} + +type securityPrincipalRuntimeSnapshot struct { + workspaceList string + workspace string + workspaceETag string + operations string +} + +type securityRuntimeStateRecorder struct { + mu sync.Mutex + events []string +} + +func (recorder *securityRuntimeStateRecorder) record(events ...string) { + recorder.mu.Lock() + defer recorder.mu.Unlock() + recorder.events = append(recorder.events, events...) +} + +func (recorder *securityRuntimeStateRecorder) snapshot() string { + recorder.mu.Lock() + defer recorder.mu.Unlock() + return strings.Join(recorder.events, "\n") +} + +type securityRecordingStore struct { + delegate *memory.Store + state *securityRuntimeStateRecorder +} + +func (store *securityRecordingStore) View( + ctx context.Context, + scopes isolation.WorkspaceScopeSet, + callback func(ports.ReferenceReader) error, +) error { + return store.delegate.View(ctx, scopes, callback) +} + +func (store *securityRecordingStore) Update( + ctx context.Context, + scope isolation.WorkspaceScope, + callback func(ports.ReferenceTransaction) error, +) error { + if callback == nil { + return store.delegate.Update(ctx, scope, nil) + } + var events []string + err := store.delegate.Update(ctx, scope, func(transaction ports.ReferenceTransaction) error { + return callback(&securityRecordingTransaction{ + ReferenceTransaction: transaction, + events: &events, + }) + }) + if err != nil { + return err + } + store.state.record(append( + []string{"store update workspace=" + scope.WorkspaceID().String()}, events..., + )...) + return nil +} + +type securityRecordingTransaction struct { + ports.ReferenceTransaction + events *[]string +} + +func (transaction *securityRecordingTransaction) PutResource(id resource.ID, value []byte) { + transaction.ReferenceTransaction.PutResource(id, value) + *transaction.events = append(*transaction.events, securityMutationEvent("put resource", id, value)) +} + +func (transaction *securityRecordingTransaction) DeleteResource(id resource.ID) { + transaction.ReferenceTransaction.DeleteResource(id) + *transaction.events = append(*transaction.events, "delete resource id="+id.String()) +} + +func (transaction *securityRecordingTransaction) PutOperation(id resource.ID, value []byte) { + transaction.ReferenceTransaction.PutOperation(id, value) + *transaction.events = append(*transaction.events, securityMutationEvent("put operation", id, value)) +} + +func securityMutationEvent(action string, id resource.ID, value []byte) string { + digest := sha256.Sum256(value) + return fmt.Sprintf("%s id=%s bytes=%d sha256=%s", action, id, len(value), hex.EncodeToString(digest[:])) +} + +type securityRecordingIssuer struct { + delegate reference.Issuer + state *securityRuntimeStateRecorder +} + +func (issuer *securityRecordingIssuer) ResourceID(kind hierarchy.Kind) (resource.ID, error) { + id, err := issuer.delegate.ResourceID(kind) + if err == nil { + issuer.state.record("issue resource kind=" + kind.String() + " id=" + id.String()) + } + return id, err +} + +func (issuer *securityRecordingIssuer) OperationID() (resource.ID, error) { + id, err := issuer.delegate.OperationID() + if err == nil { + issuer.state.record("issue operation id=" + id.String()) + } + return id, err +} + +func (issuer *securityRecordingIssuer) ResourceVersion() (string, error) { + version, err := issuer.delegate.ResourceVersion() + if err == nil { + issuer.state.record("issue resource-version=" + version) + } + return version, err +} + +type securityAccess struct { + principal identity.Principal +} + +func (access securityAccess) Authenticate( + ctx context.Context, + credential ports.BearerCredential, +) (identity.Principal, error) { + if err := ctx.Err(); err != nil { + return identity.Principal{}, err + } + if credential.Token() != securityBearerCanary { + return identity.Principal{}, ports.ErrAuthenticationInvalid + } + return identity.ClonePrincipal(access.principal), nil +} + +type securityMatrixReport struct { + Contract string `json:"contract"` + OpenAPIArtifact string `json:"openapiArtifact"` + OpenAPISHA256 string `json:"openapiSha256"` + OpenAPIValidated bool `json:"openapiValidated"` + Routes []securityRouteResult `json:"routes"` +} + +type securityRouteResult struct { + OperationID string `json:"operationId"` + Method string `json:"method"` + PathTemplate string `json:"pathTemplate"` + MemberStatus int `json:"memberStatus"` + MemberOutcome string `json:"memberOutcome"` + OutsiderStatus int `json:"outsiderStatus"` + OutsiderOutcome string `json:"outsiderOutcome"` + MissingCredentialStatus int `json:"missingCredentialStatus"` + InvalidCredentialStatus int `json:"invalidCredentialStatus"` +} + +type securityFieldViolation struct { + Field string `json:"field"` + Code string `json:"code"` + Message string `json:"message"` +} + +type securityProblem struct { + Type string `json:"type"` + Title string `json:"title"` + Status int `json:"status"` + Detail string `json:"detail,omitempty"` + Instance string `json:"instance"` + Code string `json:"code"` + RequestID string `json:"requestId"` + Errors []securityFieldViolation `json:"errors,omitempty"` + RetryAfterSeconds *int `json:"retryAfterSeconds,omitempty"` +} + +func TestBearerCanariesAreNotRequestIDs(t *testing.T) { + for _, canary := range []string{securityBearerCanary, invalidBearerCanary} { + if _, err := ports.NewBearerCredential(canary); err != nil { + t.Fatalf("bearer canary is not a valid credential: %v", err) + } + if securityRequestIDPattern.MatchString(canary) { + t.Fatalf("bearer canary overlaps the request-ID grammar: %q", canary) + } + } +} + +func TestSecurityProblemJSONRejectsDuplicateMembers(t *testing.T) { + tests := []struct { + name string + body string + wantErr bool + }{ + {name: "unique", body: `{"detail":"safe","errors":[{"field":"/spec","code":"invalid","message":"safe"}]}`}, + {name: "duplicate top-level", body: `{"detail":"secret","detail":"safe"}`, wantErr: true}, + {name: "escaped duplicate", body: `{"detail":"secret","\u0064etail":"safe"}`, wantErr: true}, + {name: "duplicate nested", body: `{"errors":[{"message":"secret","message":"safe"}]}`, wantErr: true}, + {name: "case-variant top-level", body: `{"Title":"secret","title":"safe"}`, wantErr: true}, + {name: "case-variant nested", body: `{"errors":[{"Message":"secret","message":"safe"}]}`, wantErr: true}, + {name: "malformed", body: `{"detail":`, wantErr: true}, + {name: "trailing value", body: `{} {}`, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateUniqueJSONMembers([]byte(test.body)) + if (err != nil) != test.wantErr { + t.Fatalf("validateUniqueJSONMembers() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + +func TestSecurityProblemRetryMetadata(t *testing.T) { + retry := func(value int) *int { return &value } + tests := []struct { + name string + code string + body *int + headers []string + wantErr bool + }{ + {name: "unavailable", code: "unavailable", body: retry(10), headers: []string{"10"}}, + {name: "rate limited", code: "rate-limited", body: retry(5), headers: []string{"5"}}, + {name: "missing body", code: "unavailable", headers: []string{"10"}, wantErr: true}, + {name: "zero body", code: "unavailable", body: retry(0), headers: []string{"0"}, wantErr: true}, + {name: "missing header", code: "unavailable", body: retry(10), wantErr: true}, + {name: "duplicate header", code: "unavailable", body: retry(10), headers: []string{"10", "10"}, wantErr: true}, + {name: "mismatched header", code: "unavailable", body: retry(10), headers: []string{"11"}, wantErr: true}, + {name: "non-canonical header", code: "unavailable", body: retry(10), headers: []string{"010"}, wantErr: true}, + {name: "ordinary problem", code: "validation-failed"}, + {name: "unexpected body", code: "validation-failed", body: retry(1), wantErr: true}, + {name: "unexpected header", code: "validation-failed", headers: []string{"1"}, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateSecurityRetryAfter( + securityProblemExpectations[test.code], + securityProblem{RetryAfterSeconds: test.body}, + test.headers, + ) + if (err != nil) != test.wantErr { + t.Fatalf("validateSecurityRetryAfter() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + +func TestSecurityProblemMemberPresence(t *testing.T) { + tests := []struct { + name string + body string + wantErr bool + }{ + {name: "minimal", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test"}`}, + {name: "field violation", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test","detail":"safe","errors":[{"field":"/spec","code":"invalid","message":"safe"}]}`}, + {name: "missing required", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","requestId":"req-test"}`, wantErr: true}, + {name: "null required", body: `{"type":"urn:veer:problem:validation-failed","title":null,"status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test"}`, wantErr: true}, + {name: "null detail", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test","detail":null}`, wantErr: true}, + {name: "null errors", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test","errors":null}`, wantErr: true}, + {name: "null retry", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test","retryAfterSeconds":null}`, wantErr: true}, + {name: "missing violation member", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test","errors":[{"field":"/spec","code":"invalid"}]}`, wantErr: true}, + {name: "null violation member", body: `{"type":"urn:veer:problem:validation-failed","title":"Request validation failed","status":400,"instance":"urn:veer:request:req-test","code":"validation-failed","requestId":"req-test","errors":[{"field":"/spec","code":"invalid","message":null}]}`, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateSecurityProblemMembers([]byte(test.body)) + if (err != nil) != test.wantErr { + t.Fatalf("validateSecurityProblemMembers() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + +func TestSecuritySuccessResponseLimitsCoverPublicRoutes(t *testing.T) { + limits := loadSecuritySuccessResponseLimits(t) + routes := publicSecurityRoutes() + if len(limits) != len(routes) { + t.Fatalf("success response limit operations = %d, want %d public routes", len(limits), len(routes)) + } + for _, route := range routes { + if limits[route.operationID] <= 0 { + t.Fatalf("operation %q has no positive success response limit", route.operationID) + } + } +} + +func TestPublicRouteSecurityMatrix(t *testing.T) { + routes := publicSecurityRoutes() + assertOpenAPIRouteCoverage(t, routes) + results := make([]securityRouteResult, 0, len(routes)) + + for _, route := range routes { + route := route + t.Run(route.operationID, func(t *testing.T) { + fixture := newSecurityFixture(t) + member := securityRequestWithStateInvariant(t, fixture, route, route.memberStatus, func() *httptest.ResponseRecorder { + return securityRequest(t, fixture.memberHandler, route, fixture, securityBearerCanary) + }) + assertSecurityResponse(t, member, route.memberStatus, route.memberProblemCode) + if route.memberStatus >= http.StatusBadRequest { + assertNoFixtureCanary(t, member, fixture) + } else { + assertNoSuccessfulFixtureSecrets(t, member, fixture) + } + if route.assertMemberBody != nil { + route.assertMemberBody(t, fixture, member) + } + + fixture = newSecurityFixture(t) + outsider := securityRequestWithStateInvariant(t, fixture, route, route.outsiderStatus, func() *httptest.ResponseRecorder { + return securityRequest(t, fixture.outsiderHandler, route, fixture, securityBearerCanary) + }) + assertSecurityResponse(t, outsider, route.outsiderStatus, route.outsiderProblemCode) + if route.outsiderStatus >= http.StatusBadRequest { + assertNoFixtureCanary(t, outsider, fixture) + } else { + assertNoSuccessfulFixtureSecrets(t, outsider, fixture) + } + if route.assertOutsiderBody != nil { + route.assertOutsiderBody(t, fixture, outsider) + } + if route.staleOutsiderCheck { + t.Run("outsider-stale-precondition", func(t *testing.T) { + staleFixture := newSecurityFixture(t) + denied := securityRequestWithStateInvariant(t, staleFixture, route, http.StatusForbidden, func() *httptest.ResponseRecorder { + return securityRequestWithHeaderOverrides( + t, + staleFixture.outsiderHandler, + route, + staleFixture, + securityBearerCanary, + map[string]string{ + "If-Match": `"` + securityStaleVersionCanary + `"`, + "Veer-Request-Id": securityResourceCanary, + }, + ) + }) + assertSecurityResponse(t, denied, http.StatusForbidden, "authorization-denied") + assertNoFixtureCanary(t, denied, staleFixture) + }) + } + + fixture = newSecurityFixture(t) + missing := securityRequestWithStateInvariant(t, fixture, route, http.StatusUnauthorized, func() *httptest.ResponseRecorder { + return securityRequest(t, fixture.memberHandler, route, fixture, "") + }) + assertAuthenticationDenial(t, missing, `Bearer realm="veer"`) + assertNoFixtureCanary(t, missing, fixture) + + fixture = newSecurityFixture(t) + invalid := securityRequestWithStateInvariant(t, fixture, route, http.StatusUnauthorized, func() *httptest.ResponseRecorder { + return securityRequest(t, fixture.memberHandler, route, fixture, invalidBearerCanary) + }) + assertAuthenticationDenial(t, invalid, `Bearer realm="veer", error="invalid_token"`) + assertNoFixtureCanary(t, invalid, fixture) + + if route.method == http.MethodGet { + for _, role := range authorization.Roles() { + t.Run("allowed-read-"+role.String(), func(t *testing.T) { + roleFixture := newSecurityFixtureForRole(t, role) + allowed := securityRequest(t, roleFixture.memberHandler, route, roleFixture, securityBearerCanary) + assertSecurityResponse(t, allowed, http.StatusOK, "") + assertNoSuccessfulFixtureSecrets(t, allowed, roleFixture) + route.assertMemberBody(t, roleFixture, allowed) + }) + } + } + if route.allRolesDenied { + for _, role := range authorization.Roles() { + role := role + t.Run("reserved-"+role.String(), func(t *testing.T) { + roleFixture := newSecurityFixtureForRole(t, role) + denied := securityRequestWithStateInvariant(t, roleFixture, route, http.StatusForbidden, func() *httptest.ResponseRecorder { + return securityRequest(t, roleFixture.memberHandler, route, roleFixture, securityBearerCanary) + }) + assertSecurityResponse(t, denied, http.StatusForbidden, "authorization-denied") + assertNoFixtureCanary(t, denied, roleFixture) + }) + } + } + if route.workspaceAdminOnly { + for _, role := range authorization.Roles() { + if role == authorization.RoleWorkspaceAdministrator { + continue + } + role := role + t.Run("administrator-only-"+role.String(), func(t *testing.T) { + roleFixture := newSecurityFixtureForRole(t, role) + denied := securityRequestWithStateInvariant(t, roleFixture, route, http.StatusForbidden, func() *httptest.ResponseRecorder { + return securityRequest(t, roleFixture.memberHandler, route, roleFixture, securityBearerCanary) + }) + assertSecurityResponse(t, denied, http.StatusForbidden, "authorization-denied") + assertNoFixtureCanary(t, denied, roleFixture) + }) + } + } + + results = append(results, securityRouteResult{ + OperationID: route.operationID, Method: route.method, PathTemplate: route.pathTemplate, + MemberStatus: route.memberStatus, MemberOutcome: route.memberOutcome, + OutsiderStatus: route.outsiderStatus, OutsiderOutcome: route.outsiderOutcome, + MissingCredentialStatus: missing.Code, InvalidCredentialStatus: invalid.Code, + }) + }) + } + + writeOrCompareSecurityMatrix(t, results) +} + +func TestCrossPrincipalMutationReplayDenied(t *testing.T) { + fixture := newSecurityFixture(t) + var route securityRoute + for _, candidate := range publicSecurityRoutes() { + if candidate.operationID == "replaceWorkspace" { + route = candidate + break + } + } + if route.operationID == "" { + t.Fatal("replaceWorkspace security route is missing") + } + + member := securityRequest(t, fixture.memberHandler, route, fixture, securityBearerCanary) + assertSecurityResponse(t, member, http.StatusAccepted, "") + receipt := assertMutationReceipt(t, member, fixture.workspaceID) + assertReplacementOperation(t, fixture, receipt) + assertNoSuccessfulFixtureSecrets(t, member, fixture) + + before := observeSecurityRuntimeState(t, fixture, fixture.operationID, receipt.OperationID) + outsider := securityRequest(t, fixture.outsiderHandler, route, fixture, securityBearerCanary) + assertSecurityResponse(t, outsider, http.StatusForbidden, "authorization-denied") + assertNoFixtureCanary(t, outsider, fixture) + after := observeSecurityRuntimeState(t, fixture, fixture.operationID, receipt.OperationID) + assertSecurityRuntimeStateUnchanged(t, before, after) +} + +func TestRejectedReplacementPreservesIdempotencyKey(t *testing.T) { + const key = "security-rejected-key-0001" + cases := []struct { + name, requestID, problemCode string + body []byte + stale bool + status int + }{ + {"stale-same-intent", "req-key-stale-same", "precondition-failed", validWorkspaceBody("replacement"), true, http.StatusPreconditionFailed}, + {"stale-changed-intent", "req-key-stale-changed", "precondition-failed", validWorkspaceBody("rejected-intent"), true, http.StatusPreconditionFailed}, + {"malformed-json", "req-key-invalid-json", "validation-failed", []byte(`{"metadata":`), false, http.StatusBadRequest}, + {"malformed-request-id", strings.Repeat("a", 65), "validation-failed", validWorkspaceBody("replacement"), false, http.StatusBadRequest}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + fixture := newSecurityFixture(t) + route := securityReplacementRetryRoute(key) + route.body = func(securityFixture) []byte { return test.body } + headers := map[string]string{"Veer-Request-Id": test.requestID} + if test.stale { + headers["If-Match"] = strconv.Quote(securityStaleVersionCanary) + } + response := securityRequestWithStateInvariant(t, fixture, route, test.status, func() *httptest.ResponseRecorder { + return securityRequestWithHeaderOverrides(t, fixture.memberHandler, route, fixture, securityBearerCanary, headers) + }) + assertSecurityResponse(t, response, test.status, test.problemCode) + if test.stale { + assertNoFixtureCanaryWithCurrentETag(t, response, fixture) + } else { + assertNoFixtureCanary(t, response, fixture) + } + assertSecurityIdempotencyKeyReusable(t, fixture, key) + }) + } +} + +func securityReplacementRetryRoute(key string) securityRoute { + return securityRoute{ + operationID: "replaceWorkspace", + method: http.MethodPut, + target: func(fixture securityFixture) string { + return "/api/v1alpha1/workspaces/" + fixture.workspaceID.String() + }, + body: func(securityFixture) []byte { return validWorkspaceBody("replacement") }, + headers: func(fixture securityFixture) map[string]string { + return map[string]string{ + "Content-Type": "application/json", + "Idempotency-Key": key, + "If-Match": strconv.Quote(fixture.resourceVersion), + } + }, + } +} + +func assertSecurityIdempotencyKeyReusable(t testing.TB, fixture securityFixture, key string) { + t.Helper() + route := securityReplacementRetryRoute(key) + retry := securityRequest(t, fixture.memberHandler, route, fixture, securityBearerCanary) + assertSecurityResponse(t, retry, http.StatusAccepted, "") + receipt := assertMutationReceipt(t, retry, fixture.workspaceID) + assertReplacementOperation(t, fixture, receipt) + assertNoSuccessfulFixtureSecrets(t, retry, fixture) + + before := observeSecurityRuntimeState(t, fixture, fixture.operationID, receipt.OperationID) + replay := securityRequest(t, fixture.memberHandler, route, fixture, securityBearerCanary) + assertSecurityResponse(t, replay, http.StatusAccepted, "") + assertMutationReceipt(t, replay, fixture.workspaceID) + assertNoSuccessfulFixtureSecrets(t, replay, fixture) + if !bytes.Equal(replay.Body.Bytes(), retry.Body.Bytes()) { + t.Fatalf("corrected retry replay changed its receipt: first=%s replay=%s", retry.Body.String(), replay.Body.String()) + } + after := observeSecurityRuntimeState(t, fixture, fixture.operationID, receipt.OperationID) + assertSecurityRuntimeStateUnchanged(t, before, after) +} + +func publicSecurityRoutes() []securityRoute { + workspaceTarget := func(fixture securityFixture) string { + return "/api/v1alpha1/workspaces/" + fixture.workspaceID.String() + } + mutationHeaders := func(fixture securityFixture) map[string]string { + return map[string]string{ + "Content-Type": "application/json", + "Idempotency-Key": "security-route-mutation-0001", + "If-Match": `"` + fixture.resourceVersion + `"`, + } + } + return []securityRoute{ + { + operationID: "listWorkspaces", method: http.MethodGet, + pathTemplate: "/api/v1alpha1/workspaces", + target: func(securityFixture) string { return "/api/v1alpha1/workspaces?pageSize=1" }, + memberStatus: http.StatusOK, memberOutcome: "allowed", + outsiderStatus: http.StatusOK, outsiderOutcome: "denied-rows-filtered-before-pagination", + assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { + assertWorkspacePage(t, response, fixture.workspaceID, securityOutsiderCanary) + assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + }, + assertOutsiderBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { + assertWorkspacePage(t, response, fixture.outsiderWorkspaceID, securityResourceCanary) + assertNoMemberFixtureCanary(t, response, fixture) + }, + }, + { + operationID: "createWorkspace", method: http.MethodPost, + pathTemplate: "/api/v1alpha1/workspaces", + target: func(securityFixture) string { return "/api/v1alpha1/workspaces" }, + body: func(securityFixture) []byte { return validWorkspaceBody("reserved") }, + headers: func(securityFixture) map[string]string { + return map[string]string{ + "Content-Type": "application/json", + "Idempotency-Key": "security-route-create-0001", + } + }, + memberStatus: http.StatusForbidden, memberOutcome: "service-reserved-deny", memberProblemCode: "authorization-denied", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "service-reserved-deny", outsiderProblemCode: "authorization-denied", + allRolesDenied: true, + }, + { + operationID: "getWorkspace", method: http.MethodGet, + pathTemplate: "/api/v1alpha1/workspaces/{workspaceId}", target: workspaceTarget, + memberStatus: http.StatusOK, memberOutcome: "allowed", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", + assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { + assertWorkspaceObject( + t, response, fixture.workspaceID, securityResourceCanary, fixture.resourceVersion, + ) + assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + }, + }, + { + operationID: "replaceWorkspace", method: http.MethodPut, + pathTemplate: "/api/v1alpha1/workspaces/{workspaceId}", target: workspaceTarget, + body: func(securityFixture) []byte { return validWorkspaceBody("replacement") }, + headers: mutationHeaders, + memberStatus: http.StatusAccepted, memberOutcome: "allowed", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", + workspaceAdminOnly: true, + staleOutsiderCheck: true, + assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { + receipt := assertMutationReceipt(t, response, fixture.workspaceID) + assertReplacementOperation(t, fixture, receipt) + if receipt.ResourceVersion == fixture.resourceVersion { + t.Fatalf("replacement resource version = %q, want advancement from %q", receipt.ResourceVersion, fixture.resourceVersion) + } + memberWorkspace := securityAuthorizedGet( + t, fixture.memberHandler, "/api/v1alpha1/workspaces/"+fixture.workspaceID.String(), + ) + assertWorkspaceObject(t, memberWorkspace, fixture.workspaceID, "replacement", receipt.ResourceVersion) + outsiderWorkspace := securityAuthorizedGet( + t, fixture.outsiderHandler, "/api/v1alpha1/workspaces/"+fixture.outsiderWorkspaceID.String(), + ) + assertWorkspaceObject( + t, + outsiderWorkspace, + fixture.outsiderWorkspaceID, + securityOutsiderCanary, + fixture.outsiderResourceVersion, + ) + assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + }, + }, + { + operationID: "deleteWorkspace", method: http.MethodDelete, + pathTemplate: "/api/v1alpha1/workspaces/{workspaceId}", target: workspaceTarget, + headers: mutationHeaders, + memberStatus: http.StatusConflict, memberOutcome: "authorization-allowed-lifecycle-denied", memberProblemCode: "lifecycle-conflict", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", + workspaceAdminOnly: true, + staleOutsiderCheck: true, + }, + { + operationID: "replaceWorkspaceStatus", method: http.MethodPut, + pathTemplate: "/api/v1alpha1/workspaces/{workspaceId}/status", + target: func(fixture securityFixture) string { return workspaceTarget(fixture) + "/status" }, + body: func(securityFixture) []byte { + return []byte(`{"apiVersion":"v1alpha1","kind":"Workspace","status":{"observedGeneration":1,"conditions":[]}}`) + }, + headers: mutationHeaders, + memberStatus: http.StatusForbidden, memberOutcome: "service-reserved-deny", memberProblemCode: "authorization-denied", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "service-reserved-deny", outsiderProblemCode: "authorization-denied", + allRolesDenied: true, + }, + { + operationID: "getOperation", method: http.MethodGet, + pathTemplate: "/api/v1alpha1/operations/{operationId}", + target: func(fixture securityFixture) string { + return "/api/v1alpha1/operations/" + fixture.operationID.String() + }, + memberStatus: http.StatusOK, memberOutcome: "allowed", + outsiderStatus: http.StatusForbidden, outsiderOutcome: "authorization-denied", outsiderProblemCode: "authorization-denied", + assertMemberBody: func(t *testing.T, fixture securityFixture, response *httptest.ResponseRecorder) { + assertOperationObject(t, response, fixture.operationID, fixture.workspaceID, fixture.workspaceID) + assertNoSuccessfulResponseCanary(t, response, fixture.outsiderWorkspaceID.String(), securityOutsiderCanary) + }, + }, + } +} + +func newSecurityFixture(t testing.TB) securityFixture { + return newSecurityFixtureForRole(t, authorization.RoleWorkspaceAdministrator) +} + +func newSecurityFixtureForRole(t testing.TB, memberRole authorization.Role) securityFixture { + t.Helper() + if _, err := authorization.ParseRole(memberRole.String()); err != nil { + t.Fatal(err) + } + member := securityPrincipal(t, securityIdentityCanary) + outsider := securityPrincipal(t, "security-outsider") + runtimeState := &securityRuntimeStateRecorder{} + store := &securityRecordingStore{delegate: memory.NewStore(), state: runtimeState} + issuer := &securityRecordingIssuer{delegate: &reference.SequentialIssuer{}, state: runtimeState} + service, err := reference.New(reference.Config{ + Store: store, Clock: reference.ClockFunc(func() time.Time { + return time.Date(2026, time.September, 12, 12, 0, 0, 0, time.UTC) + }), + Issuer: issuer, PageTokenKey: bytes.Repeat([]byte{0x28}, 32), + MaximumPageTokens: 16, MaximumReplays: 32, + }) + if err != nil { + t.Fatal(err) + } + workspace, err := service.Create(context.Background(), reference.CreateCommand{ + Principal: member, Kind: hierarchy.KindWorkspace, + CanonicalTarget: "security:bootstrap:workspace", IdempotencyKey: "security-bootstrap-workspace-0001", + Body: validWorkspaceBody(securityResourceCanary), + }) + if err != nil { + t.Fatal(err) + } + outsiderWorkspace, err := service.Create(context.Background(), reference.CreateCommand{ + Principal: outsider, Kind: hierarchy.KindWorkspace, + CanonicalTarget: "security:bootstrap:outsider-workspace", IdempotencyKey: "security-bootstrap-workspace-0002", + Body: validWorkspaceBody(securityOutsiderCanary), + }) + if err != nil { + t.Fatal(err) + } + memberDirectory, memberPolicy := createSecurityPolicy( + t, service, member, workspace.ResourceID, resource.ID(securityMemberCanary), + memberRole, securityPolicyCanary, "security:bootstrap:policy", "security-bootstrap-policy-0001", + ) + outsiderDirectory, _ := createSecurityPolicy( + t, service, outsider, outsiderWorkspace.ResourceID, resource.ID("mem_security_matrix_0002"), + authorization.RoleWorkspaceAdministrator, "security outsider policy", + "security:bootstrap:outsider-policy", "security-bootstrap-policy-0002", + ) + runtime, err := referenceauthorization.New(referenceauthorization.Config{ + Reference: service, + MemberDirectories: []authorization.MemberDirectory{memberDirectory, outsiderDirectory}, + MaximumAdmissions: 32, + }) + if err != nil { + t.Fatal(err) + } + memberHandler, err := httptransport.NewReferenceHandler(runtime, securityAccess{principal: member}) + if err != nil { + t.Fatal(err) + } + outsiderHandler, err := httptransport.NewReferenceHandler(runtime, securityAccess{principal: outsider}) + if err != nil { + t.Fatal(err) + } + return securityFixture{ + memberHandler: memberHandler, outsiderHandler: outsiderHandler, + workspaceID: workspace.ResourceID, outsiderWorkspaceID: outsiderWorkspace.ResourceID, + resourceVersion: workspace.ResourceVersion, outsiderResourceVersion: outsiderWorkspace.ResourceVersion, + operationID: workspace.OperationID, outsiderOperationID: outsiderWorkspace.OperationID, + policyID: memberPolicy.ResourceID, memberID: resource.ID(securityMemberCanary), + runtimeState: runtimeState, store: store, + } +} + +func createSecurityPolicy( + t testing.TB, + service *reference.Service, + principal identity.Principal, + workspaceID resource.ID, + memberID resource.ID, + role authorization.Role, + displayName, canonicalTarget, idempotencyKey string, +) (authorization.MemberDirectory, reference.MutationReceipt) { + t.Helper() + record, err := authorization.NewMemberRecord(authorization.MemberInput{ + ID: memberID, WorkspaceID: workspaceID, Kind: principal.Kind(), + LogicalIdentity: principal.LogicalIdentity(), + }) + if err != nil { + t.Fatal(err) + } + directory, err := authorization.NewMemberDirectory( + workspaceID, + []authorization.MemberRecord{record}, + ) + if err != nil { + t.Fatal(err) + } + parentID := workspaceID + policyBody, err := json.Marshal(map[string]any{ + "apiVersion": "v1alpha1", + "kind": "Policy", + "metadata": map[string]any{"displayName": displayName}, + "spec": map[string]any{"bindings": []any{map[string]any{ + "memberId": memberID.String(), "role": role.String(), + "scope": map[string]any{"kind": "Workspace"}, + }}}, + }) + if err != nil { + t.Fatal(err) + } + receipt, err := service.Create(context.Background(), reference.CreateCommand{ + Principal: principal, Kind: hierarchy.KindPolicy, WorkspaceID: workspaceID, ParentID: &parentID, + CanonicalTarget: canonicalTarget, IdempotencyKey: idempotencyKey, + Body: policyBody, Members: directory, + }) + if err != nil { + t.Fatal(err) + } + return directory, receipt +} + +func securityPrincipal(t testing.TB, subject string) identity.Principal { + t.Helper() + principal, err := identity.NewPrincipal(identity.PrincipalInput{ + Kind: identity.KindHuman, Issuer: "https://security.example", Subject: subject, + Audiences: []string{"veer-api"}, + }) + if err != nil { + t.Fatal(err) + } + return principal +} + +func securityRequest( + t testing.TB, + handler http.Handler, + route securityRoute, + fixture securityFixture, + token string, +) *httptest.ResponseRecorder { + t.Helper() + return securityRequestWithHeaderOverrides(t, handler, route, fixture, token, nil) +} + +func securityRequestWithHeaderOverrides( + t testing.TB, + handler http.Handler, + route securityRoute, + fixture securityFixture, + token string, + overrides map[string]string, +) *httptest.ResponseRecorder { + t.Helper() + var body []byte + if route.body != nil { + body = route.body(fixture) + } + request := httptest.NewRequest(route.method, route.target(fixture), bytes.NewReader(body)) + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + if route.headers != nil { + for name, value := range route.headers(fixture) { + request.Header.Set(name, value) + } + } + for name, value := range overrides { + request.Header.Set(name, value) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func securityRequestWithStateInvariant( + t testing.TB, + fixture securityFixture, + route securityRoute, + expectedStatus int, + request func() *httptest.ResponseRecorder, +) *httptest.ResponseRecorder { + t.Helper() + if route.method == http.MethodGet { + return request() + } + before := observeSecurityRuntimeState(t, fixture, fixture.operationID) + response := request() + after := observeSecurityRuntimeState(t, fixture, fixture.operationID) + if expectedStatus >= http.StatusBadRequest { + assertSecurityRuntimeStateUnchanged(t, before, after) + } else if route.operationID == "replaceWorkspace" { + assertSecurityPrincipalRuntimeStateUnchanged(t, "outsider", before.outsider, after.outsider) + } + return response +} + +func observeSecurityRuntimeState( + t testing.TB, + fixture securityFixture, + operationIDs ...resource.ID, +) securityRuntimeSnapshot { + t.Helper() + return securityRuntimeSnapshot{ + member: observeSecurityPrincipalRuntimeState( + t, fixture.memberHandler, fixture.workspaceID, operationIDs..., + ), + outsider: observeSecurityPrincipalRuntimeState( + t, fixture.outsiderHandler, fixture.outsiderWorkspaceID, fixture.outsiderOperationID, + ), + mutationState: fixture.runtimeState.snapshot(), + } +} + +func observeSecurityPrincipalRuntimeState( + t testing.TB, + handler http.Handler, + workspaceID resource.ID, + operationIDs ...resource.ID, +) securityPrincipalRuntimeSnapshot { + t.Helper() + list := securityAuthorizedGet(t, handler, "/api/v1alpha1/workspaces") + workspace := securityAuthorizedGet(t, handler, "/api/v1alpha1/workspaces/"+workspaceID.String()) + var operations strings.Builder + for _, operationID := range operationIDs { + operationResponse := securityAuthorizedGet(t, handler, "/api/v1alpha1/operations/"+operationID.String()) + fmt.Fprintf(&operations, "%s\x00%s\x00%q\n", operationID, operationResponse.Body.String(), operationResponse.Header().Values("ETag")) + } + return securityPrincipalRuntimeSnapshot{ + workspaceList: list.Body.String(), + workspace: workspace.Body.String(), + workspaceETag: fmt.Sprintf("%q", workspace.Header().Values("ETag")), + operations: operations.String(), + } +} + +func securityAuthorizedGet(t testing.TB, handler http.Handler, target string) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(http.MethodGet, target, nil) + request.Header.Set("Authorization", "Bearer "+securityBearerCanary) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + assertSecurityResponse(t, response, http.StatusOK, "") + return response +} + +func assertSecurityRuntimeStateUnchanged( + t testing.TB, + before, after securityRuntimeSnapshot, +) { + t.Helper() + if before.mutationState != after.mutationState { + delta := after.mutationState + if strings.HasPrefix(after.mutationState, before.mutationState) { + delta = strings.TrimPrefix(after.mutationState, before.mutationState) + } + t.Fatalf("denied mutation changed the complete store or issuer mutation state: delta=%q", delta) + } + assertSecurityPrincipalRuntimeStateUnchanged(t, "member", before.member, after.member) + assertSecurityPrincipalRuntimeStateUnchanged(t, "outsider", before.outsider, after.outsider) +} + +func assertSecurityPrincipalRuntimeStateUnchanged( + t testing.TB, + principal string, + before, after securityPrincipalRuntimeSnapshot, +) { + t.Helper() + switch { + case before.workspaceList != after.workspaceList: + t.Fatalf("denied mutation changed the %s workspace list", principal) + case before.workspace != after.workspace || before.workspaceETag != after.workspaceETag: + t.Fatalf("denied mutation changed the %s workspace or resource version", principal) + case before.operations != after.operations: + t.Fatalf("denied mutation changed the %s operation state", principal) + } +} + +func assertSecurityResponse( + t testing.TB, + response *httptest.ResponseRecorder, + status int, + problemCode string, +) { + t.Helper() + if response.Code != status { + t.Fatalf("response status = %d, want %d; body=%s", response.Code, status, response.Body.String()) + } + assertSingletonHeader(t, response, "Cache-Control", "no-store") + assertSingletonHeader(t, response, "X-Content-Type-Options", "nosniff") + requestIDs := response.Header().Values("Veer-Request-Id") + if len(requestIDs) != 1 || !securityRequestIDPattern.MatchString(requestIDs[0]) { + t.Fatalf("Veer-Request-Id values = %q, want exactly one canonical value", requestIDs) + } + if !json.Valid(response.Body.Bytes()) { + t.Fatalf("response is not JSON: %q", response.Body.Bytes()) + } + if problemCode != "" { + problem := assertSecurityProblemContract(t, response) + if problem.Code != problemCode { + t.Fatalf("problem code = %q, want %q; body=%s", problem.Code, problemCode, response.Body.String()) + } + } else { + assertSingletonHeader(t, response, "Content-Type", "application/json") + } + assertNoBearerCanary(t, response) +} + +func assertNoBearerCanary(t testing.TB, response *httptest.ResponseRecorder) { + t.Helper() + assertNoResponseCanary(t, response, securityBearerCanary, invalidBearerCanary) +} + +func assertNoMemberFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fixture securityFixture) { + t.Helper() + assertNoSuccessfulResponseCanary( + t, + response, + securityResourceCanary, + securityPolicyCanary, + securityMemberCanary, + securityIdentityCanary, + securityStaleVersionCanary, + fixture.workspaceID.String(), + fixture.resourceVersion, + fixture.operationID.String(), + fixture.policyID.String(), + fixture.memberID.String(), + ) +} + +func assertNoSuccessfulFixtureSecrets(t testing.TB, response *httptest.ResponseRecorder, fixture securityFixture) { + t.Helper() + assertNoSuccessfulResponseCanary( + t, + response, + securityPolicyCanary, + securityMemberCanary, + securityIdentityCanary, + securityStaleVersionCanary, + fixture.policyID.String(), + fixture.memberID.String(), + ) +} + +func assertNoFixtureCanary(t testing.TB, response *httptest.ResponseRecorder, fixture securityFixture) { + assertNoFixtureCanaryExceptHeaders(t, response, fixture, nil) +} + +func assertNoFixtureCanaryWithCurrentETag( + t testing.TB, + response *httptest.ResponseRecorder, + fixture securityFixture, +) { + t.Helper() + assertNoFixtureCanaryExceptHeaders( + t, + response, + fixture, + map[string]string{http.CanonicalHeaderKey("ETag"): `"` + fixture.resourceVersion + `"`}, + ) +} + +func assertNoFixtureCanaryExceptHeaders( + t testing.TB, + response *httptest.ResponseRecorder, + fixture securityFixture, + allowedHeaders map[string]string, +) { + t.Helper() + outputs := []string{response.Body.String()} + problemResponse := response.Header().Get("Content-Type") == "application/problem+json" + if problemResponse { + if err := validateUniqueJSONMembers(response.Body.Bytes()); err != nil { + t.Fatalf("validate problem for confidentiality scan: %v; body=%s", err, response.Body.String()) + } + decoder := json.NewDecoder(bytes.NewReader(response.Body.Bytes())) + decoder.DisallowUnknownFields() + var problem securityProblem + if err := decoder.Decode(&problem); err != nil { + t.Fatalf("decode problem for confidentiality scan: %v; body=%s", err, response.Body.String()) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + t.Fatalf("problem has trailing JSON during confidentiality scan: %v; body=%s", err, response.Body.String()) + } + requestIDs := response.Header().Values("Veer-Request-Id") + if len(requestIDs) != 1 || problem.RequestID != requestIDs[0] || + problem.Instance != "urn:veer:request:"+problem.RequestID { + t.Fatalf("problem correlation fields are not uniquely bound: body=%s headers=%#v", response.Body.String(), response.Header()) + } + problem.RequestID = "" + problem.Instance = "" + encoded, err := json.Marshal(problem) + if err != nil { + t.Fatalf("encode problem for confidentiality scan: %v", err) + } + outputs[0] = string(encoded) + } + for name, want := range allowedHeaders { + assertSingletonHeader(t, response, name, want) + } + for name, values := range response.Header() { + if problemResponse && strings.EqualFold(name, "Veer-Request-Id") { + continue + } + if _, allowed := allowedHeaders[http.CanonicalHeaderKey(name)]; allowed { + continue + } + outputs = append(outputs, values...) + } + assertOutputsDoNotContainCanaries( + t, + outputs, + securityResourceCanary, + securityPolicyCanary, + securityMemberCanary, + securityIdentityCanary, + securityStaleVersionCanary, + securityOutsiderCanary, + fixture.workspaceID.String(), + fixture.outsiderWorkspaceID.String(), + fixture.resourceVersion, + fixture.outsiderResourceVersion, + fixture.operationID.String(), + fixture.outsiderOperationID.String(), + fixture.policyID.String(), + fixture.memberID.String(), + ) +} + +func assertNoSuccessfulResponseCanary( + t testing.TB, + response *httptest.ResponseRecorder, + canaries ...string, +) { + t.Helper() + requestIDs := response.Header().Values("Veer-Request-Id") + if len(requestIDs) != 1 || !securityRequestIDPattern.MatchString(requestIDs[0]) { + t.Fatalf("Veer-Request-Id values = %q, want exactly one canonical value", requestIDs) + } + canonical, err := canonicalSecurityJSON(response.Body.Bytes()) + if err != nil { + t.Fatalf("decode successful JSON for confidentiality scan: %v", err) + } + outputs := []string{response.Body.String(), canonical} + for name, values := range response.Header() { + if strings.EqualFold(name, "Veer-Request-Id") { + continue + } + outputs = append(outputs, values...) + } + assertOutputsDoNotContainCanaries(t, outputs, canaries...) +} + +func assertNoResponseCanary(t testing.TB, response *httptest.ResponseRecorder, canaries ...string) { + t.Helper() + canonical, err := canonicalSecurityJSON(response.Body.Bytes()) + if err != nil { + t.Fatalf("decode JSON for confidentiality scan: %v", err) + } + outputs := []string{response.Body.String(), canonical} + for _, values := range response.Header() { + outputs = append(outputs, values...) + } + assertOutputsDoNotContainCanaries(t, outputs, canaries...) +} + +func canonicalSecurityJSON(body []byte) (string, error) { + if !utf8.Valid(body) { + return "", errors.New("JSON contains invalid UTF-8") + } + if err := validateUniqueJSONMembers(body); err != nil { + return "", err + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return "", err + } + canonical, err := json.Marshal(value) + return string(canonical), err +} + +func TestSecurityJSONCanonicalization(t *testing.T) { + t.Parallel() + for _, canary := range []string{securityPolicyCanary, securityIdentityCanary, securityBearerCanary} { + t.Run(canary, func(t *testing.T) { + escaped := strings.ReplaceAll(canary, "-", `\u002d`) + body := []byte(`{"nested":[{"` + escaped + `":"` + escaped + `"}]}`) + canonical, err := canonicalSecurityJSON(body) + if err != nil || strings.Count(canonical, canary) != 2 { + t.Fatalf("decoded canary is not visible in both member name and value: %q, %v", canonical, err) + } + }) + } + for _, body := range [][]byte{ + {'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}, + []byte(`{"x":"secret","\u0078":"safe"}`), + []byte(`{"x":"secret"} {"x":"safe"}`), + } { + if _, err := canonicalSecurityJSON(body); err == nil { + t.Fatalf("accepted malformed or ambiguous JSON: %q", body) + } + } +} + +func assertOutputsDoNotContainCanaries(t testing.TB, outputs []string, canaries ...string) { + t.Helper() + for _, output := range outputs { + for _, canary := range canaries { + if canary != "" && strings.Contains(output, canary) { + t.Fatalf("response disclosed confidentiality canary %q in %q", canary, output) + } + } + } +} + +func assertAuthenticationDenial(t testing.TB, response *httptest.ResponseRecorder, challenge string) { + t.Helper() + assertSecurityResponse(t, response, http.StatusUnauthorized, "authentication-required") + assertSecurityAuthenticationChallenge(t, response, challenge) +} + +func assertSecurityAuthenticationChallenge( + t testing.TB, + response *httptest.ResponseRecorder, + want string, +) { + t.Helper() + challenges := response.Header().Values("WWW-Authenticate") + if len(challenges) != 1 { + t.Fatalf("WWW-Authenticate values = %q, want exactly one", challenges) + } + if want != "" && challenges[0] != want { + t.Fatalf("WWW-Authenticate = %q, want %q", challenges[0], want) + } + if want == "" { + switch challenges[0] { + case `Bearer realm="veer"`, + `Bearer realm="veer", error="invalid_request"`, + `Bearer realm="veer", error="invalid_token"`: + default: + t.Fatalf("WWW-Authenticate = %q, want a canonical bearer challenge", challenges[0]) + } + } +} + +func assertSingletonHeader( + t testing.TB, + response *httptest.ResponseRecorder, + name, want string, +) { + t.Helper() + values := response.Header().Values(name) + if len(values) != 1 || values[0] != want { + t.Fatalf("%s values = %q, want exactly one %q", name, values, want) + } +} + +func assertWorkspacePage( + t testing.TB, + response *httptest.ResponseRecorder, + wantID resource.ID, + forbiddenCanary string, +) { + t.Helper() + var page struct { + Items []struct { + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` + } `json:"items"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil { + t.Fatalf("decode workspace page: %v; body=%s", err, response.Body.String()) + } + var firstID string + if len(page.Items) > 0 { + firstID = page.Items[0].Metadata.ID + } + if len(page.Items) != 1 || firstID != wantID.String() || page.NextPageToken != "" { + t.Fatalf( + "workspace page = items:%d id:%q next:%q, want 1/%q/empty; body=%s", + len(page.Items), firstID, page.NextPageToken, wantID, response.Body.String(), + ) + } + assertNoSuccessfulResponseCanary(t, response, forbiddenCanary) +} + +func assertWorkspaceObject( + t testing.TB, + response *httptest.ResponseRecorder, + wantID resource.ID, + wantDisplayName, wantResourceVersion string, +) { + t.Helper() + var workspace struct { + Metadata struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + ResourceVersion string `json:"resourceVersion"` + } `json:"metadata"` + } + if err := json.Unmarshal(response.Body.Bytes(), &workspace); err != nil { + t.Fatalf("decode workspace: %v; body=%s", err, response.Body.String()) + } + if workspace.Metadata.ID != wantID.String() || workspace.Metadata.DisplayName != wantDisplayName || + workspace.Metadata.ResourceVersion != wantResourceVersion { + t.Fatalf( + "workspace identity = id:%q displayName:%q resourceVersion:%q, want %q/%q/%q; body=%s", + workspace.Metadata.ID, workspace.Metadata.DisplayName, workspace.Metadata.ResourceVersion, + wantID, wantDisplayName, wantResourceVersion, response.Body.String(), + ) + } + assertSingletonHeader(t, response, "ETag", strconv.Quote(workspace.Metadata.ResourceVersion)) +} + +func assertOperationObject( + t testing.TB, + response *httptest.ResponseRecorder, + wantID, wantWorkspaceID, wantResourceID resource.ID, +) { + t.Helper() + var operation struct { + ID string `json:"id"` + WorkspaceID string `json:"workspaceId"` + ResourceID string `json:"resourceId"` + ResourceVersion string `json:"resourceVersion"` + } + if err := json.Unmarshal(response.Body.Bytes(), &operation); err != nil { + t.Fatalf("decode operation: %v; body=%s", err, response.Body.String()) + } + if operation.ID != wantID.String() || operation.WorkspaceID != wantWorkspaceID.String() || + operation.ResourceID != wantResourceID.String() { + t.Fatalf( + "operation identity = id:%q workspace:%q resource:%q, want %q/%q/%q; body=%s", + operation.ID, operation.WorkspaceID, operation.ResourceID, + wantID, wantWorkspaceID, wantResourceID, response.Body.String(), + ) + } + if operation.ResourceVersion == "" { + t.Fatalf("operation resource version is empty; body=%s", response.Body.String()) + } + assertSingletonHeader(t, response, "ETag", strconv.Quote(operation.ResourceVersion)) +} + +func assertMutationReceipt( + t testing.TB, + response *httptest.ResponseRecorder, + wantResourceID resource.ID, +) reference.MutationReceipt { + t.Helper() + var receipt reference.MutationReceipt + decoder := json.NewDecoder(bytes.NewReader(response.Body.Bytes())) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&receipt); err != nil { + t.Fatalf("decode mutation receipt: %v; body=%s", err, response.Body.String()) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + t.Fatalf("mutation receipt has trailing JSON: %v; body=%s", err, response.Body.String()) + } + if receipt.ResourceID != wantResourceID { + t.Fatalf("mutation receipt resource ID = %q, want %q; body=%s", receipt.ResourceID, wantResourceID, response.Body.String()) + } + if _, err := resource.ParseID(receipt.OperationID.String()); err != nil { + t.Fatalf("mutation receipt operation ID = %q: %v; body=%s", receipt.OperationID, err, response.Body.String()) + } + assertSingletonHeader( + t, + response, + "Location", + "/api/v1alpha1/operations/"+receipt.OperationID.String(), + ) + return receipt +} + +func assertReplacementOperation(t testing.TB, fixture securityFixture, receipt reference.MutationReceipt) { + t.Helper() + if receipt.OperationID == fixture.operationID { + t.Fatal("replacement receipt reused the bootstrap operation") + } + scopes, err := isolation.NewWorkspaceScopeSet(fixture.workspaceID) + if err != nil { + t.Fatal(err) + } + var operationBytes, workspaceBytes []byte + if err := fixture.store.View(context.Background(), scopes, func(reader ports.ReferenceReader) error { + var exists bool + operationBytes, exists = reader.GetOperation(receipt.OperationID) + if !exists { + return errors.New("replacement operation was not persisted") + } + workspaceBytes, exists = reader.GetResource(fixture.workspaceID) + if !exists { + return errors.New("replacement workspace was not persisted") + } + return nil + }); err != nil { + t.Fatal(err) + } + operationResponse := securityAuthorizedGet( + t, fixture.memberHandler, "/api/v1alpha1/operations/"+receipt.OperationID.String(), + ) + assertOperationObject(t, operationResponse, receipt.OperationID, fixture.workspaceID, receipt.ResourceID) + assertNoSuccessfulFixtureSecrets(t, operationResponse, fixture) + if !bytes.Equal(operationResponse.Body.Bytes(), operationBytes) { + t.Fatal("replacement operation response differs from its persisted canonical bytes") + } + var persistedOperation struct { + Generation int64 `json:"generation"` + } + var persistedWorkspace struct { + Metadata struct { + Generation int64 `json:"generation"` + ResourceVersion string `json:"resourceVersion"` + } `json:"metadata"` + } + if err := json.Unmarshal(operationBytes, &persistedOperation); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(workspaceBytes, &persistedWorkspace); err != nil { + t.Fatal(err) + } + if receipt.Generation < 1 || persistedOperation.Generation != receipt.Generation || + persistedWorkspace.Metadata.Generation != receipt.Generation || + persistedWorkspace.Metadata.ResourceVersion != receipt.ResourceVersion { + t.Fatalf("replacement receipt is not bound to persisted workspace and operation: receipt=%+v operation=%+v workspace=%+v", + receipt, persistedOperation, persistedWorkspace) + } +} + +func assertSecurityAllowHeader(t testing.TB, response *httptest.ResponseRecorder, request *http.Request) { + t.Helper() + path := request.URL.Path + // The operation endpoint rejects methods before parsing its identifier. + if strings.HasPrefix(path, "/api/v1alpha1/operations/") { + path = "/api/v1alpha1/operations/{operationId}" + } + var methods []string + for _, route := range publicSecurityRoutes() { + if securityPathMatches(route.pathTemplate, path) { + if route.method == request.Method { + t.Fatalf("405 rejected an allowed method %q on %q", request.Method, request.URL.Path) + } + methods = append(methods, route.method) + } + } + if len(methods) == 0 { + t.Fatalf("405 has no matched public route: %q", request.URL.Path) + } + sort.Strings(methods) + assertSingletonHeader(t, response, "Allow", strings.Join(methods, ", ")) +} + +func assertSecurityProblemContract(t testing.TB, response *httptest.ResponseRecorder) securityProblem { + t.Helper() + assertSingletonHeader(t, response, "Content-Type", "application/problem+json") + if response.Body.Len() == 0 || response.Body.Len() > maximumSecurityProblemBytes { + t.Fatalf("problem body bytes = %d, want 1..%d", response.Body.Len(), maximumSecurityProblemBytes) + } + if err := validateUniqueJSONMembers(response.Body.Bytes()); err != nil { + t.Fatalf("runtime problem JSON members: %v; body=%s", err, response.Body.String()) + } + if err := validateSecurityProblemMembers(response.Body.Bytes()); err != nil { + t.Fatalf("runtime problem required members: %v; body=%s", err, response.Body.String()) + } + decoder := json.NewDecoder(bytes.NewReader(response.Body.Bytes())) + decoder.DisallowUnknownFields() + var problem securityProblem + if err := decoder.Decode(&problem); err != nil { + t.Fatalf("decode runtime problem: %v; body=%s", err, response.Body.String()) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + t.Fatalf("runtime problem has trailing JSON: %v; body=%s", err, response.Body.String()) + } + want, codeKnown := securityProblemExpectations[problem.Code] + if !codeKnown || want.status != response.Code || problem.Status != response.Code { + t.Fatalf( + "runtime problem code/status = %q/%d/%d, want a closed code for HTTP %d", + problem.Code, problem.Status, want.status, response.Code, + ) + } + if problem.Title != want.title { + t.Fatalf("runtime problem title = %q, want %q for code %q", problem.Title, want.title, problem.Code) + } + requestIDs := response.Header().Values("Veer-Request-Id") + if len(requestIDs) != 1 { + t.Fatalf("Veer-Request-Id values = %q, want exactly one", requestIDs) + } + requestID := requestIDs[0] + if !securityRequestIDPattern.MatchString(problem.RequestID) || problem.RequestID != requestID || + problem.Instance != "urn:veer:request:"+problem.RequestID || + problem.Type != "urn:veer:problem:"+problem.Code { + t.Fatalf( + "runtime problem identity = type:%q instance:%q request:%q header:%q", + problem.Type, problem.Instance, problem.RequestID, requestID, + ) + } + if len(problem.Type) > 81 || !safeSecurityProblemText(problem.Title, 64, true) || + !safeSecurityProblemText(problem.Detail, 192, false) || len(problem.Instance) > 81 || + len(problem.Code) > 64 || !securityProblemCodePattern.MatchString(problem.Code) { + t.Fatalf("runtime problem primitive bounds failed: %#v", problem) + } + if len(problem.Errors) > 1 { + t.Fatalf("runtime problem errors = %d, want at most 1", len(problem.Errors)) + } + for _, violation := range problem.Errors { + if len(violation.Field) == 0 || len(violation.Field) > maximumSecurityFieldPathSize || + !securityFieldPathPattern.MatchString(violation.Field) || + len(violation.Code) == 0 || len(violation.Code) > 32 || + !securityProblemCodePattern.MatchString(violation.Code) || + !safeSecurityProblemText(violation.Message, 96, false) { + t.Fatalf("runtime field violation bounds failed: %#v", violation) + } + } + if err := validateSecurityRetryAfter(want, problem, response.Header().Values("Retry-After")); err != nil { + t.Fatalf("runtime retry metadata: %v", err) + } + return problem +} + +func validateSecurityRetryAfter( + want securityProblemExpectation, + problem securityProblem, + headers []string, +) error { + if !want.requiresRetryAfter { + if problem.RetryAfterSeconds != nil || len(headers) != 0 { + return errors.New("non-retryable problem declared retry metadata") + } + return nil + } + if problem.RetryAfterSeconds == nil || *problem.RetryAfterSeconds < 1 || *problem.RetryAfterSeconds > 86_400 { + return errors.New("retryable problem requires retryAfterSeconds in 1..86400") + } + if len(headers) != 1 || headers[0] != strconv.Itoa(*problem.RetryAfterSeconds) { + return fmt.Errorf("Retry-After values %q do not match retryAfterSeconds %d", headers, *problem.RetryAfterSeconds) + } + return nil +} + +func validateSecurityProblemMembers(data []byte) error { + var problem map[string]json.RawMessage + if err := json.Unmarshal(data, &problem); err != nil { + return err + } + if err := requireNonNullJSONMembers( + problem, + []string{"type", "title", "status", "instance", "code", "requestId"}, + ); err != nil { + return err + } + for _, optional := range []string{"detail", "errors", "retryAfterSeconds"} { + if raw, exists := problem[optional]; exists && isJSONNull(raw) { + return fmt.Errorf("problem member %q is null", optional) + } + } + rawErrors, exists := problem["errors"] + if !exists { + return nil + } + var violations []map[string]json.RawMessage + if err := json.Unmarshal(rawErrors, &violations); err != nil { + return fmt.Errorf("decode problem errors: %w", err) + } + for index, violation := range violations { + if err := requireNonNullJSONMembers(violation, []string{"field", "code", "message"}); err != nil { + return fmt.Errorf("problem errors[%d]: %w", index, err) + } + } + return nil +} + +func requireNonNullJSONMembers(object map[string]json.RawMessage, names []string) error { + for _, name := range names { + raw, exists := object[name] + if !exists { + return fmt.Errorf("required member %q is missing", name) + } + if isJSONNull(raw) { + return fmt.Errorf("required member %q is null", name) + } + } + return nil +} + +func isJSONNull(raw json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(raw), []byte("null")) +} + +func validateUniqueJSONMembers(data []byte) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := scanUniqueJSONValue(decoder, 0); err != nil { + return err + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + return errors.New("JSON has a trailing value") + } + return nil +} + +func scanUniqueJSONValue(decoder *json.Decoder, depth int) error { + if depth > 64 { + return errors.New("JSON nesting exceeds 64 levels") + } + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, compound := token.(json.Delim) + if !compound { + return nil + } + switch delimiter { + case '{': + seen := make([]string, 0) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("JSON object member name is not a string") + } + for _, existing := range seen { + if strings.EqualFold(existing, key) { + return fmt.Errorf("duplicate case-insensitive JSON member %q", key) + } + } + seen = append(seen, key) + if err := scanUniqueJSONValue(decoder, depth+1); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim('}') { + return errors.New("JSON object is not closed") + } + return nil + case '[': + for decoder.More() { + if err := scanUniqueJSONValue(decoder, depth+1); err != nil { + return err + } + } + closing, err := decoder.Token() + if err != nil || closing != json.Delim(']') { + return errors.New("JSON array is not closed") + } + return nil + default: + return errors.New("unexpected JSON delimiter") + } +} + +func safeSecurityProblemText(value string, maximum int, required bool) bool { + if len(value) > maximum || required && value == "" { + return false + } + for index := range len(value) { + character := value[index] + if character < 0x20 || character > 0x7e || strings.ContainsRune(`"&<>\`, rune(character)) { + return false + } + } + return true +} + +func loadSecuritySuccessResponseLimits(t testing.TB) map[string]int { + t.Helper() + data, err := openapi.Load(filepath.Join(repositoryRoot(t), "api/openapi/veer-v1alpha1.json")) + if err != nil { + t.Fatal(err) + } + if err := openapi.Validate(data); err != nil { + t.Fatal(err) + } + type referenceObject struct { + Reference string `json:"$ref"` + } + type operationObject struct { + OperationID string `json:"operationId"` + Responses map[string]referenceObject `json:"responses"` + } + var document struct { + Paths map[string]map[string]json.RawMessage `json:"paths"` + Components struct { + Responses map[string]struct { + Content map[string]struct { + Schema referenceObject `json:"schema"` + } `json:"content"` + } `json:"responses"` + Schemas map[string]struct { + MaximumJSONBytes int `json:"x-veer-maximum-json-bytes"` + } `json:"schemas"` + } `json:"components"` + } + if err := json.Unmarshal(data, &document); err != nil { + t.Fatal(err) + } + + limits := make(map[string]int) + for path, item := range document.Paths { + for method, raw := range item { + if !isHTTPMethod(method) { + continue + } + var operation operationObject + if err := json.Unmarshal(raw, &operation); err != nil { + t.Fatalf("decode %s %s operation: %v", strings.ToUpper(method), path, err) + } + var successReference string + for status, response := range operation.Responses { + statusCode, err := strconv.Atoi(status) + if err != nil || statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + continue + } + if successReference != "" { + t.Fatalf("%s %s has multiple successful response contracts", strings.ToUpper(method), path) + } + successReference = response.Reference + } + responseName, ok := strings.CutPrefix(successReference, "#/components/responses/") + if !ok || responseName == "" || strings.Contains(responseName, "/") { + t.Fatalf("%s %s success response reference = %q", strings.ToUpper(method), path, successReference) + } + response, ok := document.Components.Responses[responseName] + if !ok { + t.Fatalf("%s %s success response component %q is missing", strings.ToUpper(method), path, responseName) + } + media, ok := response.Content["application/json"] + if !ok { + t.Fatalf("%s %s success response %q has no application/json schema", strings.ToUpper(method), path, responseName) + } + schemaName, ok := strings.CutPrefix(media.Schema.Reference, "#/components/schemas/") + if !ok || schemaName == "" || strings.Contains(schemaName, "/") { + t.Fatalf("%s %s success schema reference = %q", strings.ToUpper(method), path, media.Schema.Reference) + } + schema, ok := document.Components.Schemas[schemaName] + if !ok || schema.MaximumJSONBytes <= 0 { + t.Fatalf("%s %s success schema %q has no positive JSON byte limit", strings.ToUpper(method), path, schemaName) + } + if operation.OperationID == "" { + t.Fatalf("%s %s has no operationId", strings.ToUpper(method), path) + } + if _, exists := limits[operation.OperationID]; exists { + t.Fatalf("operationId %q is duplicated", operation.OperationID) + } + limits[operation.OperationID] = schema.MaximumJSONBytes + } + } + return limits +} + +func securityOperationIDForRequest(method, path string) string { + for _, route := range publicSecurityRoutes() { + if route.method == method && securityPathMatches(route.pathTemplate, path) { + return route.operationID + } + } + return "" +} + +func securityPathMatches(template, path string) bool { + templateParts := strings.Split(strings.Trim(template, "/"), "/") + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(templateParts) != len(pathParts) { + return false + } + for index, templatePart := range templateParts { + parameter := strings.HasPrefix(templatePart, "{") && strings.HasSuffix(templatePart, "}") + if parameter && pathParts[index] != "" { + continue + } + if templatePart != pathParts[index] { + return false + } + } + return true +} + +func assertOpenAPIRouteCoverage(t *testing.T, routes []securityRoute) { + t.Helper() + data, err := openapi.Load(filepath.Join(repositoryRoot(t), "api/openapi/veer-v1alpha1.json")) + if err != nil { + t.Fatal(err) + } + if err := openapi.Validate(data); err != nil { + t.Fatal(err) + } + var document struct { + Paths map[string]map[string]json.RawMessage `json:"paths"` + } + if err := json.Unmarshal(data, &document); err != nil { + t.Fatal(err) + } + var got []string + for path, item := range document.Paths { + for method, raw := range item { + if !isHTTPMethod(method) { + continue + } + var operation struct { + OperationID string `json:"operationId"` + } + if err := json.Unmarshal(raw, &operation); err != nil || operation.OperationID == "" { + t.Fatalf("decode %s %s operation: %v", method, path, err) + } + got = append(got, operation.OperationID+"\t"+strings.ToUpper(method)+"\t"+path) + } + } + want := make([]string, len(routes)) + for index, route := range routes { + want[index] = route.operationID + "\t" + route.method + "\t" + route.pathTemplate + } + sort.Strings(got) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("public route security coverage drifted:\n got %q\nwant %q", got, want) + } +} + +func isHTTPMethod(value string) bool { + switch strings.ToUpper(value) { + case http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, + http.MethodOptions, http.MethodPatch, http.MethodPost, http.MethodPut, http.MethodTrace: + return true + default: + return false + } +} + +func writeOrCompareSecurityMatrix(t *testing.T, results []securityRouteResult) { + t.Helper() + root := repositoryRoot(t) + artifact := "api/openapi/veer-v1alpha1.json" + data, err := openapi.Load(filepath.Join(root, artifact)) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + report := securityMatrixReport{ + Contract: "veer-security-regression-v1alpha1", OpenAPIArtifact: artifact, + OpenAPISHA256: hex.EncodeToString(digest[:]), OpenAPIValidated: true, Routes: results, + } + got, err := json.MarshalIndent(report, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + golden := filepath.Join(root, "test/security/testdata/security-regression-v1alpha1.golden.json") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if t.Failed() { + t.Log("skipping security matrix update after a failed route observation") + return + } + if err := os.WriteFile(golden, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read security matrix: %v\ngenerated:\n%s", err, got) + } + if !bytes.Equal(got, want) { + t.Fatalf("security matrix drifted:\n generated:\n%s\nchecked in:\n%s", got, want) + } +} + +func validWorkspaceBody(name string) []byte { + encoded, err := json.Marshal(map[string]any{ + "apiVersion": "v1alpha1", + "kind": "Workspace", + "metadata": map[string]any{"displayName": name}, + "spec": map[string]any{}, + }) + if err != nil { + panic(err) + } + return encoded +} + +func repositoryRoot(t testing.TB) string { + t.Helper() + directory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + contents, readErr := os.ReadFile(filepath.Join(directory, "go.mod")) + if readErr == nil && bytes.Contains(contents, []byte("module github.com/ArdurAI/veer\n")) { + return directory + } + parent := filepath.Dir(directory) + if parent == directory { + t.Fatal(errors.New("repository root containing canonical go.mod was not found")) + } + directory = parent + } +} diff --git a/test/security/testdata/security-regression-v1alpha1.golden.json b/test/security/testdata/security-regression-v1alpha1.golden.json new file mode 100644 index 0000000..ace9797 --- /dev/null +++ b/test/security/testdata/security-regression-v1alpha1.golden.json @@ -0,0 +1,85 @@ +{ + "contract": "veer-security-regression-v1alpha1", + "openapiArtifact": "api/openapi/veer-v1alpha1.json", + "openapiSha256": "7f7ab682e07e4cd9d4b5e626c2aafdc416efe43cb08ddc8a55b9bc6cf448237e", + "openapiValidated": true, + "routes": [ + { + "operationId": "listWorkspaces", + "method": "GET", + "pathTemplate": "/api/v1alpha1/workspaces", + "memberStatus": 200, + "memberOutcome": "allowed", + "outsiderStatus": 200, + "outsiderOutcome": "denied-rows-filtered-before-pagination", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + }, + { + "operationId": "createWorkspace", + "method": "POST", + "pathTemplate": "/api/v1alpha1/workspaces", + "memberStatus": 403, + "memberOutcome": "service-reserved-deny", + "outsiderStatus": 403, + "outsiderOutcome": "service-reserved-deny", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + }, + { + "operationId": "getWorkspace", + "method": "GET", + "pathTemplate": "/api/v1alpha1/workspaces/{workspaceId}", + "memberStatus": 200, + "memberOutcome": "allowed", + "outsiderStatus": 403, + "outsiderOutcome": "authorization-denied", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + }, + { + "operationId": "replaceWorkspace", + "method": "PUT", + "pathTemplate": "/api/v1alpha1/workspaces/{workspaceId}", + "memberStatus": 202, + "memberOutcome": "allowed", + "outsiderStatus": 403, + "outsiderOutcome": "authorization-denied", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + }, + { + "operationId": "deleteWorkspace", + "method": "DELETE", + "pathTemplate": "/api/v1alpha1/workspaces/{workspaceId}", + "memberStatus": 409, + "memberOutcome": "authorization-allowed-lifecycle-denied", + "outsiderStatus": 403, + "outsiderOutcome": "authorization-denied", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + }, + { + "operationId": "replaceWorkspaceStatus", + "method": "PUT", + "pathTemplate": "/api/v1alpha1/workspaces/{workspaceId}/status", + "memberStatus": 403, + "memberOutcome": "service-reserved-deny", + "outsiderStatus": 403, + "outsiderOutcome": "service-reserved-deny", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + }, + { + "operationId": "getOperation", + "method": "GET", + "pathTemplate": "/api/v1alpha1/operations/{operationId}", + "memberStatus": 200, + "memberOutcome": "allowed", + "outsiderStatus": 403, + "outsiderOutcome": "authorization-denied", + "missingCredentialStatus": 401, + "invalidCredentialStatus": 401 + } + ] +}