From 8645711b6354d987a0ee5606fdb2453969c447ab Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 9 Oct 2026 12:17:08 -0400 Subject: [PATCH 01/10] docs: propose tenant-scoped revocable agent access --- README.md | 6 + ...10-09-tenant-scoped-agent-access-design.md | 163 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 docs/plans/2026-10-09-tenant-scoped-agent-access-design.md diff --git a/README.md b/README.md index edfa29f..65b2ead 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,12 @@ true`. ## Auth Use Cases & Combinations +Tenant-scoped agent access is proposed in +[the agent access design](docs/plans/2026-10-09-tenant-scoped-agent-access-design.md). +It documents current component gaps and an owner-approved, revocable grant with +short-lived access. It is not a deployed capability or permission to reuse a +personal admin session. + `workflow-plugin-auth` is a library of stateless auth primitives; complete auth flows are composed from these steps plus the engine's built-in `auth.*` modules and the provider plugins. Which combination covers which use case: diff --git a/docs/plans/2026-10-09-tenant-scoped-agent-access-design.md b/docs/plans/2026-10-09-tenant-scoped-agent-access-design.md new file mode 100644 index 0000000..28856c7 --- /dev/null +++ b/docs/plans/2026-10-09-tenant-scoped-agent-access-design.md @@ -0,0 +1,163 @@ +# Tenant-scoped agent access using the reusable auth stack + +Status: proposed source design. No client, key, credential, grant, secret, token, +deployment or persistent permission has been created. Owner activation is held. + +## Problem and observed boundary + +Jon wants agents to verify saved CMS edits and make authorized preview changes +without repeatedly borrowing his personal admin browser session. On 2026-10-09, +the task-owned Tina browser closed at its original four-hour limit, 13:29 UTC. +The control socket no longer exists. Jon confirmed at 16:03 UTC that he made no +edits/customizations, but that statement does not restore authentication. The +three preferred Facebook links and general Contact paragraph remain staged. +No personal browser profile, cookie, token or credential is an acceptable +replacement. No new login handoff is requested by this design. + +Persisted page versions, content digests and durable history can independently +verify **saved** edits. An unchanged DB does not prove that an editor has no +unsaved draft. That needs editor autosave with its own versioned draft record, +or authenticated edit-presence/leases with expiry and explicit uncertainty after +a disconnected client. Presence is advisory; optimistic saves still enforce +the loaded version. History begins when adopted, not retroactively. + +## Current reusable components, verified from source + +| Component | Available behavior | Relevant limit | +| --- | --- | --- | +| `workflow-plugin-auth/adminidentity` at `abf817c` | Typed human principal/store/session adapters; plugin-owned profile, setup and factor UI | No service-principal grant/exchange lifecycle; a setup-code or human 24-hour session is not agent access | +| Workflow `auth.m2m` at inspected `2235eac` | ES256 issue/JWKS; client-credentials and RFC7523 JWT-bearer assertion grants; configurable expiry; typed `TokenRevocationStore` | It is not RFC8693 token exchange. JWT-bearer copies requested scope without a registered-client ceiling. `Authenticate` alone does not pin issuer/audience or require all proposed claims; revocation-store errors currently do not deny | +| `workflow-plugin-sso` provider composition | Existing auth#41 design selects issuer/JWKS verification through the SSO provider rather than building another IDP | Resource grant/revocation checks are still required; a public key is not a CMS permission | +| `workflow-plugin-authz` / multisite adapters | Signed capability ceiling AND current durable role/tenant membership; platform routes separately protected | Current host accepts its human HS256 sessions and resolves durable human users; an arbitrary M2M token cannot be relabelled as one | +| CMS PR35 at `163de28` | Atomic `expected_version` saves/deletes, draft-preserving conflict behavior, transactional tenant revision ledger | Not installed in current v0.1.7 runtime. The ledger counts mutations but is not a full saved-content/actor history; additive upstream history work is separate | + +Source references: `adminidentity/types.go`, `adminidentity/handler.go`, +`README.md` Auth Use Cases, and the existing auth#41 design. Workflow references: +`module/auth_m2m.go` (`handleJWTBearer`, `Authenticate`, `parseTokenClaims`, +`isJTIRevoked`). Host reference: multisite `8d38c3b`, +`cmd/multisite-host/admin_plugin_stack.go` (`IssueSession`, `claimsFromRequest`, +`AuthorizeRequest`, `AuthorizeTenantRequest`) and `admin_plugin_authorization.go`. +These are source observations, not claims that a new exchange is deployed. + +The original 2026-06-21 reusable identity/authz architecture remains the rule: +shared auth behavior belongs in plugins/Workflow, with only durable adapters, +tenant/resource policy and route assembly in the consuming host. Preserve locked +provider plans and existing human bootstrap/factor/continuity protections. + +## Proposed first grant: exact owner-reviewable authority + +Use a distinct service principal, proposed display name `Tina preview assistant`. +Its stable opaque subject is separate from Jon/Tina's human users and credentials. +No `super_admin`, `cms.admin`, platform role or human enrollment is granted. +Default activation is read-only; the two-page write capability is a separately +visible owner checkbox for this already requested correction task. + +| Permission | Exact boundary | +| --- | --- | +| `admin:multisite.pages:read` | Read saved pages/versions/templates in preview tenant **234**, slug `tinalangevin-review`; tenant listing is filtered to this tenant. No domains/platform listing | +| Proposed `admin:multisite.pages:history:read` | Read this tenant's saved change records/digests; actor metadata is minimal and authorized. No cross-tenant/auth audit history | +| `admin:multisite.pages:update` plus typed resource/field ceiling | Update only root pages **3 `/rock-metal`** and **6 `/contact`**, only `body_html` and required `expected_version`; retain published status, shells and all other fields. Refuse existing canonical blocks rather than clearing them | +| No create/delete/publish-status permission | New pages, archived rows, other page IDs and schedule/status/template changes require a separate owner-reviewed grant | +| No identity/config/infrastructure permission | Deny users, factors, setup/bootstrap, authz administration, tenant/domain writes, reload, files/bundles, ingest/release, provider APIs, image/deploy, SQL or live Tina tenant | + +The existing broad page-update capability cannot enforce this field/page ceiling +by itself. A reusable typed grant-policy hook plus the host's exact tenant/page +and request-field adapter must enforce it **before** the CMS handler and again at +its reusable authorization boundary. No arbitrary header/body actor, email, +role, page ID, tenant ID or caller-requested scope expands a grant. + +## Authentication and grant lifecycle + +1. Owner approves the exact principal, tenant/page/field ceilings, read/write + split and finite grant expiry. Proposed grant lifetime: **30 days maximum**, + with explicit owner renewal; disable/revoke at any time. +2. Owner registers the dedicated workload's **public key** after the reviewed + implementation is available. Its private signing key stays in the owner's + OS Keychain or the existing runner's protected secret mechanism. Never put a + personal cookie/private key in chat, repo files, argv, screenshots or routine + logs. Key creation/registration and any runner permission change require the + separate activation approval; none occurs in this source design. +3. Compose the existing ES256 assertion grant with a reusable **registered grant + policy** before token issue. Assert exact client subject/key/issuer/token-endpoint + audience, expiry, `iat`, one-use `jti` and maximum assertion age (60 seconds). + Replay prevention is durable and atomic. Intersect requested scopes with the + current owner-approved grant; copy no unapproved assertion role/capability. + Do not mount the current unrestricted JWT-bearer grant as an agent broker. +4. Issue a separate audience-bound agent access token, **15 minutes maximum**, + carrying stable service subject, grant ID/version, exact issuer and resource + audience, `iat`, `exp`, `jti` and the approved capability ceiling. No refresh + token, human cookie or implicit extension. Re-issue only while the finite + current grant is still active. Store the access token only in the task/runner + process memory; clear it at completion/expiry. +5. Each resource request verifies exact algorithm/key/issuer/audience and required + claims, then checks the **current durable** principal/grant version, expiry, + disabled state, tenant/page/field ceiling and persistent token revocation. + Missing/unavailable grant or revocation storage fails closed. Removing a + grant immediately denies existing tokens, including after restart. Existing + human auth and protected-baseline guards remain separate and unchanged. +6. An owner-facing revoke/expiry/key-rotation view belongs in the reusable auth + admin surface, backed by typed host adapters. Rotation explicitly registers + a new public key and revokes the old grant/key version; no silent new identity. + +This is an assertion-to-short-lived-token path built on existing components, +not a claimed implementation of RFC8693 or a new home-grown identity provider. +The source gaps in the table must be fixed upstream and tested before activation. +If a protected runner already supplies suitable authenticated workload identity, +its provider may replace key custody only after exact issuer/audience/subject +mapping and owner approval; this design assumes no such connector is available. + +## Component ownership and implementation sequence + +| Owner | Concrete source work | +| --- | --- | +| Workflow `auth.m2m` | Typed pre-issue registered grant policy; scope/claim ceiling and subject/key binding for assertion grants; strict verifier requirements; durable replay/revocation error handling. Opt-in managed-grant mode preserves existing general consumer behavior | +| `workflow-plugin-auth` | Reusable service-principal grant-management contracts/UI, trusted principal projection, typed grant/replay/audit adapters and conformance tests; avoid copying setup-code/human enrollment | +| `workflow-plugin-authz` | Explicit capability/resource/field policy contracts; deny missing or unknown policy; no role-to-superadmin fallback | +| `workflow-plugin-cms` | Saved content/actor/version/digest history and bounded protected read API; atomic version checks in matched API/editor; unsaved draft/presence separately specified | +| `gocodealone-multisite` | Durable service-grant/replay/revocation adapters; exact tenant234 preview/page/field mapping; matched released plugin pins; explicit schema/auth-continuity adoption and activation UI. No app-specific token issuer | + +Do not replace existing human user rows or factor tables with an agent. Service +grants do not satisfy first-admin enrollment or reopen bootstrap. Any new schema, +plugin release, shared-app rollout or continuity-baseline transition needs its +own exact reviewed adoption; the existing clean10→11 CMS ledger admission does +not authorize additional history/agent tables. Retain current store identity, +protected human anchor, domains, three live content pointers and Tina live site. + +## Audit and saved-edit evidence + +Record issue/reject/revoke/key-change/access/write decisions with stable service +subject, owner authorizer, grant/version, request ID, token-JTI **digest**, tenant, +page, action, expected/actual version and before/after content digest. Record no +token, assertion, key, personal credential, raw auth snapshot or body in auth logs. +CMS's separately protected revision store may retain saved content snapshots; +its actor comes only from a verified principal adapter and its content/history +write is the same transaction. Refused/conflicting writes create no successful +revision. Deletions/tombstones preserve authorized history. History has an explicit +adoption/start marker; prior actor/content history is unavailable, not invented. + +Each agent edit starts with fresh saved content/version/digest and ends with +saved readback plus actual authenticated frontend verification. Stale versions +fail with a preserved draft; there is no unconditional retry. An unsaved editor +draft remains a distinct object and is never silently promoted or overwritten +because the current published row was unchanged. + +## Acceptance and owner activation gate + +Before offering activation, independently review exact source, run registered +Workflow/plugin conformance and mounted host tests with synthetic principals, +then exercise real adapter behavior in task-owned isolated stores. Required +denials: wrong key/issuer/audience/subject/tenant/page/field, missing/expired +claims/grant, excess scope, repeated assertion, revoked key/grant/token, store +failure, human-cookie fallback, users/platform/bootstrap/reload/live routes and +stale saved version. Prove revocation/history across restart and no source or +human auth data mutation. Match API/editor guard versions; do not deploy mixed +old unconditional writers. Hosted QA occurs only after an explicitly authorized +reviewed staging adoption, with no real mail or live-site publication. + +**Exact owner activation step:** review and approve the above tenant234 read/history +permissions, optional body-only updates for page3/page6 and finite expiry; then +register the dedicated public workload key through the reviewed reusable auth +surface and its secure custody path. That produces a named revocable grant and +short-lived task access, not a copied personal login. Until the implementation, +adoption and approval exist, the current hosted edit blocker remains authentication. +Source/design approval alone creates no access and authorizes no deployment. From 0c872c39bcb1916d3fca6d6c8a128f0967d033fc Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 9 Oct 2026 12:27:18 -0400 Subject: [PATCH 02/10] docs: require separately approved preview view access --- ...10-09-tenant-scoped-agent-access-design.md | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-10-09-tenant-scoped-agent-access-design.md b/docs/plans/2026-10-09-tenant-scoped-agent-access-design.md index 28856c7..46debd0 100644 --- a/docs/plans/2026-10-09-tenant-scoped-agent-access-design.md +++ b/docs/plans/2026-10-09-tenant-scoped-agent-access-design.md @@ -57,6 +57,7 @@ visible owner checkbox for this already requested correction task. | `admin:multisite.pages:read` | Read saved pages/versions/templates in preview tenant **234**, slug `tinalangevin-review`; tenant listing is filtered to this tenant. No domains/platform listing | | Proposed `admin:multisite.pages:history:read` | Read this tenant's saved change records/digests; actor metadata is minimal and authorized. No cross-tenant/auth audit history | | `admin:multisite.pages:update` plus typed resource/field ceiling | Update only root pages **3 `/rock-metal`** and **6 `/contact`**, only `body_html` and required `expected_version`; retain published status, shells and all other fields. Refuse existing canonical blocks rather than clearing them | +| Separately approved proposed `admin:multisite.preview:read` | GET/HEAD only at **`https://tinalangevin-review.preview.gocodealone.tech`**, the six declared page routes and exact declared local CSS/JS/images/robots. Separate audience-bound view token; no admin/API/editor/exchange paths, arbitrary aliases, external-media proxy or write methods | | No create/delete/publish-status permission | New pages, archived rows, other page IDs and schedule/status/template changes require a separate owner-reviewed grant | | No identity/config/infrastructure permission | Deny users, factors, setup/bootstrap, authz administration, tenant/domain writes, reload, files/bundles, ingest/release, provider APIs, image/deploy, SQL or live Tina tenant | @@ -66,6 +67,27 @@ and request-field adapter must enforce it **before** the CMS handler and again a its reusable authorization boundary. No arbitrary header/body actor, email, role, page ID, tenant ID or caller-requested scope expands a grant. +The current preview's reusable CMS `hostpolicy` requires its separate tenant +Basic authentication. A CMS/admin bearer token **does not satisfy that gate**. +The proposed view capability therefore requires a separately owner-approved +extension of that reusable gate: accept only a verified service-principal view +token with exact tenant234, TLS origin, resource audience and current grant; +then permit GET/HEAD to `/`, `/voice-studio`, `/rock-metal`, `/calendar`, `/media`, +`/contact`, `/robots.txt`, and the declared `tina.css`, `tina.js`, `classical.jpg`, +`metal.jpg` asset paths. Deny unknown/alias/editor/admin/API routes, redirects +that forward credentials, every write method and a CMS token's wrong audience. +An invalid bearer is refused, never converted into a Basic bypass. Existing +owner Basic access remains independently protected. Preserve private no-store, +noindex and `Vary: Authorization` on every response; test warmed anonymous and +wrong-password rejection after agent-view requests. The task client attaches +the view token only to that exact origin, stores it only in process memory and +never sends it to clicked Facebook/social destinations. + +Until this view capability is implemented, reviewed, adopted and approved, +frontend verification still needs a separate owner-authorized Basic view +handoff. CMS grant activation alone must report that view-auth blocker and must +not claim authenticated frontend QA. Neither view path is available now. + ## Authentication and grant lifecycle 1. Owner approves the exact principal, tenant/page/field ceilings, read/write @@ -77,6 +99,10 @@ role, page ID, tenant ID or caller-requested scope expands a grant. personal cookie/private key in chat, repo files, argv, screenshots or routine logs. Key creation/registration and any runner permission change require the separate activation approval; none occurs in this source design. + Token-issuer signing-key custody is also an explicit prerequisite: reuse an + existing verified issuer if available, otherwise separately approve a stable + protected issuer key/configuration. Do not reuse/export the human admin HMAC + secret or silently generate an ephemeral issuer key at application startup. 3. Compose the existing ES256 assertion grant with a reusable **registered grant policy** before token issue. Assert exact client subject/key/issuer/token-endpoint audience, expiry, `iat`, one-use `jti` and maximum assertion age (60 seconds). @@ -89,6 +115,9 @@ role, page ID, tenant ID or caller-requested scope expands a grant. token, human cookie or implicit extension. Re-issue only while the finite current grant is still active. Store the access token only in the task/runner process memory; clear it at completion/expiry. + CMS API and preview-view tokens have **different pinned audiences and scopes**; + neither is accepted on the other plane. View permission is a separately + visible owner checkbox, not inferred from page-update permission. 5. Each resource request verifies exact algorithm/key/issuer/audience and required claims, then checks the **current durable** principal/grant version, expiry, disabled state, tenant/page/field ceiling and persistent token revocation. @@ -113,7 +142,7 @@ mapping and owner approval; this design assumes no such connector is available. | Workflow `auth.m2m` | Typed pre-issue registered grant policy; scope/claim ceiling and subject/key binding for assertion grants; strict verifier requirements; durable replay/revocation error handling. Opt-in managed-grant mode preserves existing general consumer behavior | | `workflow-plugin-auth` | Reusable service-principal grant-management contracts/UI, trusted principal projection, typed grant/replay/audit adapters and conformance tests; avoid copying setup-code/human enrollment | | `workflow-plugin-authz` | Explicit capability/resource/field policy contracts; deny missing or unknown policy; no role-to-superadmin fallback | -| `workflow-plugin-cms` | Saved content/actor/version/digest history and bounded protected read API; atomic version checks in matched API/editor; unsaved draft/presence separately specified | +| `workflow-plugin-cms` | Saved content/actor/version/digest history and bounded protected read API; atomic version checks in matched API/editor; separately approved strict tenant/origin/GET-only viewer in reusable `hostpolicy`; unsaved draft/presence separately specified | | `gocodealone-multisite` | Durable service-grant/replay/revocation adapters; exact tenant234 preview/page/field mapping; matched released plugin pins; explicit schema/auth-continuity adoption and activation UI. No app-specific token issuer | Do not replace existing human user rows or factor tables with an agent. Service @@ -140,6 +169,8 @@ saved readback plus actual authenticated frontend verification. Stale versions fail with a preserved draft; there is no unconditional retry. An unsaved editor draft remains a distinct object and is never silently promoted or overwritten because the current published row was unchanged. +If the separately approved preview-view grant/owner view handoff is absent, +report CMS persistence only and retain the frontend-verification blocker. ## Acceptance and owner activation gate @@ -149,13 +180,18 @@ then exercise real adapter behavior in task-owned isolated stores. Required denials: wrong key/issuer/audience/subject/tenant/page/field, missing/expired claims/grant, excess scope, repeated assertion, revoked key/grant/token, store failure, human-cookie fallback, users/platform/bootstrap/reload/live routes and -stale saved version. Prove revocation/history across restart and no source or +stale saved version. Preview-view tests additionally reject wrong audience, +origin/tenant/alias, unlisted paths and write methods, prohibit cross-origin +credential forwarding, and recheck warmed anonymous/password/cache isolation. +Prove revocation/history across restart and no source or human auth data mutation. Match API/editor guard versions; do not deploy mixed old unconditional writers. Hosted QA occurs only after an explicitly authorized reviewed staging adoption, with no real mail or live-site publication. **Exact owner activation step:** review and approve the above tenant234 read/history permissions, optional body-only updates for page3/page6 and finite expiry; then +separately approve the exact-origin GET/HEAD-only preview-view capability if +agent-run frontend QA is desired; then register the dedicated public workload key through the reviewed reusable auth surface and its secure custody path. That produces a named revocable grant and short-lived task access, not a copied personal login. Until the implementation, From 01998cb60393ec07057c2869f17606589af312a2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 9 Oct 2026 13:13:37 -0400 Subject: [PATCH 03/10] Add scoped agent grants and tenant policy --- .github/workflows/ci.yml | 13 + CHANGELOG.md | 3 + README.md | 7 + agentidentity/http.go | 317 +++++++++++ agentidentity/http_test.go | 215 ++++++++ agentidentity/policy.go | 154 ++++++ agentidentity/policy_test.go | 157 ++++++ agentidentity/service.go | 310 +++++++++++ agentidentity/service_test.go | 518 ++++++++++++++++++ agentidentity/types.go | 186 +++++++ agentidentity/ui.go | 43 ++ docs/agentidentity/README.md | 122 +++++ docs/agentidentity/tenants.example.json | 28 + ...10-09-tenant-scoped-agent-access-design.md | 8 + 14 files changed, 2081 insertions(+) create mode 100644 agentidentity/http.go create mode 100644 agentidentity/http_test.go create mode 100644 agentidentity/policy.go create mode 100644 agentidentity/policy_test.go create mode 100644 agentidentity/service.go create mode 100644 agentidentity/service_test.go create mode 100644 agentidentity/types.go create mode 100644 agentidentity/ui.go create mode 100644 docs/agentidentity/README.md create mode 100644 docs/agentidentity/tenants.example.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e20fb54..b185588 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,19 @@ env: jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_DB: agentidentity_test + POSTGRES_USER: agentidentity_test + POSTGRES_PASSWORD: isolated-test-only + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U agentidentity_test -d agentidentity_test" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + AGENTIDENTITY_TEST_DATABASE_URL: postgres://agentidentity_test:isolated-test-only@127.0.0.1:5432/agentidentity_test?sslmode=disable steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 721f138..ea54138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] ### Added +- Opt-in native `agentidentity` service grants, registered RFC7523 exchange, + strict tenant/resource/field authorization, owner approval/revocation UI and + declarative multi-preview configuration; no human-session or issuer-key reuse. - README verified-status banner per workflow#714 (multi-repo QoL sweep). - CONTRIBUTING.md, examples/minimal/config.yaml, and GitHub issue/PR templates. - Admin auth config now exposes a `passkey_auth_enabled` toggle, and auth diff --git a/README.md b/README.md index 65b2ead..9d3922b 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,13 @@ It documents current component gaps and an owner-approved, revocable grant with short-lived access. It is not a deployed capability or permission to reuse a personal admin session. +The opt-in native Go [agentidentity package](docs/agentidentity/README.md) +implements registered grants, assertion exchange, strict service-token +authorization and reusable owner management. Per-preview policies are +declarative; adding a site does not require a code fork. Its durable host +adapters, managed Workflow issuer and matched staging adoption must be reviewed +and explicitly activated before any credentials or grants are created. + `workflow-plugin-auth` is a library of stateless auth primitives; complete auth flows are composed from these steps plus the engine's built-in `auth.*` modules and the provider plugins. Which combination covers which use case: diff --git a/agentidentity/http.go b/agentidentity/http.go new file mode 100644 index 0000000..996ab58 --- /dev/null +++ b/agentidentity/http.go @@ -0,0 +1,317 @@ +package agentidentity + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "io" + "mime" + "net/http" + "net/url" + "strings" + "time" +) + +// ExchangeHandler handles only the exact configured RFC7523 token endpoint. +// There is no client-secret, cookie, human-session or refresh-token fallback. +// The TLS/reverse-proxy gate is host-owned and MUST reject untrusted forwarding +// metadata. Do not mount Workflow's generic OAuth issuer beside this handler. +func (s *Service) ExchangeHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + privateHeaders(w) + endpoint, _ := parseHTTPS(s.options.TokenEndpoint) + if r.URL.Path != endpoint.Path || r.URL.RawPath != "" || r.URL.RawQuery != "" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + content, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || content != "application/x-www-form-urlencoded" || r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" { + httpError(w, ErrDenied) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 20000) + if err = r.ParseForm(); err != nil { + httpError(w, ErrDenied) + return + } + for key, values := range r.PostForm { + if len(values) != 1 || (key != "grant_type" && key != "grant_id" && key != "assertion" && key != "audience" && key != "scope") { + httpError(w, ErrDenied) + return + } + } + if r.PostForm.Get("grant_type") != "urn:ietf:params:oauth:grant-type:jwt-bearer" { + httpError(w, ErrDenied) + return + } + scopes := strings.Fields(r.PostForm.Get("scope")) + if strings.Join(scopes, " ") != r.PostForm.Get("scope") { + httpError(w, ErrDenied) + return + } + response, err := s.Exchange(r.Context(), ExchangeRequest{GrantID: r.PostForm.Get("grant_id"), Assertion: r.PostForm.Get("assertion"), Audience: r.PostForm.Get("audience"), Scopes: scopes}) + if err != nil { + httpError(w, err) + return + } + _ = json.NewEncoder(w).Encode(response) + }) +} + +type ManagementOptions struct { + Path string + OwnerOrigin string + PagePath string + Owners OwnerPolicy + Store GrantManager +} + +type proposal struct { + Subject string `json:"subject"` + AssertionIssuer string `json:"assertion_issuer"` + KeyID string `json:"key_id"` + PublicKeyPEM string `json:"public_key_pem"` + TenantID int64 `json:"tenant_id"` + ExpiresAt time.Time `json:"expires_at"` + APIAudience string `json:"api_audience"` + Scopes []string `json:"scopes"` + UpdatePageIDs []string `json:"update_page_ids"` + Preview *PreviewGrant `json:"preview"` + // The policy adapter must ALSO authorize the exact requested boundary. + Confirmed bool `json:"confirmed"` +} + +// ManagementHandler owns reusable registration/list/revocation behavior. +// Mount only behind existing HUMAN owner authorization and CSRF protection. +// Its typed resolver/policy cannot be replaced with a service principal. Hosts +// are responsible for a durable transaction plus audit/outbox for management +// mutations; no rollback can safely erase an already applied revoke. +func (s *Service) ManagementHandler(o ManagementOptions) (http.Handler, error) { + if o.Owners == nil || o.Store == nil || !managementPath(o.Path) || !httpsOrigin(o.OwnerOrigin) { + return nil, ErrUnavailable + } + if o.PagePath != "" && (!managementPagePath(o.PagePath) || o.PagePath == o.Path) { + return nil, ErrUnavailable + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + privateHeaders(w) + if r.URL.RawPath != "" || r.URL.RawQuery != "" { + http.NotFound(w, r) + return + } + if r.URL.Path != o.Path && r.URL.Path != o.Path+"/revoke" && (o.PagePath == "" || r.URL.Path != o.PagePath) { + http.NotFound(w, r) + return + } + owner, err := o.Owners.ResolveOwner(r) + if err != nil || !identifier(owner.ID) { + httpError(w, ErrDenied) + return + } + if r.URL.Path == o.PagePath { + if r.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + policy, ok := s.options.Policy.(*TenantPolicy) + if !ok { + httpError(w, ErrUnavailable) + return + } + visible := []TenantConfig{} + for _, c := range policy.configs() { + if o.Owners.AuthorizeOwner(r.Context(), owner, "propose", Grant{TenantID: c.TenantID, APIAudience: c.APIAudience}) == nil { + visible = append(visible, c) + } + } + if len(visible) == 0 { + httpError(w, ErrDenied) + return + } + body, err := managementHTML(o, visible) + if err != nil { + httpError(w, ErrUnavailable) + return + } + if err = s.record(r.Context(), Audit{Operation: "grant-page", Decision: "allow", Reason: "authorized", OwnerID: owner.ID}); err != nil { + httpError(w, err) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(body) + return + } + if r.URL.Path == o.Path && r.Method == http.MethodGet { + if err = o.Owners.AuthorizeOwner(r.Context(), owner, "list", Grant{}); err != nil { + httpError(w, ErrDenied) + return + } + grants, err := o.Store.ListGrants(r.Context(), owner) + if err != nil { + httpError(w, ErrUnavailable) + return + } + visible := make([]Grant, 0, len(grants)) + for _, g := range grants { + if o.Owners.AuthorizeOwner(r.Context(), owner, "read", g) == nil && s.options.Policy.AllowGrant(r.Context(), g) == nil { + visible = append(visible, g) + } + } + if err = s.record(r.Context(), Audit{Operation: "grant-list", Decision: "allow", Reason: "authorized", OwnerID: owner.ID}); err != nil { + httpError(w, err) + return + } + _ = json.NewEncoder(w).Encode(visible) + return + } + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + // Existing verified HUMAN cookie sessions remain usable by the owner + // surface. A pinned Origin/Host and fetch-site check block ambient CSRF; + // the resolver must reject service principals, even with a valid token. + origin, _ := parseHTTPS(o.OwnerOrigin) + if r.Header.Get("Origin") != o.OwnerOrigin || r.Host != origin.Host || len(r.Header.Values("Authorization")) > 1 || (r.Header.Get("Sec-Fetch-Site") != "" && r.Header.Get("Sec-Fetch-Site") != "same-origin") { + httpError(w, ErrDenied) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 20000) + if r.URL.Path == o.Path+"/revoke" { + var input struct { + ID string `json:"id"` + ExpectedVersion int64 `json:"expected_version"` + Confirmed bool `json:"confirmed"` + } + if !readJSON(r, &input) || !input.Confirmed || input.ExpectedVersion <= 0 { + httpError(w, ErrDenied) + return + } + g, err := s.options.Store.GetGrant(r.Context(), input.ID) + if err != nil { + httpError(w, ErrUnavailable) + return + } + if !identifier(input.ID) || g.ID != input.ID || g.Version != input.ExpectedVersion { + httpError(w, ErrConflict) + return + } + if err = o.Owners.AuthorizeOwner(r.Context(), owner, "revoke", g); err != nil { + httpError(w, ErrDenied) + return + } + if err = s.record(r.Context(), Audit{Operation: "grant-revoke", Decision: "allow", Reason: "approved", GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: owner.ID, Subject: g.Subject}); err != nil { + httpError(w, err) + return + } + if err = o.Store.RevokeGrant(r.Context(), g.ID, g.Version, owner.ID, s.now()); err != nil { + if err != ErrConflict { + err = ErrUnavailable + } + httpError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + var input proposal + if !readJSON(r, &input) || !input.Confirmed { + httpError(w, ErrDenied) + return + } + block, rest := pem.Decode([]byte(input.PublicKeyPEM)) + if block == nil || block.Type != "PUBLIC KEY" || len(strings.TrimSpace(string(rest))) != 0 { + httpError(w, ErrDenied) + return + } + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + httpError(w, ErrDenied) + return + } + public, ok := asPublicKey(key) + if !ok { + httpError(w, ErrDenied) + return + } + id := make([]byte, 16) + if _, err = rand.Read(id); err != nil { + httpError(w, ErrUnavailable) + return + } + g := Grant{ID: hex.EncodeToString(id), Subject: input.Subject, AssertionIssuer: input.AssertionIssuer, KeyID: input.KeyID, PublicKey: public, Version: 1, TenantID: input.TenantID, ApprovedBy: owner.ID, CreatedAt: s.now(), ExpiresAt: input.ExpiresAt.UTC().Truncate(time.Second), APIAudience: input.APIAudience, Scopes: input.Scopes, UpdatePageIDs: input.UpdatePageIDs, Preview: input.Preview} + if !validGrant(g, s.now()) { + httpError(w, ErrDenied) + return + } + if err = s.options.Policy.AllowGrant(r.Context(), g); err != nil { + httpError(w, ErrDenied) + return + } + if err = o.Owners.AuthorizeOwner(r.Context(), owner, "create", g); err != nil { + httpError(w, ErrDenied) + return + } + if err = s.record(r.Context(), Audit{Operation: "grant-create", Decision: "allow", Reason: "approved", GrantID: g.ID, GrantVersion: 1, TenantID: g.TenantID, OwnerID: owner.ID, Subject: g.Subject}); err != nil { + httpError(w, err) + return + } + if err = o.Store.CreateGrant(r.Context(), g); err != nil { + if err != ErrConflict { + err = ErrUnavailable + } + httpError(w, err) + return + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(g) + }), nil +} + +func privateHeaders(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Vary", "Authorization") + w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive") + w.Header().Set("X-Content-Type-Options", "nosniff") +} +func httpError(w http.ResponseWriter, err error) { + code := http.StatusUnauthorized + if err == ErrUnavailable { + code = http.StatusServiceUnavailable + } + if err == ErrConflict { + code = http.StatusConflict + } + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) +} +func readJSON(r *http.Request, v any) bool { + content, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || content != "application/json" { + return false + } + d := json.NewDecoder(r.Body) + d.DisallowUnknownFields() + return d.Decode(v) == nil && d.Decode(new(any)) == io.EOF +} +func parseHTTPS(raw string) (*url.URL, error) { return url.Parse(raw) } +func managementPath(p string) bool { + return strings.HasPrefix(p, "/api/") && len(p) > 5 && !strings.HasSuffix(p, "/") && !strings.ContainsAny(p, "%?#\\\t\r\n ") && strings.TrimSpace(p) == p && !strings.Contains(p, "//") && !strings.Contains(p, "/.") +} +func managementPagePath(p string) bool { + return strings.HasPrefix(p, "/admin/") && len(p) > 7 && !strings.ContainsAny(p, "%?#\\\t\r\n ") && !strings.HasSuffix(p, "/") && !strings.Contains(p, "//") && !strings.Contains(p, "/.") +} +func asPublicKey(key any) (*ecdsa.PublicKey, bool) { + k, ok := key.(*ecdsa.PublicKey) + return k, ok && publicKey(k) +} diff --git a/agentidentity/http_test.go b/agentidentity/http_test.go new file mode 100644 index 0000000..fd89bc1 --- /dev/null +++ b/agentidentity/http_test.go @@ -0,0 +1,215 @@ +package agentidentity + +import ( + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type ownerFixture struct{ deny bool } + +func (p *ownerFixture) ResolveOwner(r *http.Request) (Owner, error) { + if p.deny || r.Header.Get("Cookie") != "existing-human-session=test-fixture" || r.Header.Get("Authorization") != "" { + return Owner{}, ErrDenied + } + return Owner{ID: "verified-human-owner"}, nil +} +func (p *ownerFixture) AuthorizeOwner(_ context.Context, o Owner, action string, g Grant) error { + if p.deny || o.ID != "verified-human-owner" || (g.TenantID != 0 && g.TenantID != 234) { + return ErrDenied + } + return nil +} +func (s *testStore) CreateGrant(_ context.Context, g Grant) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.grants == nil { + s.grants = map[string]Grant{s.grant.ID: s.grant} + } + if _, ok := s.grants[g.ID]; ok { + return ErrConflict + } + s.grants[g.ID] = g + return nil +} +func (s *testStore) RevokeGrant(_ context.Context, id string, v int64, _ string, _ time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.grants == nil { + s.grants = map[string]Grant{s.grant.ID: s.grant} + } + g, ok := s.grants[id] + if !ok || g.Version != v || g.Revoked { + return ErrConflict + } + g.Revoked = true + g.Version++ + s.grants[id] = g + return nil +} +func (s *testStore) ListGrants(_ context.Context, _ Owner) ([]Grant, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.grants == nil { + return []Grant{s.grant}, nil + } + out := make([]Grant, 0, len(s.grants)) + for _, g := range s.grants { + out = append(out, g) + } + return out, nil +} + +func publicProposal(t *testing.T, f *fixture) map[string]any { + t.Helper() + b, e := x509.MarshalPKIXPublicKey(&f.workload.PublicKey) + if e != nil { + t.Fatal(e) + } + return map[string]any{"subject": "agent-new-preview", "assertion_issuer": "https://workload.example.test", "key_id": "workload-key", "public_key_pem": string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: b})), "tenant_id": 234, "expires_at": f.now.Add(24 * time.Hour).Format(time.RFC3339), "api_audience": testAPI, "scopes": []string{PagesRead, HistoryRead}, "confirmed": true} +} +func manageRequest(p string, body map[string]any) *http.Request { + b, _ := json.Marshal(body) + r := httptest.NewRequest("POST", "https://owner.example.test"+p, strings.NewReader(string(b))) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Origin", "https://owner.example.test") + r.Header.Set("Cookie", "existing-human-session=test-fixture") + r.Header.Set("Sec-Fetch-Site", "same-origin") + return r +} + +func TestOwnerRegistrationAndRevocationUseExistingHumanSession(t *testing.T) { + f := setup(t) + h, e := f.s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", Owners: &ownerFixture{}, Store: f.store}) + if e != nil { + t.Fatal(e) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, manageRequest("/api/v1/admin/auth/agent-grants", publicProposal(t, f))) + if w.Code != 201 { + t.Fatal("public-key grant registration failed", w.Code, w.Body.String()) + } + var g Grant + if e = json.Unmarshal(w.Body.Bytes(), &g); e != nil || g.ID == "" || g.ApprovedBy != "verified-human-owner" || g.Version != 1 || g.Revoked { + t.Fatal("untrusted owner identity persisted", e) + } + if strings.Contains(w.Body.String(), "PUBLIC KEY") || strings.Contains(w.Body.String(), "test-fixture") { + t.Fatal("credential/public-key payload unexpectedly echoed") + } + r := httptest.NewRequest("GET", "https://owner.example.test/api/v1/admin/auth/agent-grants", nil) + r.Header.Set("Cookie", "existing-human-session=test-fixture") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 || !strings.Contains(w.Body.String(), g.ID) { + t.Fatal("owner grant inventory absent") + } + f.store.grant = g + out, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: g.ID, Assertion: f.assertion(t, "new-grant-jti", nil), Audience: testAPI, Scopes: []string{PagesRead}}) + if e != nil { + t.Fatal(e) + } + w = httptest.NewRecorder() + h.ServeHTTP(w, manageRequest("/api/v1/admin/auth/agent-grants/revoke", map[string]any{"id": g.ID, "expected_version": g.Version, "confirmed": true})) + if w.Code != 204 { + t.Fatal("owner revoke refused", w.Code) + } + if _, e = f.s.Authorize(context.Background(), out.AccessToken, apiRead()); e != ErrDenied { + t.Fatal("revoked token retained authority", e) + } + w = httptest.NewRecorder() + h.ServeHTTP(w, manageRequest("/api/v1/admin/auth/agent-grants/revoke", map[string]any{"id": g.ID, "expected_version": g.Version, "confirmed": true})) + if w.Code != 409 { + t.Fatal("stale revoke did not conflict", w.Code) + } +} + +func TestOwnerGrantMutationDenialsAndNoPersonalCredentialFallback(t *testing.T) { + for _, kind := range []string{"agent", "no-owner", "wrong-origin", "missing-origin", "wrong-host", "cross-site", "not-confirmed", "role", "live-tenant", "grant-overlong", "privileged-scope", "private-key", "unapproved-page", "audit-down"} { + t.Run(kind, func(t *testing.T) { + f := setup(t) + h, e := f.s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", Owners: &ownerFixture{}, Store: f.store}) + if e != nil { + t.Fatal(e) + } + body := publicProposal(t, f) + switch kind { + case "not-confirmed": + body["confirmed"] = false + case "role": + body["role"] = "super_admin" + case "live-tenant": + body["tenant_id"] = 999 + case "grant-overlong": + body["expires_at"] = f.now.Add(MaxGrantLifetime + time.Second).Format(time.RFC3339) + case "privileged-scope": + body["scopes"] = []string{"admin:auth.users:update"} + case "private-key": + b, _ := x509.MarshalECPrivateKey(f.workload) + body["public_key_pem"] = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: b})) + case "unapproved-page": + body["scopes"] = []string{PagesUpdate} + body["update_page_ids"] = []string{"999"} + case "audit-down": + f.audit.fail = true + } + r := manageRequest("/api/v1/admin/auth/agent-grants", body) + switch kind { + case "agent": + r.Header.Del("Cookie") + r.Header.Set("Authorization", "Bearer synthetic-agent-token") + case "no-owner": + r.Header.Del("Cookie") + case "wrong-origin": + r.Header.Set("Origin", "https://evil.example.test") + case "missing-origin": + r.Header.Del("Origin") + case "wrong-host": + r.Host = "alias.example.test" + case "cross-site": + r.Header.Set("Sec-Fetch-Site", "cross-site") + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code < 400 || f.store.grants != nil || strings.Contains(w.Body.String(), "PRIVATE KEY") || strings.Contains(w.Body.String(), "test-fixture") { + t.Fatal("unapproved grant/secret accepted", w.Code) + } + }) + } +} + +func TestOwnerPageUsesConfiguredTenantsAndExistingSession(t *testing.T) { + f := setup(t) + p, e := NewTenantPolicy(tenantConfigs()) + if e != nil { + t.Fatal(e) + } + o := f.s.options + o.Policy = p + s, e := NewService(o) + if e != nil { + t.Fatal(e) + } + h, e := s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", PagePath: "/admin/agent-access", Owners: &ownerFixture{}, Store: f.store}) + if e != nil { + t.Fatal(e) + } + r := httptest.NewRequest("GET", "https://owner.example.test/admin/agent-access", nil) + r.Header.Set("Cookie", "existing-human-session=test-fixture") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 || !strings.Contains(w.Body.String(), "data-agent-grant-admin") || !strings.Contains(w.Body.String(), testAPI) || strings.Contains(w.Body.String(), "other-preview") || strings.Contains(w.Body.String(), "localStorage") || strings.Contains(w.Body.String(), "sessionStorage") { + t.Fatal("owner page leaked/unusable config or borrowed credentials") + } + r.Header.Del("Cookie") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 401 { + t.Fatal("anonymous owner page admitted") + } +} diff --git a/agentidentity/policy.go b/agentidentity/policy.go new file mode 100644 index 0000000..c710a04 --- /dev/null +++ b/agentidentity/policy.go @@ -0,0 +1,154 @@ +package agentidentity + +import ( + "context" + "fmt" + "path" + "slices" + "sort" + "strings" +) + +// TenantConfig is declarative per-preview configuration, independent of keys +// and grants. Adding a tenant requires a configuration entry and owner-approved +// grant, not a source fork, implicit enrollment or shared personal credential. +type TenantConfig struct { + TenantID int64 `json:"tenant_id"` + APIAudience string `json:"api_audience"` + PagesPath string `json:"pages_path"` + HistoryPath string `json:"history_path"` + Scopes []string `json:"scopes"` + UpdatePageIDs []string `json:"update_page_ids"` + Preview *PreviewGrant `json:"preview,omitempty"` +} + +// TenantPolicy is an immutable allowlist of explicit preview tenants. Never +// include a live tenant in this configuration. It matches real host routes, +// field/page ceilings and protected preview origins; it confers no credential. +type TenantPolicy struct{ tenants map[int64]TenantConfig } + +func (p *TenantPolicy) configs() []TenantConfig { + out := make([]TenantConfig, 0, len(p.tenants)) + for _, c := range p.tenants { + c.Scopes = slices.Clone(c.Scopes) + c.UpdatePageIDs = slices.Clone(c.UpdatePageIDs) + if c.Preview != nil { + copy := *c.Preview + copy.Paths = slices.Clone(copy.Paths) + c.Preview = © + } + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].TenantID < out[j].TenantID }) + return out +} + +func NewTenantPolicy(config []TenantConfig) (*TenantPolicy, error) { + if len(config) == 0 { + return nil, ErrUnavailable + } + p := &TenantPolicy{tenants: map[int64]TenantConfig{}} + for _, c := range config { + if c.TenantID <= 0 || p.tenants[c.TenantID].TenantID != 0 || !httpsURL(c.APIAudience) || !managementPath(c.PagesPath) || path.Clean(c.PagesPath) != c.PagesPath || c.HistoryPath != c.PagesPath+"/history" || !canonicalScopes(c.Scopes) { + return nil, ErrUnavailable + } + if slices.Contains(c.Scopes, PagesUpdate) && len(c.UpdatePageIDs) == 0 { + return nil, ErrUnavailable + } + seen := map[string]bool{} + for _, id := range c.UpdatePageIDs { + if !pageIdentifier(id) || seen[id] { + return nil, ErrUnavailable + } + seen[id] = true + } + if slices.Contains(c.Scopes, PreviewRead) { + if c.Preview == nil || !httpsOrigin(c.Preview.Origin) || !httpsURL(c.Preview.Audience) || c.Preview.Audience == c.APIAudience || len(c.Preview.Paths) == 0 { + return nil, ErrUnavailable + } + seen = map[string]bool{} + for _, route := range c.Preview.Paths { + if !previewPath(route) || seen[route] { + return nil, ErrUnavailable + } + seen[route] = true + } + } else if c.Preview != nil { + return nil, ErrUnavailable + } + c.Scopes = slices.Clone(c.Scopes) + c.UpdatePageIDs = slices.Clone(c.UpdatePageIDs) + if c.Preview != nil { + copy := *c.Preview + copy.Paths = slices.Clone(copy.Paths) + c.Preview = © + } + p.tenants[c.TenantID] = c + } + return p, nil +} + +func (p *TenantPolicy) AllowGrant(_ context.Context, g Grant) error { + c, ok := p.tenants[g.TenantID] + if !ok || g.APIAudience != c.APIAudience { + return ErrDenied + } + for _, scope := range g.Scopes { + if !slices.Contains(c.Scopes, scope) { + return ErrDenied + } + } + for _, id := range g.UpdatePageIDs { + if !slices.Contains(c.UpdatePageIDs, id) { + return ErrDenied + } + } + if g.Preview != nil { + if c.Preview == nil || g.Preview.Audience != c.Preview.Audience || g.Preview.Origin != c.Preview.Origin { + return ErrDenied + } + for _, route := range g.Preview.Paths { + if !slices.Contains(c.Preview.Paths, route) { + return ErrDenied + } + } + } + return nil +} + +func (p *TenantPolicy) AllowResource(ctx context.Context, g Grant, r Resource) error { + if p.AllowGrant(ctx, g) != nil || r.TenantID != g.TenantID { + return ErrDenied + } + c := p.tenants[g.TenantID] + if r.Scope == PreviewRead { + if c.Preview == nil || r.Origin != c.Preview.Origin || !slices.Contains(c.Preview.Paths, r.Path) { + return ErrDenied + } + return nil + } + // Resource paths must come from canonical host route resolution. Reject + // encoded aliases, trailing slashes, query strings and accidental auth/admin + // routes even if a host misclassifies one as a page-read permission. + if strings.ContainsAny(r.Path, "%?#\\") || path.Clean(r.Path) != r.Path { + return ErrDenied + } + switch r.Scope { + case HistoryRead: + if r.Path == c.HistoryPath && r.PageID == "" { + return nil + } + case PagesRead: + if r.Path == c.PagesPath && r.PageID == "" { + return nil + } + if pageIdentifier(r.PageID) && r.Path == fmt.Sprintf("%s/%s", c.PagesPath, r.PageID) { + return nil + } + case PagesUpdate: + if slices.Contains(c.UpdatePageIDs, r.PageID) && r.Path == c.PagesPath+"/"+r.PageID { + return nil + } + } + return ErrDenied +} diff --git a/agentidentity/policy_test.go b/agentidentity/policy_test.go new file mode 100644 index 0000000..4339e99 --- /dev/null +++ b/agentidentity/policy_test.go @@ -0,0 +1,157 @@ +package agentidentity + +import ( + "context" + "testing" +) + +func tenantConfigs() []TenantConfig { + return []TenantConfig{ + {TenantID: 234, APIAudience: testAPI, PagesPath: "/api/v1/admin/tenants/234/pages", HistoryPath: "/api/v1/admin/tenants/234/pages/history", Scopes: []string{PagesRead, HistoryRead, PagesUpdate, PreviewRead}, UpdatePageIDs: []string{"3", "6"}, Preview: &PreviewGrant{Audience: testView, Origin: "https://preview.example.test", Paths: []string{"/", "/rock-metal", "/contact", "/assets/tina.css"}}}, + {TenantID: 345, APIAudience: "https://other-preview.example.test/api", PagesPath: "/api/v1/admin/tenants/345/pages", HistoryPath: "/api/v1/admin/tenants/345/pages/history", Scopes: []string{PagesRead, HistoryRead, PagesUpdate, PreviewRead}, UpdatePageIDs: []string{"17", "18"}, Preview: &PreviewGrant{Audience: "https://other-preview.example.test/view", Origin: "https://other-preview.example.test", Paths: []string{"/", "/about", "/assets/site.css"}}}, + } +} + +func TestDeclarativeTwoTenantAccessAndIsolation(t *testing.T) { + f := setup(t) + config := tenantConfigs() + p, e := NewTenantPolicy(config) + if e != nil { + t.Fatal(e) + } + a := f.store.grant + b := a + b.ID = "grant-2" + b.Subject = "agent-other-preview" + b.TenantID = 345 + b.APIAudience = config[1].APIAudience + b.UpdatePageIDs = config[1].UpdatePageIDs + b.Preview = config[1].Preview + f.store.grants = map[string]Grant{a.ID: a, b.ID: b} + o := f.s.options + o.Policy = p + service, e := NewService(o) + if e != nil { + t.Fatal(e) + } + for _, g := range []Grant{a, b} { + f.store.grant = g // assertion fixture selects the grant's verified subject. + out, e := service.Exchange(context.Background(), ExchangeRequest{GrantID: g.ID, Assertion: f.assertion(t, g.ID+"-assertion", nil), Audience: g.APIAudience, Scopes: []string{PagesRead, PagesUpdate}}) + if e != nil { + t.Fatal("configured tenant exchange", e) + } + c := config[0] + other := config[1] + if g.TenantID == 345 { + c, other = other, c + } + read := Resource{TenantID: c.TenantID, Audience: c.APIAudience, Scope: PagesRead, Method: "GET", Path: c.PagesPath} + write := Resource{TenantID: c.TenantID, Audience: c.APIAudience, Scope: PagesUpdate, Method: "PUT", Path: c.PagesPath + "/" + c.UpdatePageIDs[0], PageID: c.UpdatePageIDs[0], RootPage: true, Fields: []string{"body_html"}, ExpectedVersion: 1} + for _, r := range []Resource{read, write} { + if _, e := service.Authorize(context.Background(), out.AccessToken, r); e != nil { + t.Fatal("configured resource denied", e) + } + } + wrong := read + wrong.TenantID = other.TenantID + wrong.Path = other.PagesPath + wrong.Audience = other.APIAudience + if _, e := service.Authorize(context.Background(), out.AccessToken, wrong); e == nil { + t.Fatal("cross-tenant token accepted") + } + wrong = write + wrong.PageID = other.UpdatePageIDs[0] + wrong.Path = c.PagesPath + "/" + wrong.PageID + if _, e := service.Authorize(context.Background(), out.AccessToken, wrong); e == nil { + t.Fatal("other tenant page ceiling accepted") + } + view, e := service.Exchange(context.Background(), ExchangeRequest{GrantID: g.ID, Assertion: f.assertion(t, g.ID+"-view", nil), Audience: g.Preview.Audience, Scopes: []string{PreviewRead}}) + if e != nil { + t.Fatal(e) + } + r := Resource{TenantID: c.TenantID, Audience: c.Preview.Audience, Scope: PreviewRead, Method: "HEAD", Origin: c.Preview.Origin, Path: "/"} + if _, e := service.Authorize(context.Background(), view.AccessToken, r); e != nil { + t.Fatal(e) + } + r.Origin = other.Preview.Origin + if _, e := service.Authorize(context.Background(), view.AccessToken, r); e == nil { + t.Fatal("alternate tenant origin accepted") + } + } + // A third/live tenant omitted from configuration is denied, even if a store + // mistakenly supplies an apparently valid signed/approved grant for it. + live := a + live.TenantID = 999 + if p.AllowGrant(context.Background(), live) != ErrDenied { + t.Fatal("unconfigured tenant admitted") + } + // The copied policy remains immutable when a caller edits source config. + config[0].UpdatePageIDs[0] = "999" + config[0].Preview.Paths[0] = "/admin" + if p.AllowGrant(context.Background(), a) != nil { + t.Fatal("source config alias mutated policy") + } +} + +func TestDeclaredRouteClassificationIsExact(t *testing.T) { + f := setup(t) + p, e := NewTenantPolicy(tenantConfigs()) + if e != nil { + t.Fatal(e) + } + for _, route := range []string{"/api/v1/admin/auth/users", "/api/v1/admin/tenants/234/pages/history", "/api/v1/admin/tenants/345/pages", "/api/v1/admin/tenants/234/pages/", "/api/v1/admin/tenants/234/%70ages", "/api/v1/admin/tenants/234/pages?tenant=345"} { + r := apiRead() + r.Path = route + if p.AllowResource(context.Background(), f.store.grant, r) != ErrDenied { + t.Fatal("misclassified route admitted", route) + } + } + r := apiRead() + r.PageID = "history" + r.Path += "/history" + if p.AllowResource(context.Background(), f.store.grant, r) != ErrDenied { + t.Fatal("page read mislabeled history scope") + } + for _, mutate := range []func([]TenantConfig){func(c []TenantConfig) { c[1].TenantID = c[0].TenantID }, func(c []TenantConfig) { c[0].Preview.Paths = append(c[0].Preview.Paths, "/admin") }, func(c []TenantConfig) { c[0].Scopes = append(c[0].Scopes, "cms.admin") }, func(c []TenantConfig) { c[0].UpdatePageIDs = nil }} { + config := tenantConfigs() + mutate(config) + if _, e := NewTenantPolicy(config); e != ErrUnavailable { + t.Fatal("invalid declaration accepted") + } + } +} + +func TestAssertionOneUseAcrossTenantsAndVersionChanges(t *testing.T) { + f := setup(t) + config := tenantConfigs() + p, e := NewTenantPolicy(config) + if e != nil { + t.Fatal(e) + } + a := f.store.grant + b := a + b.ID = "grant-2" + b.TenantID = 345 + b.APIAudience = config[1].APIAudience + b.UpdatePageIDs = config[1].UpdatePageIDs + b.Preview = config[1].Preview + f.store.grants = map[string]Grant{a.ID: a, b.ID: b} + o := f.s.options + o.Policy = p + s, e := NewService(o) + if e != nil { + t.Fatal(e) + } + raw := f.assertion(t, "global-one-use", nil) + if _, e = s.Exchange(context.Background(), ExchangeRequest{GrantID: a.ID, Assertion: raw, Audience: a.APIAudience, Scopes: []string{PagesRead}}); e != nil { + t.Fatal(e) + } + if _, e = s.Exchange(context.Background(), ExchangeRequest{GrantID: b.ID, Assertion: raw, Audience: b.APIAudience, Scopes: []string{PagesRead}}); e != ErrDenied { + t.Fatal("same assertion reused for another tenant", e) + } + a.Version++ + f.store.grants[a.ID] = a + if _, e = s.Exchange(context.Background(), ExchangeRequest{GrantID: a.ID, Assertion: raw, Audience: a.APIAudience, Scopes: []string{PagesRead}}); e != ErrDenied { + t.Fatal("same assertion reused after version change", e) + } +} diff --git a/agentidentity/service.go b/agentidentity/service.go new file mode 100644 index 0000000..ac133d9 --- /dev/null +++ b/agentidentity/service.go @@ -0,0 +1,310 @@ +package agentidentity + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "encoding/hex" + "math/big" + "net/url" + "path" + "slices" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Service struct{ options Options } + +type accessClaims struct { + jwt.RegisteredClaims + TokenUse string `json:"token_use"` + GrantID string `json:"grant_id"` + GrantVersion int64 `json:"grant_version"` + TenantID int64 `json:"tenant_id"` + Scope string `json:"scope"` +} + +func NewService(o Options) (*Service, error) { + if !httpsURL(o.IssuerURL) || !httpsURL(o.TokenEndpoint) || o.Store == nil || o.Issuer == nil || o.Policy == nil || o.Audit == nil || len(o.IssuerKeys) == 0 { + return nil, ErrUnavailable + } + keys := make(map[string]*ecdsa.PublicKey, len(o.IssuerKeys)) + for id, k := range o.IssuerKeys { + if !identifier(id) || !publicKey(k) { + return nil, ErrUnavailable + } + // Copy the curve coordinates so external configuration mutation cannot + // silently replace a trusted key after construction. + keys[id] = &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).Set(k.X), Y: new(big.Int).Set(k.Y)} + } + o.IssuerKeys = keys + if o.Clock == nil { + o.Clock = time.Now + } + return &Service{options: o}, nil +} + +func (s *Service) now() time.Time { return s.options.Clock().UTC().Truncate(time.Second) } + +func (s *Service) current(ctx context.Context, id string) (Grant, error) { + if !identifier(id) { + return Grant{}, ErrDenied + } + g, err := s.options.Store.GetGrant(ctx, id) + if err != nil { + return Grant{}, ErrUnavailable + } + if g.ID != id || !validGrant(g, s.now()) { + return Grant{}, ErrDenied + } + if err = s.options.Policy.AllowGrant(ctx, g); err != nil { + return Grant{}, ErrDenied + } + return g, nil +} + +func (s *Service) Exchange(ctx context.Context, in ExchangeRequest) (TokenResponse, error) { + g, err := s.current(ctx, in.GrantID) + if err != nil { + return TokenResponse{}, s.reject(ctx, "exchange", Grant{}, err) + } + if len(in.Assertion) > 16384 || in.Assertion == "" || !audienceScopes(g, in.Audience, in.Scopes) { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrDenied) + } + now := s.now() + c := new(jwt.RegisteredClaims) + t, err := jwt.ParseWithClaims(in.Assertion, c, func(t *jwt.Token) (any, error) { + if t.Header["kid"] != g.KeyID { + return nil, ErrDenied + } + return g.PublicKey, nil + }, jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(g.AssertionIssuer), jwt.WithSubject(g.Subject), jwt.WithAudience(s.options.TokenEndpoint), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithTimeFunc(func() time.Time { return now })) + if err != nil || t == nil || !t.Valid || !exactAudience(c.Audience, s.options.TokenEndpoint) || c.IssuedAt == nil || c.ExpiresAt == nil || !identifier(c.ID) || !shortTimes(c.IssuedAt, c.ExpiresAt, now, MaxAssertionLifetime) { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrDenied) + } + used, err := s.options.Store.ConsumeAssertion(ctx, AssertionUse{GrantID: g.ID, GrantVersion: g.Version, JTIHash: digest(c.Issuer + "\x00" + c.Subject + "\x00" + g.KeyID + "\x00" + c.ID), ExpiresAt: c.ExpiresAt.Time, Now: now}) + if err != nil { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrUnavailable) + } + if !used { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrDenied) + } + exp := now.Add(MaxTokenLifetime) + if g.ExpiresAt.Before(exp) { + exp = g.ExpiresAt.UTC().Truncate(time.Second) + } + if !exp.After(now) { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrDenied) + } + request := IssueRequest{Subject: g.Subject, Audience: in.Audience, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, Scopes: slices.Clone(in.Scopes), IssuedAt: now, ExpiresAt: exp} + raw, err := s.options.Issuer.IssueAgentToken(ctx, request) + if err != nil { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrUnavailable) + } + // Verify the issuer adapter's complete output before disclosing it. This + // catches accidental human/legacy issuer wiring, audience/scope widening, + // overlong expiry and grant mutation during issuance. + issued, err := s.parseAccess(raw, in.Audience, now) + if err != nil || issued.Subject != g.Subject || issued.GrantID != g.ID || issued.GrantVersion != g.Version || issued.TenantID != g.TenantID || issued.IssuedAt.Time.Unix() != now.Unix() || issued.ExpiresAt.Time.Unix() != exp.Unix() || !sameScopes(strings.Fields(issued.Scope), in.Scopes) { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrUnavailable) + } + current, err := s.current(ctx, g.ID) + if err != nil || current.Version != g.Version || !audienceScopes(current, in.Audience, in.Scopes) { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrDenied) + } + revoked, err := s.options.Store.IsTokenRevoked(ctx, digest(issued.ID)) + if err != nil { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrUnavailable) + } + if revoked { + return TokenResponse{}, s.reject(ctx, "exchange", g, ErrDenied) + } + if err = s.record(ctx, Audit{Operation: "exchange", Decision: "allow", Reason: "issued", Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: g.ApprovedBy, JTIHash: digest(issued.ID)}); err != nil { + return TokenResponse{}, err + } + return TokenResponse{AccessToken: raw, TokenType: "Bearer", ExpiresIn: int64(exp.Sub(now).Seconds()), Scope: strings.Join(in.Scopes, " ")}, nil +} + +func (s *Service) Authorize(ctx context.Context, raw string, r Resource) (Principal, error) { + now := s.now() + c, err := s.parseAccess(raw, r.Audience, now) + if err != nil { + return Principal{}, s.reject(ctx, "access", Grant{}, ErrDenied) + } + g, err := s.current(ctx, c.GrantID) + if err != nil { + return Principal{}, s.reject(ctx, "access", Grant{}, err) + } + if c.Subject != g.Subject || c.GrantVersion != g.Version || c.TenantID != g.TenantID || r.TenantID != g.TenantID || c.ExpiresAt.Time.After(g.ExpiresAt) || !audienceScopes(g, r.Audience, strings.Fields(c.Scope)) || !slices.Contains(strings.Fields(c.Scope), r.Scope) || !resourceAllowed(g, r) { + return Principal{}, s.reject(ctx, "access", g, ErrDenied) + } + revoked, err := s.options.Store.IsTokenRevoked(ctx, digest(c.ID)) + if err != nil { + return Principal{}, s.reject(ctx, "access", g, ErrUnavailable) + } + if revoked { + return Principal{}, s.reject(ctx, "access", g, ErrDenied) + } + if err = s.options.Policy.AllowResource(ctx, g, r); err != nil { + return Principal{}, s.reject(ctx, "access", g, ErrDenied) + } + if err = s.record(ctx, Audit{Operation: "access", Decision: "allow", Reason: "authorized", Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: g.ApprovedBy, PageID: r.PageID, Scope: r.Scope, JTIHash: digest(c.ID)}); err != nil { + return Principal{}, err + } + return Principal{Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, Scopes: strings.Fields(c.Scope), JTIHash: digest(c.ID)}, nil +} + +func (s *Service) parseAccess(raw, audience string, now time.Time) (*accessClaims, error) { + if len(raw) == 0 || len(raw) > 16384 || !httpsURL(audience) { + return nil, ErrDenied + } + c := new(accessClaims) + t, err := jwt.ParseWithClaims(raw, c, func(t *jwt.Token) (any, error) { + kid, ok := t.Header["kid"].(string) + if !ok || s.options.IssuerKeys[kid] == nil { + return nil, ErrDenied + } + return s.options.IssuerKeys[kid], nil + }, jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(s.options.IssuerURL), jwt.WithAudience(audience), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithTimeFunc(func() time.Time { return now })) + if err != nil || t == nil || !t.Valid || c.TokenUse != "agent_access" || !identifier(c.Subject) || !identifier(c.ID) || !identifier(c.GrantID) || c.GrantVersion <= 0 || c.TenantID <= 0 || !exactAudience(c.Audience, audience) || !shortTimes(c.IssuedAt, c.ExpiresAt, now, MaxTokenLifetime) || !canonicalScopes(strings.Fields(c.Scope)) || strings.Join(strings.Fields(c.Scope), " ") != c.Scope { + return nil, ErrDenied + } + return c, nil +} + +func (s *Service) record(ctx context.Context, e Audit) error { + e.At = s.now() + if err := s.options.Audit.Record(ctx, e); err != nil { + return ErrUnavailable + } + return nil +} +func (s *Service) reject(ctx context.Context, operation string, g Grant, err error) error { + reason := "denied" + if err == ErrUnavailable { + reason = "unavailable" + } + if e := s.record(ctx, Audit{Operation: operation, Decision: "deny", Reason: reason, Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: g.ApprovedBy}); e != nil { + return e + } + return err +} + +func validGrant(g Grant, now time.Time) bool { + if !identifier(g.ID) || !identifier(g.Subject) || !identifier(g.KeyID) || !identifier(g.ApprovedBy) || !httpsURL(g.AssertionIssuer) || !httpsURL(g.APIAudience) || !publicKey(g.PublicKey) || g.Version <= 0 || g.TenantID <= 0 || g.Revoked || g.CreatedAt.IsZero() || g.CreatedAt.After(now) || !g.ExpiresAt.After(now) || g.ExpiresAt.Sub(g.CreatedAt) > MaxGrantLifetime || !canonicalScopes(g.Scopes) { + return false + } + if slices.Contains(g.Scopes, PagesUpdate) && len(g.UpdatePageIDs) == 0 { + return false + } + seen := map[string]bool{} + for _, id := range g.UpdatePageIDs { + if !pageIdentifier(id) || seen[id] { + return false + } + seen[id] = true + } + if slices.Contains(g.Scopes, PreviewRead) { + if g.Preview == nil || !httpsOrigin(g.Preview.Origin) || !httpsURL(g.Preview.Audience) || g.Preview.Audience == g.APIAudience || len(g.Preview.Paths) == 0 { + return false + } + seen = map[string]bool{} + for _, p := range g.Preview.Paths { + if !previewPath(p) || seen[p] { + return false + } + seen[p] = true + } + } else if g.Preview != nil { + return false + } + return true +} + +func audienceScopes(g Grant, a string, scopes []string) bool { + if !canonicalScopes(scopes) { + return false + } + for _, scope := range scopes { + if !slices.Contains(g.Scopes, scope) { + return false + } + if scope == PreviewRead { + if g.Preview == nil || a != g.Preview.Audience { + return false + } + } else if a != g.APIAudience { + return false + } + } + return true +} + +func resourceAllowed(g Grant, r Resource) bool { + switch r.Scope { + case PagesRead, HistoryRead: + return (r.Method == "GET" || r.Method == "HEAD") && len(r.Fields) == 0 && r.Audience == g.APIAudience && (r.PageID == "" || pageIdentifier(r.PageID)) + case PagesUpdate: + return r.Method == "PUT" && r.RootPage && !r.HasCanonicalBlocks && r.ExpectedVersion > 0 && len(r.Fields) == 1 && r.Fields[0] == "body_html" && slices.Contains(g.UpdatePageIDs, r.PageID) && r.Audience == g.APIAudience + case PreviewRead: + return g.Preview != nil && r.Audience == g.Preview.Audience && r.Origin == g.Preview.Origin && (r.Method == "GET" || r.Method == "HEAD") && len(r.Fields) == 0 && previewPath(r.Path) && slices.Contains(g.Preview.Paths, r.Path) + } + return false +} + +func httpsURL(raw string) bool { + u, err := url.Parse(raw) + return len(raw) > 0 && len(raw) <= 2048 && err == nil && u.Scheme == "https" && u.Hostname() != "" && u.User == nil && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.Opaque == "" && u.RawPath == "" && (u.Path == "" || path.Clean(u.Path) == u.Path) && u.Host == strings.ToLower(u.Host) && u.String() == raw && !strings.ContainsAny(raw, "* \t\n\r\\") +} +func httpsOrigin(raw string) bool { + u, e := url.Parse(raw) + return e == nil && httpsURL(raw) && u.Path == "" +} +func identifier(s string) bool { + if len(s) == 0 || len(s) > 128 { + return false + } + for _, r := range s { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune("-_.:", r)) { + return false + } + } + return true +} +func pageIdentifier(s string) bool { + id, err := strconv.ParseUint(s, 10, 64) + return err == nil && id > 0 && strconv.FormatUint(id, 10) == s +} +func previewPath(p string) bool { + if p == "" || !strings.HasPrefix(p, "/") || path.Clean(p) != p || strings.ContainsAny(p, "%?#\\\t\r\n ") { + return false + } + first := strings.ToLower(strings.Split(strings.TrimPrefix(p, "/"), "/")[0]) + return first != "admin" && first != "api" && first != "editor" && first != "oauth" && first != ".well-known" +} +func canonicalScopes(scopes []string) bool { + if len(scopes) == 0 || len(scopes) > 4 { + return false + } + seen := map[string]bool{} + for _, s := range scopes { + if seen[s] || (s != PagesRead && s != HistoryRead && s != PagesUpdate && s != PreviewRead) { + return false + } + seen[s] = true + } + return true +} +func sameScopes(a, b []string) bool { return len(a) == len(b) && slices.Equal(a, b) } +func exactAudience(a jwt.ClaimStrings, want string) bool { return len(a) == 1 && a[0] == want } +func shortTimes(i, e *jwt.NumericDate, now time.Time, max time.Duration) bool { + return i != nil && e != nil && !i.Time.After(now) && e.Time.After(now) && e.Time.After(i.Time) && e.Time.Sub(i.Time) <= max && now.Sub(i.Time) <= max +} +func publicKey(k *ecdsa.PublicKey) bool { + return k != nil && k.Curve == elliptic.P256() && k.X != nil && k.Y != nil && k.Curve.IsOnCurve(k.X, k.Y) +} +func digest(raw string) string { b := sha256.Sum256([]byte(raw)); return hex.EncodeToString(b[:]) } diff --git a/agentidentity/service_test.go b/agentidentity/service_test.go new file mode 100644 index 0000000..4ec47cd --- /dev/null +++ b/agentidentity/service_test.go @@ -0,0 +1,518 @@ +package agentidentity + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const testIssuer = "https://issuer.example.test" +const testEndpoint = "https://issuer.example.test/oauth/agent/token" +const testAPI = "https://preview.example.test/api" +const testView = "https://preview.example.test/view" + +type testStore struct { + mu sync.Mutex + grant Grant + grants map[string]Grant + uses map[string]bool + revoked map[string]bool + failRead, failReplay, failRevocation bool +} + +func (s *testStore) GetGrant(_ context.Context, id string) (Grant, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.failRead { + return Grant{}, errors.New("STORE-SECRET-DO-NOT-LOG") + } + if s.grants != nil { + g, ok := s.grants[id] + if !ok { + return Grant{}, ErrDenied + } + return g, nil + } + if id != s.grant.ID { + return Grant{}, ErrDenied + } + return s.grant, nil +} +func (s *testStore) ConsumeAssertion(_ context.Context, u AssertionUse) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.failReplay { + return false, errors.New("REPLAY-SECRET-DO-NOT-LOG") + } + g := s.grant + if s.grants != nil { + g = s.grants[u.GrantID] + } + if g.ID != u.GrantID || g.Version != u.GrantVersion || g.Revoked || !g.ExpiresAt.After(u.Now) { + return false, nil + } + key := u.JTIHash + if s.uses[key] { + return false, nil + } + s.uses[key] = true + return true, nil +} +func (s *testStore) IsTokenRevoked(_ context.Context, jti string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.failRevocation { + return false, errors.New("REVOCATION-SECRET-DO-NOT-LOG") + } + return s.revoked[jti], nil +} + +type testAudit struct { + mu sync.Mutex + events []Audit + fail bool +} + +func (a *testAudit) Record(_ context.Context, e Audit) error { + a.mu.Lock() + defer a.mu.Unlock() + if a.fail { + return errors.New("AUDIT-SECRET-DO-NOT-LOG") + } + a.events = append(a.events, e) + return nil +} + +type testPolicy struct{ deny bool } + +func (p *testPolicy) AllowGrant(_ context.Context, g Grant) error { + if p.deny || g.TenantID != 234 || g.APIAudience != testAPI || (g.Preview != nil && (g.Preview.Origin != "https://preview.example.test" || g.Preview.Audience != testView)) { + return ErrDenied + } + for _, id := range g.UpdatePageIDs { + if id != "3" && id != "6" { + return ErrDenied + } + } + return nil +} +func (p *testPolicy) AllowResource(_ context.Context, _ Grant, r Resource) error { + if p.deny || r.TenantID != 234 || strings.Contains(r.Path, "auth") || strings.Contains(r.Path, "platform") || strings.Contains(r.Path, "domains") { + return ErrDenied + } + return nil +} + +type testIssuerAdapter struct { + key *ecdsa.PrivateKey + mutate func(*accessClaims) + fail bool +} + +func (i *testIssuerAdapter) IssueAgentToken(_ context.Context, r IssueRequest) (string, error) { + if i.fail { + return "", errors.New("ISSUER-SECRET-DO-NOT-LOG") + } + c := accessClaims{RegisteredClaims: jwt.RegisteredClaims{Issuer: testIssuer, Subject: r.Subject, Audience: jwt.ClaimStrings{r.Audience}, IssuedAt: jwt.NewNumericDate(r.IssuedAt), ExpiresAt: jwt.NewNumericDate(r.ExpiresAt), ID: "test-issued-jti"}, TokenUse: "agent_access", GrantID: r.GrantID, GrantVersion: r.GrantVersion, TenantID: r.TenantID, Scope: strings.Join(r.Scopes, " ")} + if i.mutate != nil { + i.mutate(&c) + } + return sign(i.key, c, "issuer-key") +} +func sign(k *ecdsa.PrivateKey, c jwt.Claims, kid string) (string, error) { + t := jwt.NewWithClaims(jwt.SigningMethodES256, c) + t.Header["kid"] = kid + return t.SignedString(k) +} + +type fixture struct { + s *Service + store *testStore + audit *testAudit + issuer *testIssuerAdapter + workload *ecdsa.PrivateKey + now time.Time + policy *testPolicy +} + +func setup(t *testing.T) *fixture { + t.Helper() + k, e := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if e != nil { + t.Fatal(e) + } + w, e := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if e != nil { + t.Fatal(e) + } + now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) + g := Grant{ID: "grant-1", Subject: "agent-tina-preview", AssertionIssuer: "https://workload.example.test", KeyID: "workload-key", PublicKey: &w.PublicKey, Version: 1, TenantID: 234, ApprovedBy: "human-owner", CreatedAt: now.Add(-time.Hour), ExpiresAt: now.Add(24 * time.Hour), APIAudience: testAPI, Scopes: []string{PagesRead, HistoryRead, PagesUpdate, PreviewRead}, UpdatePageIDs: []string{"3", "6"}, Preview: &PreviewGrant{Audience: testView, Origin: "https://preview.example.test", Paths: []string{"/", "/rock-metal", "/contact", "/assets/tina.css"}}} + f := &fixture{store: &testStore{grant: g, uses: map[string]bool{}, revoked: map[string]bool{}}, audit: &testAudit{}, issuer: &testIssuerAdapter{key: k}, workload: w, now: now, policy: &testPolicy{}} + s, e := NewService(Options{IssuerURL: testIssuer, TokenEndpoint: testEndpoint, IssuerKeys: map[string]*ecdsa.PublicKey{"issuer-key": &k.PublicKey}, Store: f.store, Issuer: f.issuer, Policy: f.policy, Audit: f.audit, Clock: func() time.Time { return f.now }}) + if e != nil { + t.Fatal(e) + } + f.s = s + return f +} +func (f *fixture) assertion(t *testing.T, id string, mutate func(*jwt.RegisteredClaims)) string { + t.Helper() + c := jwt.RegisteredClaims{Issuer: f.store.grant.AssertionIssuer, Subject: f.store.grant.Subject, Audience: jwt.ClaimStrings{testEndpoint}, IssuedAt: jwt.NewNumericDate(f.now), ExpiresAt: jwt.NewNumericDate(f.now.Add(time.Minute)), ID: id} + if mutate != nil { + mutate(&c) + } + raw, e := sign(f.workload, c, "workload-key") + if e != nil { + t.Fatal(e) + } + return raw +} +func (f *fixture) exchange(t *testing.T, scopes []string, aud string) TokenResponse { + t.Helper() + out, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "assertion-1", nil), Audience: aud, Scopes: scopes}) + if e != nil { + t.Fatal(e) + } + return out +} +func apiRead() Resource { + return Resource{TenantID: 234, Audience: testAPI, Scope: PagesRead, Method: "GET", Path: "/api/v1/admin/tenants/234/pages"} +} +func apiUpdate() Resource { + return Resource{TenantID: 234, Audience: testAPI, Scope: PagesUpdate, PageID: "3", Method: "PUT", Fields: []string{"body_html"}, ExpectedVersion: 3, RootPage: true, Path: "/api/v1/admin/tenants/234/pages/3"} +} + +func TestExchangeAndLeastPrivilegeResource(t *testing.T) { + f := setup(t) + out := f.exchange(t, []string{PagesRead, HistoryRead, PagesUpdate}, testAPI) + if out.TokenType != "Bearer" || out.ExpiresIn != 900 { + t.Fatal("incorrect bounded response") + } + for _, r := range []Resource{apiRead(), apiUpdate(), {TenantID: 234, Audience: testAPI, Scope: HistoryRead, Method: "GET", Path: "/api/v1/admin/tenants/234/pages/history"}} { + p, e := f.s.Authorize(context.Background(), out.AccessToken, r) + if e != nil || p.Subject != "agent-tina-preview" || p.TenantID != 234 || p.JTIHash == "test-issued-jti" { + t.Fatalf("typed principal missing: %v", e) + } + } + var parsed accessClaims + _, _, e := jwt.NewParser().ParseUnverified(out.AccessToken, &parsed) + if e != nil || parsed.TokenUse != "agent_access" { + t.Fatal("missing service token discriminator") + } +} + +func TestAssertionBoundaries(t *testing.T) { + cases := map[string]func(*jwt.RegisteredClaims){ + "issuer": func(c *jwt.RegisteredClaims) { c.Issuer = "https://other.example.test" }, + "subject": func(c *jwt.RegisteredClaims) { c.Subject = "human-owner" }, + "audience": func(c *jwt.RegisteredClaims) { c.Audience = jwt.ClaimStrings{testAPI} }, + "multiple audiences": func(c *jwt.RegisteredClaims) { c.Audience = append(c.Audience, testAPI) }, + "expiry required": func(c *jwt.RegisteredClaims) { c.ExpiresAt = nil }, + "iat required": func(c *jwt.RegisteredClaims) { c.IssuedAt = nil }, + "jti required": func(c *jwt.RegisteredClaims) { c.ID = "" }, + "expired": func(c *jwt.RegisteredClaims) { c.ExpiresAt = c.IssuedAt }, + "future": func(c *jwt.RegisteredClaims) { c.IssuedAt = jwt.NewNumericDate(c.IssuedAt.Add(time.Second)) }, + "overlong": func(c *jwt.RegisteredClaims) { c.ExpiresAt = jwt.NewNumericDate(c.IssuedAt.Add(61 * time.Second)) }, + "stale": func(c *jwt.RegisteredClaims) { c.IssuedAt = jwt.NewNumericDate(c.IssuedAt.Add(-61 * time.Second)) }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + f := setup(t) + _, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "jti", mutate), Audience: testAPI, Scopes: []string{PagesRead}}) + if e != ErrDenied || len(f.store.uses) != 0 { + t.Fatal("invalid assertion consumed/accepted", e) + } + }) + } + for _, kind := range []string{"wrong-key", "wrong-kid", "HS256", "none"} { + t.Run(kind, func(t *testing.T) { + f := setup(t) + raw := f.assertion(t, "jti", nil) + c := jwt.MapClaims{} + _, _, _ = jwt.NewParser().ParseUnverified(raw, c) + switch kind { + case "wrong-key": + raw, _ = sign(f.issuer.key, c, "workload-key") + case "wrong-kid": + raw, _ = sign(f.workload, c, "other-key") + case "HS256": + raw, _ = jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString([]byte("test-only-secret")) + case "none": + raw, _ = jwt.NewWithClaims(jwt.SigningMethodNone, c).SignedString(jwt.UnsafeAllowNoneSignatureType) + } + _, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: raw, Audience: testAPI, Scopes: []string{PagesRead}}) + if e != ErrDenied { + t.Fatal("untrusted assertion accepted", e) + } + }) + } +} + +func TestScopesAndCrossTenant(t *testing.T) { + for _, scopes := range [][]string{nil, {"cms.admin"}, {"admin:auth.users:write"}, {PagesRead, PagesRead}, {PreviewRead, PagesRead}, {"admin:multisite.pages:create"}} { + t.Run(strings.Join(scopes, "-"), func(t *testing.T) { + f := setup(t) + _, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "jti", nil), Audience: testAPI, Scopes: scopes}) + if e != ErrDenied { + t.Fatal("excess/ambiguous scopes accepted") + } + }) + } + for _, mutation := range []func(*Grant){func(g *Grant) { g.TenantID = 235 }, func(g *Grant) { g.Scopes = []string{PagesRead}; g.Preview = nil }, func(g *Grant) { g.UpdatePageIDs = []string{"999"} }, func(g *Grant) { g.ExpiresAt = g.CreatedAt.Add(MaxGrantLifetime + time.Second) }, func(g *Grant) { g.Revoked = true }} { + f := setup(t) + mutation(&f.store.grant) + _, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "jti", nil), Audience: testAPI, Scopes: []string{PagesUpdate}}) + if e != ErrDenied { + t.Fatal("changed grant accepted", e) + } + } + f := setup(t) + out := f.exchange(t, []string{PagesRead}, testAPI) + r := apiRead() + r.TenantID = 235 + if _, e := f.s.Authorize(context.Background(), out.AccessToken, r); e != ErrDenied { + t.Fatal("cross tenant allowed") + } +} + +func TestReplayAtomicAcrossServiceInstances(t *testing.T) { + f := setup(t) + other, e := NewService(f.s.options) + if e != nil { + t.Fatal(e) + } + in := ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "one-use", nil), Audience: testAPI, Scopes: []string{PagesRead}} + var wg sync.WaitGroup + success := make(chan bool, 12) + for n := 0; n < 12; n++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + s := f.s + if n%2 == 0 { + s = other + } + _, e := s.Exchange(context.Background(), in) + success <- e == nil + }(n) + } + wg.Wait() + close(success) + count := 0 + for ok := range success { + if ok { + count++ + } + } + if count != 1 { + t.Fatalf("replayed assertion issued %d times", count) + } +} + +func TestTokenAndGrantCurrentState(t *testing.T) { + for _, name := range []string{"token-expired", "grant-expired", "grant-revoked", "version-changed", "token-revoked", "read-unavailable", "revocation-unavailable", "audit-unavailable", "policy-removed"} { + t.Run(name, func(t *testing.T) { + f := setup(t) + out := f.exchange(t, []string{PagesRead}, testAPI) + switch name { + case "token-expired": + f.now = f.now.Add(MaxTokenLifetime) + case "grant-expired": + f.store.grant.ExpiresAt = f.now + case "grant-revoked": + f.store.grant.Revoked = true + case "version-changed": + f.store.grant.Version++ + case "token-revoked": + f.store.revoked[digest("test-issued-jti")] = true + case "read-unavailable": + f.store.failRead = true + case "revocation-unavailable": + f.store.failRevocation = true + case "audit-unavailable": + f.audit.fail = true + case "policy-removed": + f.policy.deny = true + } + // A reconstructed service consults the same authoritative adapter; no + // local token/revocation cache can undo a persisted decision. + restarted, e := NewService(f.s.options) + if e != nil { + t.Fatal(e) + } + _, e = restarted.Authorize(context.Background(), out.AccessToken, apiRead()) + if e == nil { + t.Fatal("current-state denial lost after reconstruction") + } + }) + } +} + +func TestBodyOnlyRootPageBoundary(t *testing.T) { + cases := map[string]func(*Resource){ + "page": func(r *Resource) { r.PageID = "4" }, "version": func(r *Resource) { r.ExpectedVersion = 0 }, "blocks": func(r *Resource) { r.HasCanonicalBlocks = true }, "overlay": func(r *Resource) { r.RootPage = false }, "status": func(r *Resource) { r.Fields = append(r.Fields, "status") }, "only status": func(r *Resource) { r.Fields = []string{"status"} }, "delete": func(r *Resource) { r.Method = "DELETE" }, "create": func(r *Resource) { r.Scope = "admin:multisite.pages:create" }, "live": func(r *Resource) { r.TenantID = 42 }, "users": func(r *Resource) { r.Path = "/api/v1/admin/auth/users" }, "read token": func(r *Resource) { r.Scope = PagesRead }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + f := setup(t) + out := f.exchange(t, []string{PagesUpdate}, testAPI) + r := apiUpdate() + mutate(&r) + if _, e := f.s.Authorize(context.Background(), out.AccessToken, r); e != ErrDenied { + t.Fatal("forbidden operation authorized", e) + } + }) + } + f := setup(t) + out := f.exchange(t, []string{PagesRead}, testAPI) + if _, e := f.s.Authorize(context.Background(), out.AccessToken, apiUpdate()); e != ErrDenied { + t.Fatal("read-only token wrote") + } +} + +func TestSeparatePreviewPlane(t *testing.T) { + f := setup(t) + out := f.exchange(t, []string{PreviewRead}, testView) + r := Resource{TenantID: 234, Audience: testView, Scope: PreviewRead, Method: "GET", Origin: "https://preview.example.test", Path: "/rock-metal"} + if _, e := f.s.Authorize(context.Background(), out.AccessToken, r); e != nil { + t.Fatal(e) + } + for _, mutate := range []func(*Resource){func(r *Resource) { r.Audience = testAPI }, func(r *Resource) { r.Origin = "https://alias.example.test" }, func(r *Resource) { r.Path = "/admin" }, func(r *Resource) { r.Path = "/api/v1/admin" }, func(r *Resource) { r.Path = "/editor" }, func(r *Resource) { r.Path = "/oauth/token" }, func(r *Resource) { r.Path = "/%61dmin" }, func(r *Resource) { r.Path = "/unlisted" }, func(r *Resource) { r.Method = "POST" }, func(r *Resource) { r.TenantID = 235 }} { + c := r + mutate(&c) + if _, e := f.s.Authorize(context.Background(), out.AccessToken, c); e != ErrDenied { + t.Fatal("preview scope widened", e) + } + } + if _, e := f.s.Authorize(context.Background(), out.AccessToken, apiRead()); e != ErrDenied { + t.Fatal("view token accepted on API") + } +} + +func TestIssuerOutputStrictAndRedactedFailures(t *testing.T) { + for name, mutate := range map[string]func(*accessClaims){"audience": func(c *accessClaims) { c.Audience = jwt.ClaimStrings{testView} }, "issuer": func(c *accessClaims) { c.Issuer = "https://other.example.test" }, "subject": func(c *accessClaims) { c.Subject = "human-owner" }, "scope": func(c *accessClaims) { c.Scope = PagesUpdate }, "grant": func(c *accessClaims) { c.GrantVersion++ }, "tenant": func(c *accessClaims) { c.TenantID = 235 }, "expiry": func(c *accessClaims) { c.ExpiresAt = jwt.NewNumericDate(c.ExpiresAt.Add(time.Second)) }, "human": func(c *accessClaims) { c.TokenUse = "human_session" }} { + t.Run(name, func(t *testing.T) { + f := setup(t) + f.issuer.mutate = mutate + _, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "jti", nil), Audience: testAPI, Scopes: []string{PagesRead}}) + if e != ErrUnavailable { + t.Fatal("unsafe issuer output disclosed", e) + } + }) + } + f := setup(t) + f.store.failRead = true + _, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: "ASSERTION-SECRET-DO-NOT-LOG", Audience: testAPI, Scopes: []string{PagesRead}}) + b, _ := json.Marshal(f.audit.events) + if e != ErrUnavailable || strings.Contains(string(b), "SECRET") || strings.Contains(e.Error(), "SECRET") { + t.Fatal("audit/error disclosed sensitive input") + } + for _, kind := range []string{"replay", "revocation", "audit", "issuer"} { + t.Run(kind, func(t *testing.T) { + f := setup(t) + switch kind { + case "replay": + f.store.failReplay = true + case "revocation": + f.store.failRevocation = true + case "audit": + f.audit.fail = true + case "issuer": + f.issuer.fail = true + } + out, e := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: "grant-1", Assertion: f.assertion(t, "RAW-JTI", nil), Audience: testAPI, Scopes: []string{PagesRead}}) + if e != ErrUnavailable || out.AccessToken != "" || strings.Contains(e.Error(), "SECRET") { + t.Fatal("storage/issuer failure failed open") + } + b, _ := json.Marshal(f.audit.events) + if strings.Contains(string(b), "RAW-JTI") || strings.Contains(string(b), "SECRET") { + t.Fatal("raw auth data logged") + } + }) + } +} + +func TestGrantCapsTokenAtFiniteExpiry(t *testing.T) { + f := setup(t) + f.store.grant.ExpiresAt = f.now.Add(45 * time.Second) + out := f.exchange(t, []string{PagesRead}, testAPI) + if out.ExpiresIn != 45 { + t.Fatal("grant expiry not applied to issued token") + } +} + +func TestMountedExchangeHTTPBoundary(t *testing.T) { + f := setup(t) + form := url.Values{"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"}, "grant_id": {"grant-1"}, "assertion": {f.assertion(t, "http-jti", nil)}, "audience": {testAPI}, "scope": {PagesRead}} + server := httptest.NewServer(f.s.ExchangeHandler()) + defer server.Close() + response, e := http.Post(server.URL+"/oauth/agent/token", "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + if e != nil { + t.Fatal(e) + } + defer response.Body.Close() + var result TokenResponse + if e = json.NewDecoder(response.Body).Decode(&result); e != nil || response.StatusCode != 200 || result.AccessToken == "" || response.Header.Get("Cache-Control") != "private, no-store" { + t.Fatal("mounted exchange failure", e, response.StatusCode) + } + for _, kind := range []string{"cookie", "basic", "query", "duplicate", "refresh", "extra", "alias", "GET"} { + t.Run(kind, func(t *testing.T) { + copyForm := url.Values{} + for k, v := range form { + copyForm[k] = append([]string(nil), v...) + } + p := "/oauth/agent/token" + method := "POST" + switch kind { + case "query": + p += "?grant_id=grant-1" + case "duplicate": + copyForm.Add("grant_id", "grant-1") + case "refresh": + copyForm.Set("grant_type", "refresh_token") + case "extra": + copyForm.Set("role", "super_admin") + case "alias": + p = "/other/oauth/agent/token" + case "GET": + method = "GET" + } + r := httptest.NewRequest(method, p, strings.NewReader(copyForm.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if kind == "cookie" { + r.Header.Set("Cookie", "personal=SECRET") + } + if kind == "basic" { + r.SetBasicAuth("human", "SECRET") + } + w := httptest.NewRecorder() + f.s.ExchangeHandler().ServeHTTP(w, r) + if w.Code < 400 || strings.Contains(w.Body.String(), "SECRET") { + t.Fatal("alternate auth/request accepted", w.Code) + } + }) + } +} + +func TestConstructorHasNoInsecureFallback(t *testing.T) { + f := setup(t) + for _, mutate := range []func(*Options){func(o *Options) { o.Store = nil }, func(o *Options) { o.Audit = nil }, func(o *Options) { o.Policy = nil }, func(o *Options) { o.Issuer = nil }, func(o *Options) { o.IssuerURL = "http://issuer.example.test" }, func(o *Options) { o.TokenEndpoint = "" }, func(o *Options) { o.IssuerKeys = nil }} { + o := f.s.options + mutate(&o) + if _, e := NewService(o); e != ErrUnavailable { + t.Fatal("insecure constructor accepted") + } + } +} diff --git a/agentidentity/types.go b/agentidentity/types.go new file mode 100644 index 0000000..6fe1a34 --- /dev/null +++ b/agentidentity/types.go @@ -0,0 +1,186 @@ +// Package agentidentity implements opt-in, tenant-scoped service grants. Hosts +// supply durable adapters and Workflow's managed issuer; no human sessions or +// issuer/private keys are created by this package. +package agentidentity + +import ( + "context" + "crypto/ecdsa" + "errors" + "net/http" + "time" +) + +const ( + PagesRead = "admin:multisite.pages:read" + HistoryRead = "admin:multisite.pages:history:read" + PagesUpdate = "admin:multisite.pages:update" + PreviewRead = "admin:multisite.preview:read" + MaxGrantLifetime = 30 * 24 * time.Hour + MaxTokenLifetime = 15 * time.Minute + MaxAssertionLifetime = time.Minute +) + +var ( + ErrDenied = errors.New("agent access denied") + ErrUnavailable = errors.New("agent access unavailable") + ErrConflict = errors.New("agent grant conflict") +) + +type PreviewGrant struct { + Audience string `json:"audience"` + Origin string `json:"origin"` + Paths []string `json:"paths"` +} + +// Grant is a host-owned durable row. Rotation and any permission edit MUST +// increment Version atomically; revocation must not delete the audit record. +// The public assertion key is not issuer key material or a human credential. +type Grant struct { + ID string `json:"id"` + Subject string `json:"subject"` + AssertionIssuer string `json:"assertion_issuer"` + KeyID string `json:"key_id"` + PublicKey *ecdsa.PublicKey `json:"-"` + Version int64 `json:"version"` + TenantID int64 `json:"tenant_id"` + ApprovedBy string `json:"approved_by"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Revoked bool `json:"revoked"` + APIAudience string `json:"api_audience"` + Scopes []string `json:"scopes"` + UpdatePageIDs []string `json:"update_page_ids,omitempty"` + Preview *PreviewGrant `json:"preview,omitempty"` +} + +type AssertionUse struct { + GrantID string + GrantVersion int64 + JTIHash string + ExpiresAt time.Time + Now time.Time +} + +// Store methods MUST use durable state. ConsumeAssertion atomically checks the +// current grant version, enabled/expiry state AND inserts its globally unique +// assertion identity hash (issuer/subject/key/JTI binding). Uniqueness MUST NOT +// be partitioned by grant/version: one assertion cannot be reused on another +// tenant grant or after a permission edit. Return false for replay or changed/ +// revoked grants. Create and Revoke must be +// transactional and never alter a human identity/factor/bootstrap record. +// No in-memory production fallback is supplied. +type Store interface { + GetGrant(context.Context, string) (Grant, error) + ConsumeAssertion(context.Context, AssertionUse) (bool, error) + IsTokenRevoked(context.Context, string) (bool, error) +} + +type GrantManager interface { + CreateGrant(context.Context, Grant) error + RevokeGrant(context.Context, string, int64, string, time.Time) error + ListGrants(context.Context, Owner) ([]Grant, error) +} + +type Owner struct{ ID string } + +// OwnerPolicy must resolve a verified HUMAN owner and explicitly authorize the +// proposed tenant/resource/field ceiling. Request JSON never supplies identity. +// A service principal must not be projected into this resolver. +type OwnerPolicy interface { + ResolveOwner(*http.Request) (Owner, error) + AuthorizeOwner(context.Context, Owner, string, Grant) error +} + +// Policy is the consuming app's exact tenant/resource allowlist. It is required +// even for already persisted grants: the library does not know which tenant is +// live or which routes exist. It must reject live tenants and privileged routes +// for the preview assistant. An omitted policy never grants access. +type Policy interface { + AllowGrant(context.Context, Grant) error + AllowResource(context.Context, Grant, Resource) error +} + +type IssueRequest struct { + Subject, Audience, GrantID string + GrantVersion, TenantID int64 + Scopes []string + IssuedAt, ExpiresAt time.Time +} + +// Issuer is an adapter to Workflow M2M IssueManagedToken with managed-only +// issuance and an explicitly configured stable ES256 key. Hosts map this typed +// request; never implement this with a human cookie/HMAC or generic OAuth route. +type Issuer interface { + IssueAgentToken(context.Context, IssueRequest) (string, error) +} + +type IssuerFunc func(context.Context, IssueRequest) (string, error) + +func (f IssuerFunc) IssueAgentToken(ctx context.Context, r IssueRequest) (string, error) { + return f(ctx, r) +} + +// Audit contains fixed reason codes and verified metadata, never raw tokens, +// assertions, keys, headers, bodies, arbitrary error strings or human emails. +type Audit struct { + At time.Time `json:"at"` + Operation string `json:"operation"` + Decision string `json:"decision"` + Reason string `json:"reason"` + Subject string `json:"subject,omitempty"` + GrantID string `json:"grant_id,omitempty"` + GrantVersion int64 `json:"grant_version,omitempty"` + TenantID int64 `json:"tenant_id,omitempty"` + OwnerID string `json:"owner_id,omitempty"` + PageID string `json:"page_id,omitempty"` + Scope string `json:"scope,omitempty"` + JTIHash string `json:"jti_hash,omitempty"` +} +type Auditor interface { + Record(context.Context, Audit) error +} + +type Options struct { + IssuerURL string + TokenEndpoint string + IssuerKeys map[string]*ecdsa.PublicKey + Store Store + Issuer Issuer + Policy Policy + Audit Auditor + // Clock is for deterministic tests; production hosts normally omit it. + Clock func() time.Time +} + +type ExchangeRequest struct { + GrantID string + Assertion string + Audience string + Scopes []string +} +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` +} + +// Resource must be built from the matched route/current saved row by the host, +// never copied unverified from JSON or X-Tenant/actor headers. All writes are +// body_html-only root-page CAS; canonical blocks are refused. +type Resource struct { + TenantID int64 + Audience, Scope, PageID string + Fields []string + ExpectedVersion uint64 + RootPage bool + HasCanonicalBlocks bool + Method, Origin, Path string +} + +type Principal struct { + Subject, GrantID, JTIHash string + GrantVersion, TenantID int64 + Scopes []string +} diff --git a/agentidentity/ui.go b/agentidentity/ui.go new file mode 100644 index 0000000..fa600b6 --- /dev/null +++ b/agentidentity/ui.go @@ -0,0 +1,43 @@ +package agentidentity + +import ( + "encoding/json" + "fmt" +) + +func managementHTML(o ManagementOptions, tenants []TenantConfig) ([]byte, error) { + payload, err := json.Marshal(struct { + APIPath string `json:"api_path"` + Tenants []TenantConfig `json:"tenants"` + }{o.Path, tenants}) + if err != nil { + return nil, err + } + return []byte(fmt.Sprintf(managementTemplate, payload)), nil +} + +const managementTemplate = `Agent access + +

Agent access

Approve a separate assistant for a preview site. Access expires automatically and can be revoked here. Your personal account and login stay separate.

+

Approve access

+

+ + + +Use the public key from the approved protected workload. Its private key stays in the configured protected store; it is never entered here. + +
Permissions
+
+

Current grants

    +
    ` diff --git a/docs/agentidentity/README.md b/docs/agentidentity/README.md new file mode 100644 index 0000000..2273e9f --- /dev/null +++ b/docs/agentidentity/README.md @@ -0,0 +1,122 @@ +# Scoped agents for preview sites + +`agentidentity` is an opt-in native Go surface, like `adminidentity`; it does not +add gRPC step/manifest claims. Shared behavior belongs here. Hosts provide current +human-owner authorization, durable SQL adapters, canonical route/current-row +resolution and Workflow's managed issuer. No tenant/page IDs are compiled into +the mechanism. The JSON [two-site example](tenants.example.json) is public policy, +not a grant, secret, token or deployed configuration. + +## Configure once, approve each site + +Construct `NewTenantPolicy` from per-preview `TenantConfig` entries. Each declares +tenant ID, exact API audience/pages/history routes, maximum read/history/body-edit +scopes, allowed root page IDs, and separately approved view audience/origin/paths. +The `NewService` constructor requires that policy, durable Store, trusted ES256 +issuer public keys, managed Issuer and Auditor. Missing adapters fail closed. +An unconfigured tenant, unknown scope, misclassified route, alias or page outside +the field/page ceiling is denied. Never include a live tenant in this assistant +configuration. Configuration is a ceiling; it does not enroll an assistant. + +API tokens and protected-view tokens use distinct pinned audiences. Assertions +use only registered public P-256 keys, exact issuer/subject/key/endpoint audience, +required issue/expiry/JTI and a60-second maximum. An assertion identity is consumed +once across tenant grants and permission versions, with durable atomic current- +grant checks. Caller assertion roles/extra claims are not forwarded. Tokens last +at most15minutes and never outlive their finite grant (at most30days). Every use +checks the current grant/version/expiry/revoke and token revocation; storage or +audit failure denies. No refresh, client secret, human cookie or HS256 fallback +exists on the exchange path. + +`Authorize` accepts a typed Resource built from real host route resolution and +the current saved row, not caller X-Tenant/actor headers or JSON. Body writes +require PUT, the granted root page, exactly `body_html`, positive loaded +`expected_version` and no canonical blocks. The CMS performs atomic CAS; this +library cannot manufacture one. Reads cannot become writes, and no create/delete, +publish-status, schedule, identity, bootstrap, platform, domain, reload or live +authority is supported. Preview access permits exact-origin GET/HEAD only on +declared paths and never forwards a token to another origin. + +## Workflow issuer adapter + +The dedicated Workflow M2M instance needs the reviewed `IssueManagedToken` API +(WorkflowPR1043), a separately approved stable PEM-loaded ES256 key and +`SetManagedOnly(true)` before serving. Do not expose legacy OAuth grants beside +it or share its key with a legacy issuer. The host maps an `IssuerFunc` directly: + +```go +issuer := agentidentity.IssuerFunc(func(ctx context.Context, r agentidentity.IssueRequest) (string, error) { + return managed.IssueManagedToken(ctx, module.ManagedTokenRequest{ + Subject: r.Subject, Audience: r.Audience, GrantID: r.GrantID, + GrantVersion: r.GrantVersion, TenantID: r.TenantID, Scopes: r.Scopes, + IssuedAt: r.IssuedAt, ExpiresAt: r.ExpiresAt, + }) +}) +``` + +Pin the released matched Workflow API before compiling this adapter; this +plugin's current dependency is not silently changed to an unreleased branch. +The library verifies the complete issued token against pinned public keys and +expected typed grant before returning it. A wrong issuer/audience/scope/tenant, +human token discriminator or longer expiry cannot escape through a miswired +issuer adapter. Managed JWKS and tokens use the same public-key thumbprint `kid`. +Key rotation is explicit trusted-key configuration plus current grant/version +revocation, never silent key generation. This package is not another issuer/IDP. + +## Owner activation path + +1. Adopt the independently reviewed matched host/plugin/SQL/issuer configuration + through the guarded staging lane. Verify existing owner factors, accounts, + protected hosts and content; service QA cannot substitute for owner login. +2. Use the existing signed-in owner account on the reusable **Agent access** page. + `ManagementHandler` mounts an optional `PagePath`, API `Path` and pinned + `OwnerOrigin`. The human `OwnerPolicy` authorizes `propose/list/read/create/revoke` + for current tenant authority. POSTs require exact Origin/Host and same-origin + fetch metadata; agent bearers cannot be projected into the human resolver. + Ordinary owner cookies stay in the browser and are never exported to an agent. +3. Select a configured preview, stable assistant subject and registered public + workload key. Approve finite expiry and permissions. Read/history are selected + initially; body edits and protected viewing are separate unchecked options. + The page shows the exact page/origin boundaries. It rejects private-key PEM. +4. Activate only the separately approved protected workload custody path. Reuse + one approved workload identity/public key for additional separately approved + tenant grants when appropriate; each grant can expire/revoke independently. + A new site requires a policy entry and owner approval, not a source fork, + duplicate personal credential or per-site private-key hand juggling. +5. Revoke from the same page. The durable store checks the loaded grant version + and records the change transactionally. Rotation uses a separately approved + public key/new grant and revokes the old grant; there is no implicit renewal. + +For an approved local workload, existing `wfctl secrets set --provider keychain +--service ` offers hidden entry, and `--from-file` +supports an approved securely held PEM. Existing `wfctl env setup`/manifest store +routing can handle approved protected-runner configuration where the installed +version/provider supports it. These are instructions for an approved operator, +not commands this task ran. Never put key contents in argv/chat/repo/URLs, call +`secrets get/export` for handoff, or use random `--auto-gen-keys` as an EC PEM +generator. Issuer/workload keys are separate from the existing human admin HMAC. +If the exact runner/store/key permissions are not approved and supported, retain +the activation blocker rather than guessing a credential or generating a key. + +## Adoption and evidence limits + +The host must mount the exact TLS exchange path and current durable adapters. +Store replay uniqueness is global for issuer/subject/key/JTI, and its current-grant +test plus consume is one transaction. Management create/revoke uses a durable +transactional audit/outbox; plugin approval logs do not alone prove persistence. +Database schema application is explicit and separate; constructors apply no DDL. +No grant can be created until the human owner policy and persistence are mounted. + +The existing CMS Basic alternate-host gate does not accept this token yet. Its +separately reviewed strict service-view integration is required before agent +frontend QA; API activation alone proves no view access. Preserve Basic owner +access, anonymous/wrong-auth warmed-cache denial, no-store/Vary/noindex and +cross-origin token restrictions. Human sessions, bootstrap and protected auth +continuity stay separate. CMS CAS/history/promotion adoption and named live-content +approval remain mandatory; no site creation or grant implicitly publishes content. + +Tests use synthetic identities/keys and isolated adapters. Service reconstruction +tests prove use of the authoritative adapter instead of a local revocation cache; +production restart durability needs the real durable adapter and hosted proof. +Saved history can verify persisted edits, not unsaved drafts. Full matched-host, +owner-factor and actual browser QA remain adoption requirements. diff --git a/docs/agentidentity/tenants.example.json b/docs/agentidentity/tenants.example.json new file mode 100644 index 0000000..38411ff --- /dev/null +++ b/docs/agentidentity/tenants.example.json @@ -0,0 +1,28 @@ +[ + { + "tenant_id": 234, + "api_audience": "https://gocodealone.tech/api/v1/admin/agent-cms", + "pages_path": "/api/v1/admin/tenants/234/pages", + "history_path": "/api/v1/admin/tenants/234/pages/history", + "scopes": ["admin:multisite.pages:read", "admin:multisite.pages:history:read", "admin:multisite.pages:update", "admin:multisite.preview:read"], + "update_page_ids": ["3", "6"], + "preview": { + "audience": "https://tinalangevin-review.preview.gocodealone.tech/agent-view", + "origin": "https://tinalangevin-review.preview.gocodealone.tech", + "paths": ["/", "/voice-studio", "/rock-metal", "/calendar", "/media", "/contact", "/robots.txt", "/assets/tina.css", "/assets/tina.js", "/assets/classical.jpg", "/assets/metal.jpg"] + } + }, + { + "tenant_id": 345, + "api_audience": "https://admin.example.test/api/agent-cms", + "pages_path": "/api/v1/admin/tenants/345/pages", + "history_path": "/api/v1/admin/tenants/345/pages/history", + "scopes": ["admin:multisite.pages:read", "admin:multisite.pages:history:read", "admin:multisite.pages:update", "admin:multisite.preview:read"], + "update_page_ids": ["17", "18"], + "preview": { + "audience": "https://other-review.example.test/agent-view", + "origin": "https://other-review.example.test", + "paths": ["/", "/about", "/contact", "/assets/site.css"] + } + } +] diff --git a/docs/plans/2026-10-09-tenant-scoped-agent-access-design.md b/docs/plans/2026-10-09-tenant-scoped-agent-access-design.md index 46debd0..ecf465e 100644 --- a/docs/plans/2026-10-09-tenant-scoped-agent-access-design.md +++ b/docs/plans/2026-10-09-tenant-scoped-agent-access-design.md @@ -3,6 +3,14 @@ Status: proposed source design. No client, key, credential, grant, secret, token, deployment or persistent permission has been created. Owner activation is held. +Source implementation and reuse amendment: the opt-in native `agentidentity` +package implements grants/exchange/strict authorization and owner management; +Workflow retains managed ES256 issuance. Tenant/page/origin rules are declarative +configuration, not compiled Tina-specific policy. Positive tests cover two sites +and cross-tenant refusals. See `docs/agentidentity/README.md` for the bounded +activation path and explicit matched host/SQL/issuer/view adoption requirements. +This source amendment creates no hosted access or deployment. + ## Problem and observed boundary Jon wants agents to verify saved CMS edits and make authorized preview changes From 4cf36ed9ef2e4d135ef098d0d62bf92de6912bfe Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 9 Oct 2026 13:25:01 -0400 Subject: [PATCH 04/10] fix: keep owner management on authoritative agent store --- agentidentity/http.go | 13 ++++++++----- agentidentity/http_test.go | 23 ++++++++++++++++++++--- agentidentity/service.go | 12 ++++++++++++ agentidentity/service_test.go | 29 +++++++++++++++++++++++++++++ agentidentity/ui.go | 4 ++-- docs/agentidentity/README.md | 7 +++++-- 6 files changed, 76 insertions(+), 12 deletions(-) diff --git a/agentidentity/http.go b/agentidentity/http.go index 996ab58..e7bd3d5 100644 --- a/agentidentity/http.go +++ b/agentidentity/http.go @@ -71,7 +71,6 @@ type ManagementOptions struct { OwnerOrigin string PagePath string Owners OwnerPolicy - Store GrantManager } type proposal struct { @@ -95,7 +94,11 @@ type proposal struct { // are responsible for a durable transaction plus audit/outbox for management // mutations; no rollback can safely erase an already applied revoke. func (s *Service) ManagementHandler(o ManagementOptions) (http.Handler, error) { - if o.Owners == nil || o.Store == nil || !managementPath(o.Path) || !httpsOrigin(o.OwnerOrigin) { + // Management and validation must use the same authoritative adapter. A + // separately configured manager could report a successful revoke while the + // store used by Authorize still admits the old grant and its tokens. + manager, ok := s.options.Store.(GrantManager) + if !ok || o.Owners == nil || !managementPath(o.Path) || !httpsOrigin(o.OwnerOrigin) { return nil, ErrUnavailable } if o.PagePath != "" && (!managementPagePath(o.PagePath) || o.PagePath == o.Path) { @@ -154,7 +157,7 @@ func (s *Service) ManagementHandler(o ManagementOptions) (http.Handler, error) { httpError(w, ErrDenied) return } - grants, err := o.Store.ListGrants(r.Context(), owner) + grants, err := manager.ListGrants(r.Context(), owner) if err != nil { httpError(w, ErrUnavailable) return @@ -212,7 +215,7 @@ func (s *Service) ManagementHandler(o ManagementOptions) (http.Handler, error) { httpError(w, err) return } - if err = o.Store.RevokeGrant(r.Context(), g.ID, g.Version, owner.ID, s.now()); err != nil { + if err = manager.RevokeGrant(r.Context(), g.ID, g.Version, owner.ID, s.now()); err != nil { if err != ErrConflict { err = ErrUnavailable } @@ -264,7 +267,7 @@ func (s *Service) ManagementHandler(o ManagementOptions) (http.Handler, error) { httpError(w, err) return } - if err = o.Store.CreateGrant(r.Context(), g); err != nil { + if err = manager.CreateGrant(r.Context(), g); err != nil { if err != ErrConflict { err = ErrUnavailable } diff --git a/agentidentity/http_test.go b/agentidentity/http_test.go index fd89bc1..00bdb86 100644 --- a/agentidentity/http_test.go +++ b/agentidentity/http_test.go @@ -84,9 +84,26 @@ func manageRequest(p string, body map[string]any) *http.Request { return r } +// A read-only wrapper deliberately hides GrantManager even if its underlying +// store can manage grants. Management cannot be mounted against another store. +type validationOnlyStore struct{ Store } + +func TestOwnerManagementRequiresValidationStoreManagement(t *testing.T) { + f := setup(t) + opts := f.s.options + opts.Store = validationOnlyStore{f.store} + s, err := NewService(opts) + if err != nil { + t.Fatal(err) + } + if h, err := s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", Owners: &ownerFixture{}}); err != ErrUnavailable || h != nil { + t.Fatal("management mounted without the validation authority's manager", err) + } +} + func TestOwnerRegistrationAndRevocationUseExistingHumanSession(t *testing.T) { f := setup(t) - h, e := f.s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", Owners: &ownerFixture{}, Store: f.store}) + h, e := f.s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", Owners: &ownerFixture{}}) if e != nil { t.Fatal(e) } @@ -133,7 +150,7 @@ func TestOwnerGrantMutationDenialsAndNoPersonalCredentialFallback(t *testing.T) for _, kind := range []string{"agent", "no-owner", "wrong-origin", "missing-origin", "wrong-host", "cross-site", "not-confirmed", "role", "live-tenant", "grant-overlong", "privileged-scope", "private-key", "unapproved-page", "audit-down"} { t.Run(kind, func(t *testing.T) { f := setup(t) - h, e := f.s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", Owners: &ownerFixture{}, Store: f.store}) + h, e := f.s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", Owners: &ownerFixture{}}) if e != nil { t.Fatal(e) } @@ -195,7 +212,7 @@ func TestOwnerPageUsesConfiguredTenantsAndExistingSession(t *testing.T) { if e != nil { t.Fatal(e) } - h, e := s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", PagePath: "/admin/agent-access", Owners: &ownerFixture{}, Store: f.store}) + h, e := s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", PagePath: "/admin/agent-access", Owners: &ownerFixture{}}) if e != nil { t.Fatal(e) } diff --git a/agentidentity/service.go b/agentidentity/service.go index ac133d9..ad352ff 100644 --- a/agentidentity/service.go +++ b/agentidentity/service.go @@ -194,6 +194,18 @@ func (s *Service) reject(ctx context.Context, operation string, g Grant, err err return err } +// ValidateGrantForStorage checks the structural grant and resource bounds. +// It permits expired or revoked rows so durable adapters can load historical +// state; it does not authorize access, apply the host policy, or approve a new +// grant. Create adapters must additionally require Version 1 and !Revoked. +func ValidateGrantForStorage(g Grant) error { + g.Revoked = false + if !validGrant(g, g.CreatedAt) { + return ErrDenied + } + return nil +} + func validGrant(g Grant, now time.Time) bool { if !identifier(g.ID) || !identifier(g.Subject) || !identifier(g.KeyID) || !identifier(g.ApprovedBy) || !httpsURL(g.AssertionIssuer) || !httpsURL(g.APIAudience) || !publicKey(g.PublicKey) || g.Version <= 0 || g.TenantID <= 0 || g.Revoked || g.CreatedAt.IsZero() || g.CreatedAt.After(now) || !g.ExpiresAt.After(now) || g.ExpiresAt.Sub(g.CreatedAt) > MaxGrantLifetime || !canonicalScopes(g.Scopes) { return false diff --git a/agentidentity/service_test.go b/agentidentity/service_test.go index 4ec47cd..3744a86 100644 --- a/agentidentity/service_test.go +++ b/agentidentity/service_test.go @@ -360,6 +360,35 @@ func TestTokenAndGrantCurrentState(t *testing.T) { } } +func TestStorageValidationRetainsHistoricalRowsWithoutAuthorizingThem(t *testing.T) { + f := setup(t) + g := f.store.grant + g.Revoked = true + if err := ValidateGrantForStorage(g); err != nil || !g.Revoked { + t.Fatal("revoked durable row rejected or mutated", err) + } + g.Revoked = false + g.CreatedAt = f.now.Add(-2 * time.Hour) + g.ExpiresAt = f.now.Add(-time.Hour) + if err := ValidateGrantForStorage(g); err != nil || validGrant(g, f.now) { + t.Fatal("historical structural check became access admission", err) + } + for _, mutate := range []func(*Grant){ + func(g *Grant) { g.CreatedAt = time.Time{} }, + func(g *Grant) { g.ExpiresAt = g.CreatedAt }, + func(g *Grant) { g.ExpiresAt = g.CreatedAt.Add(MaxGrantLifetime + time.Second) }, + func(g *Grant) { g.Scopes = []string{"admin:auth.users:update"} }, + func(g *Grant) { g.PublicKey = nil }, + func(g *Grant) { g.Version = 0 }, + } { + bad := g + mutate(&bad) + if err := ValidateGrantForStorage(bad); err != ErrDenied { + t.Fatal("malformed durable grant accepted", err) + } + } +} + func TestBodyOnlyRootPageBoundary(t *testing.T) { cases := map[string]func(*Resource){ "page": func(r *Resource) { r.PageID = "4" }, "version": func(r *Resource) { r.ExpectedVersion = 0 }, "blocks": func(r *Resource) { r.HasCanonicalBlocks = true }, "overlay": func(r *Resource) { r.RootPage = false }, "status": func(r *Resource) { r.Fields = append(r.Fields, "status") }, "only status": func(r *Resource) { r.Fields = []string{"status"} }, "delete": func(r *Resource) { r.Method = "DELETE" }, "create": func(r *Resource) { r.Scope = "admin:multisite.pages:create" }, "live": func(r *Resource) { r.TenantID = 42 }, "users": func(r *Resource) { r.Path = "/api/v1/admin/auth/users" }, "read token": func(r *Resource) { r.Scope = PagesRead }, diff --git a/agentidentity/ui.go b/agentidentity/ui.go index fa600b6..20265c6 100644 --- a/agentidentity/ui.go +++ b/agentidentity/ui.go @@ -34,9 +34,9 @@ const statusEl=document.getElementById('status'),tenantEl=document.getElementByI const labels={'admin:multisite.pages:read':'Read saved pages','admin:multisite.pages:history:read':'Read saved history','admin:multisite.pages:update':'Edit the listed page bodies only','admin:multisite.preview:read':'View this protected preview'}; for(const t of config.tenants){const o=document.createElement('option');o.value=String(t.tenant_id);o.textContent='Preview tenant '+t.tenant_id;tenantEl.append(o)} function selected(){return config.tenants.find(t=>String(t.tenant_id)===tenantEl.value)} -function renderBoundary(){const t=selected();document.getElementById('boundary').textContent='API: '+t.api_audience+' · Editable pages: '+(t.update_page_ids||[]).join(', ')+(t.preview?' · Preview: '+t.preview.origin:'');const box=document.getElementById('permissions');box.replaceChildren();for(const scope of t.scopes){const label=document.createElement('label'),check=document.createElement('input');check.type='checkbox';check.value=scope;check.checked=scope.endsWith(':read')&&!scope.includes('.preview:');label.append(check,document.createTextNode(' '+labels[scope]));box.append(label)}} +function renderBoundary(){const t=selected();document.getElementById('boundary').textContent='API: '+t.api_audience+' · Editable pages: '+(t.update_page_ids||[]).join(', ')+(t.preview?' · Preview: '+t.preview.origin+' · Allowed routes and assets: '+t.preview.paths.join(', '):'');const box=document.getElementById('permissions');box.replaceChildren();for(const scope of t.scopes){const label=document.createElement('label'),check=document.createElement('input');check.type='checkbox';check.value=scope;check.checked=scope.endsWith(':read')&&!scope.includes('.preview:');label.append(check,document.createTextNode(' '+labels[scope]));box.append(label)}} async function request(path,method='GET',body){const r=await fetch(path,{method,credentials:'same-origin',headers:body?{'Content-Type':'application/json'}:{},body:body?JSON.stringify(body):undefined});if(!r.ok)throw new Error('Access request failed ('+r.status+').');return r.status===204?null:r.json()} -async function load(){const list=document.getElementById('grants');list.replaceChildren();for(const g of await request(config.api_path)){const item=document.createElement('li');item.append(document.createTextNode(g.subject+' · tenant '+g.tenant_id+' · expires '+new Date(g.expires_at).toLocaleString()+' · '+(g.revoked?'revoked':'active')+' · '+g.scopes.map(s=>labels[s]||s).join(', ')));if(!g.revoked){const button=document.createElement('button');button.type='button';button.textContent='Revoke';button.addEventListener('click',async()=>{if(!confirm('Revoke '+g.subject+' for tenant '+g.tenant_id+'?'))return;try{await request(config.api_path+'/revoke','POST',{id:g.id,expected_version:g.version,confirmed:true});statusEl.textContent='Access revoked.';await load()}catch(e){statusEl.textContent=e.message}});item.append(document.createTextNode(' '),button)}list.append(item)}} +async function load(){const list=document.getElementById('grants');list.replaceChildren();for(const g of await request(config.api_path)){const item=document.createElement('li');item.append(document.createTextNode(g.subject+' · tenant '+g.tenant_id+' · expires '+new Date(g.expires_at).toLocaleString()+' · '+(g.revoked?'revoked':new Date(g.expires_at).getTime()<=Date.now()?'expired':'active')+' · '+g.scopes.map(s=>labels[s]||s).join(', ')));if(!g.revoked){const button=document.createElement('button');button.type='button';button.textContent='Revoke';button.addEventListener('click',async()=>{if(!confirm('Revoke '+g.subject+' for tenant '+g.tenant_id+'?'))return;try{await request(config.api_path+'/revoke','POST',{id:g.id,expected_version:g.version,confirmed:true});statusEl.textContent='Access revoked.';await load()}catch(e){statusEl.textContent=e.message}});item.append(document.createTextNode(' '),button)}list.append(item)}} tenantEl.addEventListener('change',renderBoundary);document.getElementById('reload').addEventListener('click',()=>load().catch(e=>statusEl.textContent=e.message)); document.getElementById('grant-form').addEventListener('submit',async e=>{e.preventDefault();const t=selected(),scopes=[...document.querySelectorAll('#permissions input:checked')].map(x=>x.value);if(!scopes.length){statusEl.textContent='Select at least one permission.';return}const body={subject:document.getElementById('subject').value,assertion_issuer:document.getElementById('issuer').value,key_id:document.getElementById('key-id').value,public_key_pem:document.getElementById('public-key').value,tenant_id:t.tenant_id,api_audience:t.api_audience,expires_at:new Date(Date.now()+Number(document.getElementById('days').value)*86400000).toISOString(),scopes,update_page_ids:scopes.includes('admin:multisite.pages:update')?t.update_page_ids:[],preview:scopes.includes('admin:multisite.preview:read')?t.preview:null,confirmed:document.getElementById('confirmed').checked};try{await request(config.api_path,'POST',body);statusEl.textContent='Scoped access approved. Private key custody and workload activation remain with the approved operator.';await load()}catch(e){statusEl.textContent=e.message}}); renderBoundary();load().catch(e=>statusEl.textContent=e.message); diff --git a/docs/agentidentity/README.md b/docs/agentidentity/README.md index 2273e9f..aa47c0b 100644 --- a/docs/agentidentity/README.md +++ b/docs/agentidentity/README.md @@ -70,14 +70,17 @@ revocation, never silent key generation. This package is not another issuer/IDP. protected hosts and content; service QA cannot substitute for owner login. 2. Use the existing signed-in owner account on the reusable **Agent access** page. `ManagementHandler` mounts an optional `PagePath`, API `Path` and pinned - `OwnerOrigin`. The human `OwnerPolicy` authorizes `propose/list/read/create/revoke` + `OwnerOrigin`; its manager is the same authoritative `Store` supplied to + `NewService`, which must also implement `GrantManager`. A separate management + database cannot be wired to report a revoke that validation never observes. + The human `OwnerPolicy` authorizes `propose/list/read/create/revoke` for current tenant authority. POSTs require exact Origin/Host and same-origin fetch metadata; agent bearers cannot be projected into the human resolver. Ordinary owner cookies stay in the browser and are never exported to an agent. 3. Select a configured preview, stable assistant subject and registered public workload key. Approve finite expiry and permissions. Read/history are selected initially; body edits and protected viewing are separate unchecked options. - The page shows the exact page/origin boundaries. It rejects private-key PEM. + The page shows the exact page/origin/route boundaries. It rejects private-key PEM. 4. Activate only the separately approved protected workload custody path. Reuse one approved workload identity/public key for additional separately approved tenant grants when appropriate; each grant can expire/revoke independently. From 3b4951715fb47bef6b3918dbc7be19e540a5f217 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 9 Oct 2026 13:26:39 -0400 Subject: [PATCH 05/10] fix: label agent owner selectors for browser accessibility --- agentidentity/ui.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/agentidentity/ui.go b/agentidentity/ui.go index 20265c6..5ab71db 100644 --- a/agentidentity/ui.go +++ b/agentidentity/ui.go @@ -17,15 +17,15 @@ func managementHTML(o ManagementOptions, tenants []TenantConfig) ([]byte, error) } const managementTemplate = `Agent access - +

    Agent access

    Approve a separate assistant for a preview site. Access expires automatically and can be revoked here. Your personal account and login stay separate.

    Approve access

    -

    +

    Use the public key from the approved protected workload. Its private key stays in the configured protected store; it is never entered here. - +
    Permissions

    Current grants

      From 5df9a1d11181fb3d9ff260bbd3a11026f7158c7c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 9 Oct 2026 13:37:18 -0400 Subject: [PATCH 06/10] feat: persist agent grants and replay in PostgreSQL --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 3 +- agentidentity/postgres/audit.go | 76 +++ agentidentity/postgres/integration_test.go | 643 +++++++++++++++++++++ agentidentity/postgres/schema.sql | 67 +++ agentidentity/postgres/store.go | 304 ++++++++++ agentidentity/postgres/store_test.go | 191 ++++++ docs/agentidentity/README.md | 4 +- docs/agentidentity/postgres.md | 145 +++++ go.mod | 2 +- 10 files changed, 1433 insertions(+), 4 deletions(-) create mode 100644 agentidentity/postgres/audit.go create mode 100644 agentidentity/postgres/integration_test.go create mode 100644 agentidentity/postgres/schema.sql create mode 100644 agentidentity/postgres/store.go create mode 100644 agentidentity/postgres/store_test.go create mode 100644 docs/agentidentity/postgres.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b185588..832f2d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: if [ -n "$RELEASES_TOKEN" ]; then git config --global url."https://x-access-token:${RELEASES_TOKEN}@github.com/".insteadOf "https://github.com/" fi - - run: go test -race ./... + - run: go test -race -v ./... - run: go vet ./... plugin-contracts: diff --git a/CHANGELOG.md b/CHANGELOG.md index ea54138..e8e8c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ ### Added - Opt-in native `agentidentity` service grants, registered RFC7523 exchange, strict tenant/resource/field authorization, owner approval/revocation UI and - declarative multi-preview configuration; no human-session or issuer-key reuse. + declarative multi-preview configuration and explicit PostgreSQL persistence + with atomic replay/revocation and retained audit; no human-session or issuer-key reuse. - README verified-status banner per workflow#714 (multi-repo QoL sweep). - CONTRIBUTING.md, examples/minimal/config.yaml, and GitHub issue/PR templates. - Admin auth config now exposes a `passkey_auth_enabled` toggle, and auth diff --git a/agentidentity/postgres/audit.go b/agentidentity/postgres/audit.go new file mode 100644 index 0000000..180f68a --- /dev/null +++ b/agentidentity/postgres/audit.go @@ -0,0 +1,76 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "strconv" + + "github.com/GoCodeAlone/workflow-plugin-auth/agentidentity" +) + +var operations = map[string]bool{ + "exchange": true, "access": true, "grant-page": true, "grant-list": true, + "grant-create": true, "grant-revoke": true, "grant-create-committed": true, + "grant-revoke-committed": true, "assertion-consume": true, "token-revoke": true, +} +var reasons = map[string]bool{ + "issued": true, "authorized": true, "approved": true, "denied": true, + "unavailable": true, "committed": true, "consumed": true, "revoked": true, +} + +func validAudit(e agentidentity.Audit) bool { + if e.At.IsZero() || !operations[e.Operation] || !reasons[e.Reason] || (e.Decision != "allow" && e.Decision != "deny") || e.GrantVersion < 0 || e.TenantID < 0 { + return false + } + for _, id := range []string{e.Subject, e.GrantID, e.OwnerID} { + if id != "" && !identifier.MatchString(id) { + return false + } + } + if e.PageID != "" { + id, err := strconv.ParseUint(e.PageID, 10, 64) + if err != nil || id == 0 || strconv.FormatUint(id, 10) != e.PageID { + return false + } + } + if e.JTIHash != "" && !hash.MatchString(e.JTIHash) { + return false + } + if e.Scope != "" && e.Scope != agentidentity.PagesRead && e.Scope != agentidentity.HistoryRead && e.Scope != agentidentity.PagesUpdate && e.Scope != agentidentity.PreviewRead { + return false + } + return true +} + +func (s *Store) appendAudit(ctx context.Context, tx *sql.Tx, e agentidentity.Audit) error { + if !validAudit(e) { + return agentidentity.ErrDenied + } + e.At = e.At.UTC() + payload, err := json.Marshal(e) + if err != nil { + return agentidentity.ErrUnavailable + } + if _, err := tx.ExecContext(ctx, "INSERT INTO "+s.table("agent_management_audit")+" (event) VALUES($1)", payload); err != nil { + return agentidentity.ErrUnavailable + } + return nil +} + +// Record retains fixed-code service/management metadata. Create/Revoke also +// append their distinct committed event in the SAME mutation transaction. +func (s *Store) Record(ctx context.Context, e agentidentity.Audit) error { + if !validAudit(e) { + return agentidentity.ErrDenied + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return agentidentity.ErrUnavailable + } + defer tx.Rollback() + if s.appendAudit(ctx, tx, e) != nil || tx.Commit() != nil { + return agentidentity.ErrUnavailable + } + return nil +} diff --git a/agentidentity/postgres/integration_test.go b/agentidentity/postgres/integration_test.go new file mode 100644 index 0000000..dcb5c95 --- /dev/null +++ b/agentidentity/postgres/integration_test.go @@ -0,0 +1,643 @@ +package postgres + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "database/sql" + _ "embed" + "encoding/hex" + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/GoCodeAlone/workflow-plugin-auth/agentidentity" + "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/stdlib" +) + +//go:embed schema.sql +var fixtureSchemaSQL string + +// The integration fixture is explicit and loopback-only. It creates and drops +// only a random schema inside the dedicated synthetic CI/test database. +func fixtureURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "postgres" && u.Scheme != "postgresql") || u.User == nil || u.Path != "/agentidentity_test" || u.RawPath != "" || u.Fragment != "" || u.Opaque != "" { + return nil, agentidentity.ErrDenied + } + ip := net.ParseIP(u.Hostname()) + port, err := strconv.ParseUint(u.Port(), 10, 16) + if ip == nil || !ip.IsLoopback() || err != nil || port == 0 || !identifier.MatchString(u.User.Username()) { + return nil, agentidentity.ErrDenied + } + if password, ok := u.User.Password(); !ok || password == "" { + return nil, agentidentity.ErrDenied + } + q, err := url.ParseQuery(u.RawQuery) + if err != nil || len(q) != 1 || len(q["sslmode"]) != 1 || q.Get("sslmode") != "disable" { + return nil, agentidentity.ErrDenied + } + return u, nil +} + +func TestIntegrationURLRequiresExplicitSyntheticLoopbackDatabase(t *testing.T) { + good := "postgres://agentidentity_test:isolated-test-only@127.0.0.1:5432/agentidentity_test?sslmode=disable" + if _, err := fixtureURL(good); err != nil { + t.Fatal("isolated CI URI refused") + } + for _, bad := range []string{ + "", "host=127.0.0.1 dbname=agentidentity_test", + "postgres://fixture:synthetic@database.example.test:5432/agentidentity_test?sslmode=disable", + "postgres://fixture:synthetic@127.0.0.1:5432/production?sslmode=disable", + "postgres://fixture@127.0.0.1:5432/agentidentity_test?sslmode=disable", + "postgres://fixture:synthetic@127.0.0.1/agentidentity_test?sslmode=disable", + good + "&service=personal", good + "&passfile=/personal/pgpass", good + "&host=remote.example.test", + good + "&sslmode=disable", good + "#fragment", + } { + if _, err := fixtureURL(bad); err != agentidentity.ErrDenied { + t.Fatal("unsafe integration URI accepted") + } + } +} + +type pgFixture struct { + db *sql.DB + store *Store + config *pgx.ConnConfig +} + +func newPGFixture(t *testing.T) *pgFixture { + t.Helper() + raw := os.Getenv("AGENTIDENTITY_TEST_DATABASE_URL") + if raw == "" { + t.Skip("explicit AGENTIDENTITY_TEST_DATABASE_URL not provided; no database/profile action") + } + u, err := fixtureURL(raw) + if err != nil { + t.Fatal("integration database must be explicit synthetic loopback URI") + } + // Prevent inherited personal service, passfile, SSL or runtime profiles. + // These tests do not run in parallel because Setenv is process-scoped. + for _, name := range []string{"PGHOST", "PGPORT", "PGDATABASE", "PGUSER", "PGPASSWORD", "PGPASSFILE", "PGAPPNAME", "PGCONNECT_TIMEOUT", "PGSSLMODE", "PGSSLKEY", "PGSSLCERT", "PGSSLSNI", "PGSSLROOTCERT", "PGSSLPASSWORD", "PGSSLNEGOTIATION", "PGTARGETSESSIONATTRS", "PGSERVICE", "PGSERVICEFILE", "PGTZ", "PGOPTIONS", "PGMINPROTOCOLVERSION", "PGMAXPROTOCOLVERSION"} { + t.Setenv(name, "") + } + q := u.Query() + q.Set("passfile", filepath.Join(t.TempDir(), "nonexistent-synthetic-passfile")) + q.Set("connect_timeout", "5") + u.RawQuery = q.Encode() + config, err := pgx.ParseConfig(u.String()) + if err != nil { + t.Fatal("synthetic connection configuration failed") + } + db := stdlib.OpenDB(*config) + db.SetMaxOpenConns(8) + t.Cleanup(func() { db.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if db.PingContext(ctx) != nil { + t.Fatal("explicit synthetic PostgreSQL fixture unavailable") + } + var nonce [12]byte + if _, err := rand.Read(nonce[:]); err != nil { + t.Fatal("schema nonce failed") + } + schema := "agent_fixture_" + hex.EncodeToString(nonce[:]) + if _, err := db.ExecContext(ctx, `CREATE SCHEMA "`+schema+`"`); err != nil { + t.Fatal("owned synthetic schema create failed") + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if _, err := db.ExecContext(ctx, `DROP SCHEMA "`+schema+`" CASCADE`); err != nil { + t.Error("owned synthetic schema cleanup failed") + } + }) + tx, err := db.BeginTx(ctx, nil) + if err != nil { + t.Fatal("fixture transaction failed") + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `SET LOCAL search_path TO "`+schema+`"`); err != nil { + t.Fatal("owned schema search path failed") + } + if _, err := tx.ExecContext(ctx, fixtureSchemaSQL); err != nil { + t.Fatal("explicit fixture-only SQL adoption failed") + } + if tx.Commit() != nil { + t.Fatal("fixture migration commit failed") + } + s, err := New(db, schema) + if err != nil { + t.Fatal("fixture store failed") + } + return &pgFixture{db: db, store: s, config: config} +} + +func (f *pgFixture) reconnect(t *testing.T) *Store { + t.Helper() + db := stdlib.OpenDB(*f.config) + db.SetMaxOpenConns(8) + t.Cleanup(func() { db.Close() }) + s, err := New(db, f.store.schema) + if err != nil { + t.Fatal("reconnected fixture store failed") + } + return s +} + +func (f *pgFixture) count(t *testing.T, table string) int { + t.Helper() + var n int + if err := f.db.QueryRow("SELECT count(*) FROM " + f.store.table(table)).Scan(&n); err != nil { + t.Fatal("fixture row count failed") + } + return n +} + +func assertionUse(g agentidentity.Grant, label string) agentidentity.AssertionUse { + now := time.Now().UTC().Truncate(time.Microsecond) + return agentidentity.AssertionUse{GrantID: g.ID, GrantVersion: g.Version, JTIHash: fixtureHash(label), Now: now, ExpiresAt: now.Add(45 * time.Second)} +} + +func TestPostgresGrantReplayRevokeSurviveNewConnectionPool(t *testing.T) { + f := newPGFixture(t) + ctx := context.Background() + g := fixtureGrant(t, "fixture-one", 501) + if err := f.store.CreateGrant(ctx, g); err != nil { + t.Fatal("valid grant create failed") + } + if f.store.CreateGrant(ctx, g) != agentidentity.ErrConflict { + t.Fatal("duplicate create was not a CAS conflict") + } + loaded, err := f.reconnect(t).GetGrant(ctx, g.ID) + if err != nil || loaded.PublicKey.X.Cmp(g.PublicKey.X) != 0 || loaded.PublicKey.Y.Cmp(g.PublicKey.Y) != 0 || loaded.Preview.Origin != g.Preview.Origin || loaded.TenantID != g.TenantID || loaded.ApprovedBy != g.ApprovedBy || loaded.Version != 1 { + t.Fatal("grant/public key/content did not persist across connection pools") + } + use := assertionUse(g, "reconnect-assertion") + if ok, err := f.store.ConsumeAssertion(ctx, use); err != nil || !ok { + t.Fatal("first assertion consume failed") + } + if ok, err := f.reconnect(t).ConsumeAssertion(ctx, use); err != nil || ok { + t.Fatal("replay survived reconnect") + } + if f.store.RevokeGrant(ctx, g.ID, 2, "current-owner", time.Now()) != agentidentity.ErrConflict { + t.Fatal("stale expected version did not conflict") + } + if f.store.RevokeGrant(ctx, g.ID, 1, "current-owner", time.Now()) != nil { + t.Fatal("matching revoke failed") + } + if f.store.RevokeGrant(ctx, g.ID, 1, "current-owner", time.Now()) != agentidentity.ErrConflict { + t.Fatal("repeat revoke did not conflict") + } + loaded, err = f.reconnect(t).GetGrant(ctx, g.ID) + if err != nil || !loaded.Revoked || loaded.Version != 2 { + t.Fatal("revoke/version bump did not persist") + } + for _, version := range []int64{1, 2} { + use = assertionUse(g, "revoked-assertion") + use.GrantVersion = version + if ok, err := f.reconnect(t).ConsumeAssertion(ctx, use); err != nil || ok { + t.Fatal("revoked grant assertion was admitted") + } + } + now := time.Now().UTC() + if err := f.store.RevokeToken(ctx, fixtureHash("token-one"), "current-owner", now, now.Add(time.Minute)); err != nil { + t.Fatal("token revoke failed") + } + before := f.count(t, "agent_management_audit") + if err := f.store.RevokeToken(ctx, fixtureHash("token-one"), "current-owner", now, now.Add(time.Minute)); err != nil || f.count(t, "agent_management_audit") != before { + t.Fatal("idempotent token revoke duplicated committed outbox event") + } + if revoked, err := f.reconnect(t).IsTokenRevoked(ctx, fixtureHash("token-one")); err != nil || !revoked { + t.Fatal("token revocation did not persist") + } + if revoked, err := f.store.IsTokenRevoked(ctx, fixtureHash("different-token")); err != nil || revoked { + t.Fatal("unrelated token hash was revoked") + } + if f.count(t, "agent_management_audit") != 4 { + t.Fatal("committed create/consume/revoke/token events were not retained exactly once") + } +} + +func TestPostgresGlobalReplayConcurrentTenantsAndVersions(t *testing.T) { + f := newPGFixture(t) + ctx := context.Background() + one, two := fixtureGrant(t, "fixture-one", 501), fixtureGrant(t, "fixture-two", 502) + two.PublicKey = one.PublicKey // One workload identity, independent tenant grants. + for _, g := range []agentidentity.Grant{one, two} { + if err := f.store.CreateGrant(ctx, g); err != nil { + t.Fatal("tenant grant create failed") + } + if ok, err := f.store.ConsumeAssertion(ctx, assertionUse(g, g.ID+"-positive")); err != nil || !ok { + t.Fatal("independent tenant positive consume failed") + } + } + start := make(chan struct{}) + type outcome struct { + ok bool + err error + } + results := make(chan outcome, 24) + var workers sync.WaitGroup + for i := 0; i < 24; i++ { + g := one + if i%2 != 0 { + g = two + } + use := assertionUse(g, "same-issuer-subject-kid-jti") + workers.Add(1) + go func() { + defer workers.Done() + <-start + ok, err := f.store.ConsumeAssertion(ctx, use) + results <- outcome{ok, err} + }() + } + close(start) + workers.Wait() + close(results) + winners := 0 + for result := range results { + if result.err != nil { + t.Fatal("concurrent fixture consume failed") + } + if result.ok { + winners++ + } + } + if winners != 1 || f.count(t, "agent_assertion_uses") != 3 { + t.Fatal("global replay identity had multiple winners across grants") + } + // Fixture-only permission-edit version bump proves uniqueness is not + // partitioned by grant version. No production edit API is introduced. + if _, err := f.db.Exec("UPDATE "+f.store.table("agent_grants")+" SET version=2 WHERE id=$1", one.ID); err != nil { + t.Fatal("fixture version bump failed") + } + one.Version = 2 + if ok, err := f.reconnect(t).ConsumeAssertion(ctx, assertionUse(one, "same-issuer-subject-kid-jti")); err != nil || ok { + t.Fatal("version bump reopened globally consumed assertion") + } + if ok, err := f.store.ConsumeAssertion(ctx, assertionUse(one, "new-version-positive")); err != nil || !ok { + t.Fatal("fresh assertion at current version failed") + } + stale := assertionUse(one, "stale-version") + stale.GrantVersion = 1 + if ok, err := f.store.ConsumeAssertion(ctx, stale); err != nil || ok { + t.Fatal("stale grant version admitted") + } +} + +func TestPostgresExpiryUsesDatabaseClockAndRevokeSerialization(t *testing.T) { + f := newPGFixture(t) + ctx := context.Background() + g := fixtureGrant(t, "fixture-expiry", 501) + g.CreatedAt = time.Now().UTC().Add(-2 * time.Hour) + g.ExpiresAt = time.Now().UTC().Add(time.Hour) + if f.store.CreateGrant(ctx, g) != nil { + t.Fatal("expiry fixture create failed") + } + use := assertionUse(g, "expired-assertion") + use.Now, use.ExpiresAt = time.Now().UTC().Add(-time.Minute), time.Now().UTC().Add(-30*time.Second) + if ok, err := f.store.ConsumeAssertion(ctx, use); err != nil || ok { + t.Fatal("lagging caller clock admitted expired assertion") + } + if _, err := f.db.Exec("UPDATE "+f.store.table("agent_grants")+" SET expires_at=$2 WHERE id=$1", g.ID, time.Now().UTC().Add(-time.Second)); err != nil { + t.Fatal("owned expiry fixture update failed") + } + use = assertionUse(g, "expired-grant") + use.Now = time.Now().UTC().Add(-30 * time.Second) + use.ExpiresAt = use.Now.Add(time.Minute) + if ok, err := f.store.ConsumeAssertion(ctx, use); err != nil || ok { + t.Fatal("lagging caller clock admitted expired grant") + } + expired := fixtureGrant(t, "expired-create", 502) + expired.CreatedAt, expired.ExpiresAt = g.CreatedAt, time.Now().UTC().Add(-time.Second) + if f.store.CreateGrant(ctx, expired) != agentidentity.ErrDenied { + t.Fatal("expired create accepted") + } + live := fixtureGrant(t, "fixture-lock", 502) + if f.store.CreateGrant(ctx, live) != nil { + t.Fatal("lock fixture create failed") + } + tx, err := f.db.BeginTx(ctx, nil) + if err != nil { + t.Fatal("lock transaction failed") + } + defer tx.Rollback() + if _, err := tx.Exec("UPDATE "+f.store.table("agent_grants")+" SET revoked=true,version=2,revoked_by='fixture-owner',revoked_at=clock_timestamp() WHERE id=$1", live.ID); err != nil { + t.Fatal("owned concurrent revoke update failed") + } + result := make(chan bool, 1) + errResult := make(chan error, 1) + go func() { + ok, err := f.store.ConsumeAssertion(ctx, assertionUse(live, "locked-revoke")) + result <- ok + errResult <- err + }() + if tx.Commit() != nil { + t.Fatal("owned concurrent revoke commit failed") + } + if <-result || <-errResult != nil { + t.Fatal("consumer did not observe current revoked row after lock") + } +} + +func TestPostgresAuditFailureRollsBackEveryMutation(t *testing.T) { + f := newPGFixture(t) + ctx := context.Background() + g := fixtureGrant(t, "fixture-rollback", 501) + if f.store.CreateGrant(ctx, g) != nil { + t.Fatal("rollback fixture create failed") + } + before := f.count(t, "agent_management_audit") + function := f.store.table("fixture_reject_audit") + if _, err := f.db.Exec("CREATE FUNCTION " + function + "() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic-secret-token-PEM-DSN'; END; $$"); err != nil { + t.Fatal("owned audit failure fixture failed") + } + if _, err := f.db.Exec("CREATE TRIGGER fixture_audit_failure BEFORE INSERT ON " + f.store.table("agent_management_audit") + " FOR EACH ROW EXECUTE FUNCTION " + function + "()"); err != nil { + t.Fatal("owned audit failure trigger failed") + } + newGrant := fixtureGrant(t, "fixture-rollback-new", 502) + use := assertionUse(g, "rollback-assertion") + now := time.Now().UTC() + if f.store.CreateGrant(ctx, newGrant) != agentidentity.ErrUnavailable || f.store.RevokeGrant(ctx, g.ID, 1, "current-owner", now) != agentidentity.ErrUnavailable { + t.Fatal("audit failure did not fail grant mutations closed") + } + if ok, err := f.store.ConsumeAssertion(ctx, use); err != agentidentity.ErrUnavailable || ok { + t.Fatal("audit failure did not roll back assertion consume") + } + if f.store.RevokeToken(ctx, fixtureHash("rollback-token"), "current-owner", now, now.Add(time.Minute)) != agentidentity.ErrUnavailable { + t.Fatal("audit failure did not fail token revoke closed") + } + if f.store.Record(ctx, agentidentity.Audit{At: now, Operation: "access", Decision: "deny", Reason: "denied"}) != agentidentity.ErrUnavailable { + t.Fatal("audit raw database failure escaped redaction") + } + loaded, err := f.reconnect(t).GetGrant(ctx, g.ID) + if err != nil || loaded.Revoked || loaded.Version != 1 || f.count(t, "agent_grants") != 1 || f.count(t, "agent_assertion_uses") != 0 || f.count(t, "agent_token_revocations") != 0 || f.count(t, "agent_management_audit") != before { + t.Fatal("failed transaction left partial durable changes") + } + if _, err := f.db.Exec("DROP TRIGGER fixture_audit_failure ON " + f.store.table("agent_management_audit")); err != nil { + t.Fatal("owned audit failure trigger removal failed") + } + if ok, err := f.store.ConsumeAssertion(ctx, use); err != nil || !ok { + t.Fatal("rolled-back assertion was incorrectly consumed") + } +} + +func TestPostgresSQLConstraintsAndRetainedRows(t *testing.T) { + f := newPGFixture(t) + g := fixtureGrant(t, "fixture-constraints", 501) + ctx := context.Background() + if f.store.CreateGrant(ctx, g) != nil { + t.Fatal("constraint fixture create failed") + } + for _, clause := range []string{"tenant_id=0", "version=0", "expires_at=created_at+interval '721 hours'", "expires_at=created_at", "created_at='infinity'::timestamptz", "revoked=true"} { + if _, err := f.db.Exec("UPDATE "+f.store.table("agent_grants")+" SET "+clause+" WHERE id=$1", g.ID); err == nil { + t.Fatal("database structural constraint did not refuse invalid row") + } + } + if ok, err := f.store.ConsumeAssertion(ctx, assertionUse(g, "retained")); err != nil || !ok { + t.Fatal("retained fixture consume failed") + } + now := time.Now().UTC() + if f.store.RevokeToken(ctx, fixtureHash("retained-token"), "owner", now, now.Add(time.Minute)) != nil { + t.Fatal("retained fixture token revoke failed") + } + for _, table := range []string{"agent_assertion_uses", "agent_token_revocations", "agent_management_audit"} { + before := f.count(t, table) + for _, operation := range []string{"DELETE FROM ", "TRUNCATE "} { + if _, err := f.db.Exec(operation + f.store.table(table)); err == nil { + t.Fatal("retained record rewrite accepted") + } + } + if f.count(t, table) != before { + t.Fatal("retained records changed") + } + } + if _, err := f.db.Exec("UPDATE " + f.store.table("agent_management_audit") + " SET event='{}'::jsonb"); err == nil { + t.Fatal("audit update accepted") + } +} + +type ownerContextKey struct{} +type additionalOwnerPolicy struct{ tenants map[string]int64 } + +func (p additionalOwnerPolicy) ResolveOwner(r *http.Request) (agentidentity.Owner, error) { + owner, ok := r.Context().Value(ownerContextKey{}).(agentidentity.Owner) + if !ok { + return owner, agentidentity.ErrDenied + } + return owner, nil +} +func (p additionalOwnerPolicy) AuthorizeOwner(_ context.Context, owner agentidentity.Owner, operation string, g agentidentity.Grant) error { + tenant, ok := p.tenants[owner.ID] + if !ok { + return agentidentity.ErrDenied + } + if operation == "list" || tenant == g.TenantID { + return nil + } + return agentidentity.ErrDenied +} + +func TestPostgresOwnerListAndResourcePolicyApplyCurrentTenantAuthority(t *testing.T) { + f := newPGFixture(t) + ctx := context.Background() + one, two := fixtureGrant(t, "fixture-owner-one", 501), fixtureGrant(t, "fixture-owner-two", 502) + two.PublicKey = one.PublicKey + two.UpdatePageIDs = []string{"20"} + two.Preview = &agentidentity.PreviewGrant{Audience: "https://second.example.test/audience", Origin: "https://second.example.test", Paths: []string{"/", "/music"}} + configs := []agentidentity.TenantConfig{} + for _, g := range []agentidentity.Grant{one, two} { + if f.store.CreateGrant(ctx, g) != nil { + t.Fatal("owner list fixture create failed") + } + configs = append(configs, agentidentity.TenantConfig{TenantID: g.TenantID, APIAudience: g.APIAudience, PagesPath: "/admin/pages", HistoryPath: "/admin/pages/history", Scopes: g.Scopes, UpdatePageIDs: g.UpdatePageIDs, Preview: g.Preview}) + } + policy, err := agentidentity.NewTenantPolicy(configs) + if err != nil { + t.Fatal("two-tenant policy failed") + } + for _, g := range []agentidentity.Grant{one, two} { + loaded, err := f.reconnect(t).GetGrant(ctx, g.ID) + if err != nil || policy.AllowGrant(ctx, loaded) != nil { + t.Fatal("tenant policy positive failed") + } + resource := agentidentity.Resource{TenantID: g.TenantID, Audience: g.APIAudience, Scope: agentidentity.PagesUpdate, PageID: g.UpdatePageIDs[0], Path: "/admin/pages/" + g.UpdatePageIDs[0]} + if policy.AllowResource(ctx, loaded, resource) != nil { + t.Fatal("tenant resource positive failed") + } + resource.TenantID = 503 + if policy.AllowResource(ctx, loaded, resource) != agentidentity.ErrDenied { + t.Fatal("cross-tenant resource admitted") + } + resource.TenantID = g.TenantID + resource.PageID, resource.Path = "999", "/admin/pages/999" + if policy.AllowResource(ctx, loaded, resource) != agentidentity.ErrDenied { + t.Fatal("other tenant/page ceiling admitted") + } + } + owners := additionalOwnerPolicy{tenants: map[string]int64{"current-one": 501, "current-two": 502}} + service, err := agentidentity.NewService(agentidentity.Options{IssuerURL: "https://issuer.example.test", TokenEndpoint: "https://issuer.example.test/agent/token", IssuerKeys: map[string]*ecdsa.PublicKey{"issuer-kid": one.PublicKey}, Store: f.reconnect(t), Audit: f.store, Policy: policy, Issuer: agentidentity.IssuerFunc(func(context.Context, agentidentity.IssueRequest) (string, error) { return "", agentidentity.ErrDenied })}) + if err != nil { + t.Fatal("durable management fixture service failed") + } + handler, err := service.ManagementHandler(agentidentity.ManagementOptions{Path: "/admin/agent-grants", OwnerOrigin: "https://owner.example.test", Owners: owners}) + if err != nil { + t.Fatal("management fixture handler failed") + } + for _, owner := range []string{"current-one", "current-two"} { + r := httptest.NewRequest("GET", "https://owner.example.test/admin/agent-grants", nil) + r = r.WithContext(context.WithValue(r.Context(), ownerContextKey{}, agentidentity.Owner{ID: owner})) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + var grants []agentidentity.Grant + if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &grants) != nil || len(grants) != 1 || grants[0].TenantID != owners.tenants[owner] { + t.Fatal("current owner tenant filtering failed") + } + } + // Request metadata cannot substitute for the verified owner context. + r := httptest.NewRequest("GET", "https://owner.example.test/admin/agent-grants", nil) + r.Header.Set("X-Owner-ID", "current-one") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatal("header owner spoof accepted") + } + delete(owners.tenants, "current-one") + r = r.WithContext(context.WithValue(r.Context(), ownerContextKey{}, agentidentity.Owner{ID: "current-one"})) + w = httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatal("removed current owner retained list access") + } + if _, err := f.store.GetGrant(ctx, "unconfigured-new-site"); !errors.Is(err, agentidentity.ErrDenied) { + t.Fatal("site/config creation inferred an agent grant") + } +} + +// This signer is a synthetic fixture, not a Workflow managed issuer adapter. +// It proves persistence/core interoperability without external key custody. +type fixtureAccessClaims struct { + jwt.RegisteredClaims + TokenUse string `json:"token_use"` + GrantID string `json:"grant_id"` + GrantVersion int64 `json:"grant_version"` + TenantID int64 `json:"tenant_id"` + Scope string `json:"scope"` +} + +func TestPostgresServiceExchangeAuthorizeAcrossTenantsAndRevocations(t *testing.T) { + f := newPGFixture(t) + ctx := context.Background() + issuerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal("synthetic issuer key fixture failed") + } + workload, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal("synthetic workload key fixture failed") + } + one, two := fixtureGrant(t, "service-one", 501), fixtureGrant(t, "service-two", 502) + one.PublicKey, two.PublicKey = &workload.PublicKey, &workload.PublicKey + configs := []agentidentity.TenantConfig{} + for _, g := range []agentidentity.Grant{one, two} { + if f.store.CreateGrant(ctx, g) != nil { + t.Fatal("service grant fixture create failed") + } + configs = append(configs, agentidentity.TenantConfig{TenantID: g.TenantID, APIAudience: g.APIAudience, PagesPath: "/admin/pages", HistoryPath: "/admin/pages/history", Scopes: g.Scopes, UpdatePageIDs: g.UpdatePageIDs, Preview: g.Preview}) + } + policy, err := agentidentity.NewTenantPolicy(configs) + if err != nil { + t.Fatal("service tenant fixture policy failed") + } + const endpoint = "https://issuer.example.test/agent/token" + issuer := agentidentity.IssuerFunc(func(_ context.Context, r agentidentity.IssueRequest) (string, error) { + claims := fixtureAccessClaims{ + RegisteredClaims: jwt.RegisteredClaims{Issuer: "https://issuer.example.test", Subject: r.Subject, Audience: jwt.ClaimStrings{r.Audience}, IssuedAt: jwt.NewNumericDate(r.IssuedAt), ExpiresAt: jwt.NewNumericDate(r.ExpiresAt), ID: "issued-" + r.GrantID}, + TokenUse: "agent_access", GrantID: r.GrantID, GrantVersion: r.GrantVersion, TenantID: r.TenantID, Scope: strings.Join(r.Scopes, " "), + } + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["kid"] = "synthetic-issuer-key" + raw, err := token.SignedString(issuerKey) + if err != nil { + return "", agentidentity.ErrUnavailable + } + return raw, nil + }) + newService := func() *agentidentity.Service { + s, err := agentidentity.NewService(agentidentity.Options{IssuerURL: "https://issuer.example.test", TokenEndpoint: endpoint, IssuerKeys: map[string]*ecdsa.PublicKey{"synthetic-issuer-key": &issuerKey.PublicKey}, Store: f.reconnect(t), Audit: f.store, Policy: policy, Issuer: issuer}) + if err != nil { + t.Fatal("durable service fixture construction failed") + } + return s + } + assertion := func(id string) string { + now := time.Now().UTC().Truncate(time.Second) + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.RegisteredClaims{Issuer: one.AssertionIssuer, Subject: one.Subject, Audience: jwt.ClaimStrings{endpoint}, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(45 * time.Second)), ID: id}) + token.Header["kid"] = one.KeyID + raw, err := token.SignedString(workload) + if err != nil { + t.Fatal("synthetic assertion fixture signing failed") + } + return raw + } + service := newService() + sharedAssertion := assertion("global-service-assertion") + first, err := service.Exchange(ctx, agentidentity.ExchangeRequest{GrantID: one.ID, Assertion: sharedAssertion, Audience: one.APIAudience, Scopes: []string{agentidentity.PagesRead}}) + if err != nil { + t.Fatal("durable first tenant service exchange failed") + } + if _, err := newService().Exchange(ctx, agentidentity.ExchangeRequest{GrantID: two.ID, Assertion: sharedAssertion, Audience: two.APIAudience, Scopes: []string{agentidentity.PagesRead}}); err != agentidentity.ErrDenied { + t.Fatal("core global bound assertion replay across tenant grants admitted") + } + second, err := newService().Exchange(ctx, agentidentity.ExchangeRequest{GrantID: two.ID, Assertion: assertion("second-service-assertion"), Audience: two.APIAudience, Scopes: []string{agentidentity.PagesRead}}) + if err != nil { + t.Fatal("durable second tenant service exchange failed") + } + var firstPrincipal agentidentity.Principal + for i, g := range []agentidentity.Grant{one, two} { + raw := first.AccessToken + if i == 1 { + raw = second.AccessToken + } + resource := agentidentity.Resource{TenantID: g.TenantID, Audience: g.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: "/admin/pages"} + principal, err := newService().Authorize(ctx, raw, resource) + if err != nil || principal.TenantID != g.TenantID { + t.Fatal("durable current tenant service authorization failed") + } + if i == 0 { + firstPrincipal = principal + } + resource.TenantID = 503 + if _, err := service.Authorize(ctx, raw, resource); err != agentidentity.ErrDenied { + t.Fatal("core cross-tenant token admitted") + } + } + now := time.Now().UTC() + if f.store.RevokeToken(ctx, firstPrincipal.JTIHash, "current-owner", now, now.Add(time.Minute)) != nil { + t.Fatal("service token revocation fixture failed") + } + if _, err := newService().Authorize(ctx, first.AccessToken, agentidentity.Resource{TenantID: one.TenantID, Audience: one.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: "/admin/pages"}); err != agentidentity.ErrDenied { + t.Fatal("durable revoked token admitted after new service/pool") + } + if f.store.RevokeGrant(ctx, two.ID, 1, "current-owner", time.Now()) != nil { + t.Fatal("service grant revoke fixture failed") + } + if _, err := newService().Authorize(ctx, second.AccessToken, agentidentity.Resource{TenantID: two.TenantID, Audience: two.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: "/admin/pages"}); err != agentidentity.ErrDenied { + t.Fatal("durable revoked grant token admitted after new service/pool") + } +} diff --git a/agentidentity/postgres/schema.sql b/agentidentity/postgres/schema.sql new file mode 100644 index 0000000..a6daa30 --- /dev/null +++ b/agentidentity/postgres/schema.sql @@ -0,0 +1,67 @@ +-- Explicit, unapplied host prerequisite. Run in one caller-owned transaction +-- with a reviewed schema search_path. Constructors never execute this file. +-- These rows contain service grants/public keys, not human credentials. +CREATE TABLE agent_grants ( + id TEXT PRIMARY KEY CHECK (id ~ '^[A-Za-z0-9_.:-]{1,128}$'), + subject TEXT NOT NULL CHECK (subject ~ '^[A-Za-z0-9_.:-]{1,128}$'), + assertion_issuer TEXT NOT NULL CHECK (length(assertion_issuer) BETWEEN 1 AND 2048), + key_id TEXT NOT NULL CHECK (key_id ~ '^[A-Za-z0-9_.:-]{1,128}$'), + public_key_der BYTEA NOT NULL CHECK (octet_length(public_key_der) BETWEEN 1 AND 512), + version BIGINT NOT NULL CHECK (version > 0), + tenant_id BIGINT NOT NULL CHECK (tenant_id > 0), + approved_by TEXT NOT NULL CHECK (approved_by ~ '^[A-Za-z0-9_.:-]{1,128}$'), + created_at TIMESTAMPTZ NOT NULL CHECK (isfinite(created_at)), + expires_at TIMESTAMPTZ NOT NULL CHECK (isfinite(expires_at)), + revoked BOOLEAN NOT NULL DEFAULT false, + api_audience TEXT NOT NULL CHECK (length(api_audience) BETWEEN 1 AND 2048), + scopes JSONB NOT NULL CHECK (jsonb_typeof(scopes) = 'array' AND jsonb_array_length(scopes) BETWEEN 1 AND 4), + update_page_ids JSONB NOT NULL CHECK (jsonb_typeof(update_page_ids) = 'array' AND jsonb_array_length(update_page_ids) <= 128), + preview JSONB CHECK (preview IS NULL OR jsonb_typeof(preview) = 'object'), + revoked_by TEXT CHECK (revoked_by ~ '^[A-Za-z0-9_.:-]{1,128}$'), + revoked_at TIMESTAMPTZ CHECK (isfinite(revoked_at)), + CHECK (expires_at > created_at AND expires_at - created_at <= interval '720 hours'), + CHECK ((NOT revoked AND revoked_by IS NULL AND revoked_at IS NULL) OR + (revoked AND version > 1 AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL AND revoked_at >= created_at)) +); +CREATE INDEX agent_grants_tenant_id ON agent_grants (tenant_id, id); +CREATE TABLE agent_assertion_uses ( + jti_hash TEXT PRIMARY KEY CHECK (jti_hash ~ '^[0-9a-f]{64}$'), + grant_id TEXT NOT NULL REFERENCES agent_grants(id), + grant_version BIGINT NOT NULL CHECK (grant_version > 0), + consumed_at TIMESTAMPTZ NOT NULL CHECK (isfinite(consumed_at)), + expires_at TIMESTAMPTZ NOT NULL CHECK (isfinite(expires_at)), + CHECK (expires_at > consumed_at AND expires_at - consumed_at <= interval '1 minute') +); +-- The primary key is GLOBAL in this adapter's shared schema. Grant/version are +-- metadata only; partitioning uniqueness by either would allow replay. +CREATE TABLE agent_token_revocations ( + jti_hash TEXT PRIMARY KEY CHECK (jti_hash ~ '^[0-9a-f]{64}$'), + revoked_by TEXT NOT NULL CHECK (revoked_by ~ '^[A-Za-z0-9_.:-]{1,128}$'), + revoked_at TIMESTAMPTZ NOT NULL CHECK (isfinite(revoked_at)), + expires_at TIMESTAMPTZ NOT NULL CHECK (isfinite(expires_at)), + CHECK (expires_at > revoked_at AND expires_at - revoked_at <= interval '15 minutes') +); +-- Immutable audit rows form an outbox. Delivery cursors belong to the host; +-- successful delivery never deletes or edits these retained records. +CREATE TABLE agent_management_audit ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + event JSONB NOT NULL CHECK (jsonb_typeof(event) = 'object'), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() +); +CREATE FUNCTION agent_refuse_retained_rewrite() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION 'retained service identity record is append-only'; +END; +$$; +CREATE TRIGGER agent_audit_append_only BEFORE UPDATE OR DELETE ON agent_management_audit + FOR EACH ROW EXECUTE FUNCTION agent_refuse_retained_rewrite(); +CREATE TRIGGER agent_audit_no_truncate BEFORE TRUNCATE ON agent_management_audit + FOR EACH STATEMENT EXECUTE FUNCTION agent_refuse_retained_rewrite(); +CREATE TRIGGER agent_assertion_append_only BEFORE UPDATE OR DELETE ON agent_assertion_uses + FOR EACH ROW EXECUTE FUNCTION agent_refuse_retained_rewrite(); +CREATE TRIGGER agent_assertion_no_truncate BEFORE TRUNCATE ON agent_assertion_uses + FOR EACH STATEMENT EXECUTE FUNCTION agent_refuse_retained_rewrite(); +CREATE TRIGGER agent_revocation_append_only BEFORE UPDATE OR DELETE ON agent_token_revocations + FOR EACH ROW EXECUTE FUNCTION agent_refuse_retained_rewrite(); +CREATE TRIGGER agent_revocation_no_truncate BEFORE TRUNCATE ON agent_token_revocations + FOR EACH STATEMENT EXECUTE FUNCTION agent_refuse_retained_rewrite(); diff --git a/agentidentity/postgres/store.go b/agentidentity/postgres/store.go new file mode 100644 index 0000000..e5be286 --- /dev/null +++ b/agentidentity/postgres/store.go @@ -0,0 +1,304 @@ +// Package postgres persists agentidentity service grants in a host-owned SQL +// schema. It never provisions a database, schema, issuer key or human identity. +package postgres + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "database/sql" + "encoding/json" + "errors" + "math" + "regexp" + "time" + + "github.com/GoCodeAlone/workflow-plugin-auth/agentidentity" +) + +var namespace = regexp.MustCompile("^[a-z_][a-z0-9_]{0,62}$") +var identifier = regexp.MustCompile("^[A-Za-z0-9_.:-]{1,128}$") +var hash = regexp.MustCompile("^[0-9a-f]{64}$") + +const maxList = 4096 + +// Store uses one shared schema for ALL tenant grants and replay identities. +// The caller owns connection security/pool lifecycle, migration and current +// human/tenant authorization. Schema is copied/validated; no search_path is used. +type Store struct { + db *sql.DB + schema string +} + +// New validates configuration only. It performs no I/O, Ping or DDL. +func New(db *sql.DB, schema string) (*Store, error) { + if db == nil || !namespace.MatchString(schema) { + return nil, agentidentity.ErrUnavailable + } + return &Store{db: db, schema: schema}, nil +} + +func (s *Store) table(name string) string { return `"` + s.schema + `"."` + name + `"` } + +const grantColumns = "id,subject,assertion_issuer,key_id,public_key_der,version,tenant_id,approved_by,created_at,expires_at,revoked,api_audience,scopes,update_page_ids,preview" + +type scanner interface{ Scan(...any) error } + +func scanGrant(row scanner) (agentidentity.Grant, error) { + var g agentidentity.Grant + var key, scopes, pages, preview []byte + dest := []any{&g.ID, &g.Subject, &g.AssertionIssuer, &g.KeyID, &key, &g.Version, &g.TenantID, &g.ApprovedBy, &g.CreatedAt, &g.ExpiresAt, &g.Revoked, &g.APIAudience, &scopes, &pages, &preview} + if err := row.Scan(dest...); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return g, agentidentity.ErrDenied + } + return g, agentidentity.ErrUnavailable + } + if len(key) > 512 || len(scopes) > 4096 || len(pages) > 4096 || len(preview) > 16384 { + return agentidentity.Grant{}, agentidentity.ErrUnavailable + } + pk, err := x509.ParsePKIXPublicKey(key) + if err != nil { + return agentidentity.Grant{}, agentidentity.ErrUnavailable + } + public, ok := pk.(*ecdsa.PublicKey) + if !ok || public.Curve != elliptic.P256() { + return agentidentity.Grant{}, agentidentity.ErrUnavailable + } + g.PublicKey = public + if json.Unmarshal(scopes, &g.Scopes) != nil || json.Unmarshal(pages, &g.UpdatePageIDs) != nil { + return agentidentity.Grant{}, agentidentity.ErrUnavailable + } + if len(preview) != 0 { + if json.Unmarshal(preview, &g.Preview) != nil || g.Preview == nil { + return agentidentity.Grant{}, agentidentity.ErrUnavailable + } + } + g.CreatedAt, g.ExpiresAt = g.CreatedAt.UTC(), g.ExpiresAt.UTC() + if agentidentity.ValidateGrantForStorage(g) != nil || len(g.UpdatePageIDs) > 128 || (g.Revoked && g.Version < 2) { + return agentidentity.Grant{}, agentidentity.ErrUnavailable + } + return g, nil +} + +func (s *Store) GetGrant(ctx context.Context, id string) (agentidentity.Grant, error) { + if !identifier.MatchString(id) { + return agentidentity.Grant{}, agentidentity.ErrDenied + } + return scanGrant(s.db.QueryRowContext(ctx, "SELECT "+grantColumns+" FROM "+s.table("agent_grants")+" WHERE id=$1", id)) +} + +// ListGrants is internal persistence, not an owner-authorized endpoint. The +// reusable management handler filters each row using CURRENT OwnerPolicy. +// Original ApprovedBy must not be used to infer today's tenant authority. +func (s *Store) ListGrants(ctx context.Context, owner agentidentity.Owner) ([]agentidentity.Grant, error) { + if !identifier.MatchString(owner.ID) { + return nil, agentidentity.ErrDenied + } + rows, err := s.db.QueryContext(ctx, "SELECT "+grantColumns+" FROM "+s.table("agent_grants")+" ORDER BY id LIMIT $1", maxList+1) + if err != nil { + return nil, agentidentity.ErrUnavailable + } + defer rows.Close() + out := []agentidentity.Grant{} + for rows.Next() { + if len(out) == maxList { + return nil, agentidentity.ErrUnavailable + } + g, err := scanGrant(rows) + if err != nil { + return nil, agentidentity.ErrUnavailable + } + out = append(out, g) + } + if rows.Err() != nil { + return nil, agentidentity.ErrUnavailable + } + return out, nil +} + +func sqlConflict(err error) bool { + var e interface{ SQLState() string } + return errors.As(err, &e) && e.SQLState() == "23505" +} + +func (s *Store) CreateGrant(ctx context.Context, g agentidentity.Grant) error { + if g.Version != 1 || g.Revoked || agentidentity.ValidateGrantForStorage(g) != nil || len(g.UpdatePageIDs) > 128 { + return agentidentity.ErrDenied + } + key, err := x509.MarshalPKIXPublicKey(g.PublicKey) + if err != nil { + return agentidentity.ErrDenied + } + scopes, err := json.Marshal(g.Scopes) + if err != nil { + return agentidentity.ErrDenied + } + pages := g.UpdatePageIDs + if pages == nil { + pages = []string{} + } + pageJSON, err := json.Marshal(pages) + if err != nil { + return agentidentity.ErrDenied + } + var preview any + if g.Preview != nil { + b, err := json.Marshal(g.Preview) + if err != nil || len(b) > 16384 { + return agentidentity.ErrDenied + } + preview = b + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return agentidentity.ErrUnavailable + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, "INSERT INTO "+s.table("agent_grants")+" ("+grantColumns+") SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15 WHERE $9::timestamptz<=clock_timestamp() AND $10::timestamptz>clock_timestamp()", g.ID, g.Subject, g.AssertionIssuer, g.KeyID, key, g.Version, g.TenantID, g.ApprovedBy, g.CreatedAt.UTC(), g.ExpiresAt.UTC(), false, g.APIAudience, scopes, pageJSON, preview) + if err != nil { + if sqlConflict(err) { + return agentidentity.ErrConflict + } + return agentidentity.ErrUnavailable + } + n, err := result.RowsAffected() + if err != nil { + return agentidentity.ErrUnavailable + } + if n != 1 { + return agentidentity.ErrDenied + } + event := agentidentity.Audit{At: g.CreatedAt.UTC(), Operation: "grant-create-committed", Decision: "allow", Reason: "committed", Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: g.ApprovedBy} + if s.appendAudit(ctx, tx, event) != nil || tx.Commit() != nil { + return agentidentity.ErrUnavailable + } + return nil +} + +func (s *Store) RevokeGrant(ctx context.Context, id string, expectedVersion int64, actor string, at time.Time) error { + if !identifier.MatchString(id) || !identifier.MatchString(actor) || expectedVersion <= 0 || expectedVersion == math.MaxInt64 || at.IsZero() { + return agentidentity.ErrDenied + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return agentidentity.ErrUnavailable + } + defer tx.Rollback() + g, err := scanGrant(tx.QueryRowContext(ctx, "SELECT "+grantColumns+" FROM "+s.table("agent_grants")+" WHERE id=$1 FOR UPDATE", id)) + if errors.Is(err, agentidentity.ErrDenied) { + return agentidentity.ErrConflict + } + if err != nil { + return agentidentity.ErrUnavailable + } + if g.Revoked || g.Version != expectedVersion { + return agentidentity.ErrConflict + } + if at.Before(g.CreatedAt) { + return agentidentity.ErrDenied + } + result, err := tx.ExecContext(ctx, "UPDATE "+s.table("agent_grants")+" SET revoked=true,version=version+1,revoked_by=$3,revoked_at=$4 WHERE id=$1 AND version=$2 AND NOT revoked", id, expectedVersion, actor, at.UTC()) + if err != nil { + return agentidentity.ErrUnavailable + } + n, err := result.RowsAffected() + if err != nil { + return agentidentity.ErrUnavailable + } + if n != 1 { + return agentidentity.ErrConflict + } + event := agentidentity.Audit{At: at.UTC(), Operation: "grant-revoke-committed", Decision: "allow", Reason: "committed", Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version + 1, TenantID: g.TenantID, OwnerID: actor} + if s.appendAudit(ctx, tx, event) != nil || tx.Commit() != nil { + return agentidentity.ErrUnavailable + } + return nil +} + +// ConsumeAssertion serializes with grant revoke and atomically rechecks the +// current row. The DB clock also refuses expired state if a caller clock lags. +// One bound JTIHash is consumed globally, never by grant/version partition. +func (s *Store) ConsumeAssertion(ctx context.Context, use agentidentity.AssertionUse) (bool, error) { + if !identifier.MatchString(use.GrantID) || use.GrantVersion <= 0 || !hash.MatchString(use.JTIHash) || use.Now.IsZero() || !use.ExpiresAt.After(use.Now) || use.ExpiresAt.Sub(use.Now) > agentidentity.MaxAssertionLifetime { + return false, agentidentity.ErrDenied + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, agentidentity.ErrUnavailable + } + defer tx.Rollback() + g, err := scanGrant(tx.QueryRowContext(ctx, "SELECT "+grantColumns+" FROM "+s.table("agent_grants")+" WHERE id=$1 FOR UPDATE", use.GrantID)) + if errors.Is(err, agentidentity.ErrDenied) { + return false, nil + } + if err != nil { + return false, agentidentity.ErrUnavailable + } + if g.Revoked || g.Version != use.GrantVersion || g.CreatedAt.After(use.Now) || !g.ExpiresAt.After(use.Now) { + return false, nil + } + result, err := tx.ExecContext(ctx, "INSERT INTO "+s.table("agent_assertion_uses")+" (jti_hash,grant_id,grant_version,consumed_at,expires_at) SELECT $1,id,version,$4,$5 FROM "+s.table("agent_grants")+" WHERE id=$2 AND version=$3 AND NOT revoked AND created_at<=clock_timestamp() AND expires_at>clock_timestamp() AND $5::timestamptz>clock_timestamp() ON CONFLICT (jti_hash) DO NOTHING", use.JTIHash, g.ID, g.Version, use.Now.UTC(), use.ExpiresAt.UTC()) + if err != nil { + return false, agentidentity.ErrUnavailable + } + n, err := result.RowsAffected() + if err != nil { + return false, agentidentity.ErrUnavailable + } + if n != 1 { + return false, nil + } + event := agentidentity.Audit{At: use.Now.UTC(), Operation: "assertion-consume", Decision: "allow", Reason: "consumed", Subject: g.Subject, GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: g.ApprovedBy, JTIHash: use.JTIHash} + if s.appendAudit(ctx, tx, event) != nil || tx.Commit() != nil { + return false, agentidentity.ErrUnavailable + } + return true, nil +} + +func (s *Store) IsTokenRevoked(ctx context.Context, jtiHash string) (bool, error) { + if !hash.MatchString(jtiHash) { + return false, agentidentity.ErrDenied + } + var revoked bool + if err := s.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM "+s.table("agent_token_revocations")+" WHERE jti_hash=$1)", jtiHash).Scan(&revoked); err != nil { + return false, agentidentity.ErrUnavailable + } + return revoked, nil +} + +// RevokeToken records only an already-approved token identity hash. A caller +// must authorize the actor/target separately; this typed adapter grants no role. +func (s *Store) RevokeToken(ctx context.Context, jtiHash, actor string, at, expiresAt time.Time) error { + if !hash.MatchString(jtiHash) || !identifier.MatchString(actor) || at.IsZero() || !expiresAt.After(at) || expiresAt.Sub(at) > agentidentity.MaxTokenLifetime { + return agentidentity.ErrDenied + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return agentidentity.ErrUnavailable + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, "INSERT INTO "+s.table("agent_token_revocations")+" (jti_hash,revoked_by,revoked_at,expires_at) VALUES($1,$2,$3,$4) ON CONFLICT (jti_hash) DO NOTHING", jtiHash, actor, at.UTC(), expiresAt.UTC()) + if err != nil { + return agentidentity.ErrUnavailable + } + n, err := result.RowsAffected() + if err != nil { + return agentidentity.ErrUnavailable + } + if n == 1 { + event := agentidentity.Audit{At: at.UTC(), Operation: "token-revoke", Decision: "allow", Reason: "revoked", OwnerID: actor, JTIHash: jtiHash} + if s.appendAudit(ctx, tx, event) != nil { + return agentidentity.ErrUnavailable + } + } + if tx.Commit() != nil { + return agentidentity.ErrUnavailable + } + return nil +} + +var _ agentidentity.Store = (*Store)(nil) +var _ agentidentity.GrantManager = (*Store)(nil) +var _ agentidentity.Auditor = (*Store)(nil) diff --git a/agentidentity/postgres/store_test.go b/agentidentity/postgres/store_test.go new file mode 100644 index 0000000..e2c85e1 --- /dev/null +++ b/agentidentity/postgres/store_test.go @@ -0,0 +1,191 @@ +package postgres + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "database/sql" + "database/sql/driver" + "encoding/hex" + "errors" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/GoCodeAlone/workflow-plugin-auth/agentidentity" +) + +const syntheticLeak = "postgres://synthetic-secret@example.invalid/db Bearer synthetic-token -----BEGIN PRIVATE KEY-----" + +var driverOpens atomic.Int64 +var driverCalls atomic.Int64 + +type failingDriver struct{} +type failingConn struct{} + +func (failingDriver) Open(string) (driver.Conn, error) { + driverOpens.Add(1) + return failingConn{}, nil +} +func (failingConn) Prepare(string) (driver.Stmt, error) { + driverCalls.Add(1) + return nil, errors.New(syntheticLeak) +} +func (failingConn) Close() error { return nil } +func (failingConn) Begin() (driver.Tx, error) { + driverCalls.Add(1) + return nil, errors.New(syntheticLeak) +} +func (failingConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) { + driverCalls.Add(1) + return nil, errors.New(syntheticLeak) +} + +func init() { sql.Register("agentidentity-postgres-redaction-fixture", failingDriver{}) } + +func fixtureGrant(t *testing.T, id string, tenant int64) agentidentity.Grant { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal("synthetic public-key fixture failed") + } + now := time.Now().UTC().Truncate(time.Microsecond).Add(-time.Second) + return agentidentity.Grant{ + ID: id, Subject: "synthetic-agent", AssertionIssuer: "https://agent.example.test", + KeyID: "synthetic-kid", PublicKey: &key.PublicKey, Version: 1, TenantID: tenant, + ApprovedBy: "synthetic-owner", CreatedAt: now, ExpiresAt: now.Add(time.Hour), + APIAudience: "https://cms.example.test", + Scopes: []string{agentidentity.PagesRead, agentidentity.HistoryRead, agentidentity.PagesUpdate, agentidentity.PreviewRead}, + UpdatePageIDs: []string{"10"}, + Preview: &agentidentity.PreviewGrant{Audience: "https://preview.example.test/audience", Origin: "https://preview.example.test", Paths: []string{"/", "/teaching"}}, + } +} + +func fixtureHash(label string) string { + h := sha256.Sum256([]byte("synthetic-fixture:" + label)) + return hex.EncodeToString(h[:]) +} + +func TestConstructorHasNoIOAndValidatesNamespace(t *testing.T) { + db, err := sql.Open("agentidentity-postgres-redaction-fixture", "synthetic-unused") + if err != nil { + t.Fatal("driver fixture unavailable") + } + defer db.Close() + before := driverOpens.Load() + for _, schema := range []string{"agent_identity", "a", strings.Repeat("a", 63)} { + store, err := New(db, schema) + if err != nil || store.schema != schema { + t.Fatal("valid namespace refused") + } + } + for _, schema := range []string{"", "public;drop schema public", "a.b", `a"b`, "MixedCase", "1first", strings.Repeat("a", 64), "a\n"} { + if _, err := New(db, schema); err != agentidentity.ErrUnavailable { + t.Fatal("unsafe namespace accepted") + } + } + if _, err := New(nil, "agent_identity"); err != agentidentity.ErrUnavailable { + t.Fatal("nil database accepted") + } + if driverOpens.Load() != before { + t.Fatal("constructor performed database I/O") + } +} + +func TestDatabaseFailuresReturnOnlyFixedPublicErrors(t *testing.T) { + db, err := sql.Open("agentidentity-postgres-redaction-fixture", "synthetic-unused") + if err != nil { + t.Fatal("driver fixture unavailable") + } + defer db.Close() + s, err := New(db, "agent_fixture") + if err != nil { + t.Fatal("constructor failed") + } + ctx := context.Background() + g := fixtureGrant(t, "fixture-grant", 501) + now := time.Now().UTC() + checks := []struct { + name string + run func() error + }{ + {"get", func() error { _, err := s.GetGrant(ctx, g.ID); return err }}, + {"list", func() error { _, err := s.ListGrants(ctx, agentidentity.Owner{ID: "owner"}); return err }}, + {"create", func() error { return s.CreateGrant(ctx, g) }}, + {"revoke", func() error { return s.RevokeGrant(ctx, g.ID, 1, "owner", now) }}, + {"consume", func() error { + _, err := s.ConsumeAssertion(ctx, agentidentity.AssertionUse{GrantID: g.ID, GrantVersion: 1, JTIHash: fixtureHash("assertion"), Now: now, ExpiresAt: now.Add(30 * time.Second)}) + return err + }}, + {"read-revocation", func() error { _, err := s.IsTokenRevoked(ctx, fixtureHash("token")); return err }}, + {"revoke-token", func() error { return s.RevokeToken(ctx, fixtureHash("token"), "owner", now, now.Add(time.Minute)) }}, + {"audit", func() error { + return s.Record(ctx, agentidentity.Audit{At: now, Operation: "access", Decision: "deny", Reason: "denied"}) + }}, + } + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + if err := check.run(); err != agentidentity.ErrUnavailable || strings.Contains(err.Error(), syntheticLeak) { + t.Fatal("database error was not redacted to fixed unavailable") + } + }) + } +} + +func TestInvalidInputsAndUnboundedAuditNeverReachDatabase(t *testing.T) { + db, err := sql.Open("agentidentity-postgres-redaction-fixture", "synthetic-unused") + if err != nil { + t.Fatal("driver fixture unavailable") + } + defer db.Close() + s, _ := New(db, "agent_fixture") + ctx := context.Background() + before := driverCalls.Load() + g := fixtureGrant(t, "fixture-grant", 501) + g.Version = 2 + if s.CreateGrant(ctx, g) != agentidentity.ErrDenied { + t.Fatal("create did not enforce initial version") + } + g.Version, g.Revoked = 1, true + if s.CreateGrant(ctx, g) != agentidentity.ErrDenied { + t.Fatal("revoked create accepted") + } + if _, err := s.GetGrant(ctx, "bad/id"); err != agentidentity.ErrDenied { + t.Fatal("invalid grant ID accepted") + } + if _, err := s.ListGrants(ctx, agentidentity.Owner{}); err != agentidentity.ErrDenied { + t.Fatal("missing owner accepted") + } + if s.RevokeGrant(ctx, "fixture-grant", 0, "owner", time.Now()) != agentidentity.ErrDenied { + t.Fatal("missing expected version accepted") + } + if _, err := s.IsTokenRevoked(ctx, syntheticLeak); err != agentidentity.ErrDenied { + t.Fatal("raw token accepted as a hash") + } + if s.RevokeToken(ctx, fixtureHash("token"), "owner", time.Now(), time.Now().Add(time.Hour)) != agentidentity.ErrDenied { + t.Fatal("overlong token lifetime accepted") + } + base := agentidentity.Audit{At: time.Now(), Operation: "access", Decision: "deny", Reason: "denied"} + for _, mutate := range []func(*agentidentity.Audit){ + func(a *agentidentity.Audit) { a.Reason = syntheticLeak }, + func(a *agentidentity.Audit) { a.Operation = syntheticLeak }, + func(a *agentidentity.Audit) { a.Subject = syntheticLeak }, + func(a *agentidentity.Audit) { a.OwnerID = "owner@example.test" }, + func(a *agentidentity.Audit) { a.Scope = "admin:*" }, + func(a *agentidentity.Audit) { a.JTIHash = syntheticLeak }, + func(a *agentidentity.Audit) { a.PageID = "18446744073709551616" }, + func(a *agentidentity.Audit) { a.PageID = "01" }, + } { + a := base + mutate(&a) + if s.Record(ctx, a) != agentidentity.ErrDenied { + t.Fatal("unbounded audit metadata accepted") + } + } + if driverCalls.Load() != before { + t.Fatal("invalid input reached database") + } +} diff --git a/docs/agentidentity/README.md b/docs/agentidentity/README.md index aa47c0b..125f2c1 100644 --- a/docs/agentidentity/README.md +++ b/docs/agentidentity/README.md @@ -107,7 +107,9 @@ The host must mount the exact TLS exchange path and current durable adapters. Store replay uniqueness is global for issuer/subject/key/JTI, and its current-grant test plus consume is one transaction. Management create/revoke uses a durable transactional audit/outbox; plugin approval logs do not alone prove persistence. -Database schema application is explicit and separate; constructors apply no DDL. +The [PostgreSQL adapter](postgres.md) supplies current grants, global assertion +replay, token revocation and transactional retained management audit. Database +schema application is explicit and separate; constructors apply no DDL. No grant can be created until the human owner policy and persistence are mounted. The existing CMS Basic alternate-host gate does not accept this token yet. Its diff --git a/docs/agentidentity/postgres.md b/docs/agentidentity/postgres.md new file mode 100644 index 0000000..751887b --- /dev/null +++ b/docs/agentidentity/postgres.md @@ -0,0 +1,145 @@ +# Durable PostgreSQL service grants + +`agentidentity/postgres` supplies a reusable `database/sql` adapter for +`agentidentity.Store`, `GrantManager` and `Auditor`. It persists public assertion +keys, approved tenant grants, globally consumed assertion identities, token +revocations and an immutable audit outbox. It creates no human identity, +passkey, TOTP factor, bootstrap credential, managed issuer key or private key. + +```go +store, err := postgres.New(existingHostDB, reviewedSharedSchema) +// Handle err; New only validates the DB pointer and namespace. No Ping or DDL. +// Pass this SAME store to agentidentity.Options.Store and Options.Audit. +``` + +`New(*sql.DB, string) (*Store, error)` accepts a lowercase PostgreSQL namespace +matching `[a-z_][a-z0-9_]{0,62}`. Every runtime table reference is qualified with +that copied namespace; connection `search_path` does not select the grant store. +The host owns TLS, the pool, driver registration, credentials and lifecycle. +Production code imports no driver. Tests use the already pinned pgx driver. + +The host must use **one shared schema for all grants accepted by one service +identity exchange authority**. A separate namespace per tenant would split the +global replay ledger and permit reuse of one assertion on another tenant grant. +A second preview site requires declarative `TenantConfig` and a separate, +explicitly approved grant; it requires no adapter fork or new human credential. +Creating a site or declaring its configuration never activates an agent grant. + +## Typed persistence and authorization + +- `GetGrant(ctx, id)` loads the full saved grant and P-256 public key. Missing or + invalid identifiers return `ErrDenied`; corrupt/unavailable storage returns + `ErrUnavailable`. Revoked or expired rows remain readable for management; + service authorization rejects them using current grant and host policy. +- `CreateGrant(ctx, grant)` requires version 1, an active structurally valid + grant, positive tenant ID, approved owner ID and a lifetime no greater than + 30 days. The database clock rejects future creation or already expired grants. + Duplicate IDs return `ErrConflict`. The insert and committed audit event share + one transaction. The reusable owner handler supplies `ApprovedBy` from its + verified human resolver, never from request JSON or headers. +- `RevokeGrant(ctx, id, expectedVersion, actor, at)` locks and rechecks the + current row, refuses stale/repeated revocation with `ErrConflict`, increments + its version and retains who revoked it and when. The version change and audit + event commit together. Revocation does not delete the original approval row. +- `ConsumeAssertion(ctx, use)` locks the current grant, rechecks version, + revocation, creation and expiration, then inserts the bound identity hash + atomically. Expiration is also checked against the database clock after lock + acquisition. Replay/changed/revoked/expired grants return `false, nil`; + storage/audit failure returns `ErrUnavailable`. The `jti_hash` primary key is + global: **grant ID and version are metadata, never uniqueness partitions**. + Core computes this hash from issuer, subject, key ID and JTI. Only the typed + service may derive that hash from a verified assertion. +- `IsTokenRevoked(ctx, hash)` reads the retained revocation hash. + `RevokeToken(ctx, hash, actor, at, expiresAt)` is an additional typed adapter + operation, with a maximum 15 minute token interval. Repeated revocation is + idempotent. Its caller must separately authorize the current actor and exact + target; there is no public revocation endpoint or automatic role assignment. +- `ListGrants(ctx, Owner)` is an internal persistence read, bounded to 4096 rows; + exceeding the bound fails closed instead of silently truncating. It does not + infer current ownership from historical `ApprovedBy`. The reusable management + handler resolves the current human owner and filters every result through + current `OwnerPolicy` **and** `TenantPolicy`. Never expose this adapter method + directly as an HTTP list endpoint. +- `Record(ctx, Audit)` accepts fixed operation/reason/scope codes and bounded + verified metadata. It rejects raw error strings, tokens, PEM and email-like + owner IDs. Mutation methods also write distinct committed events inside their + own transactions, so failure of a later service-level audit cannot hide an + already committed grant mutation. + +All public database errors collapse to `ErrUnavailable`, `ErrConflict` or +`ErrDenied`; DSNs, SQL errors, PEM and tokens are not returned. There are no +automatic retries. A commit error can have an uncertain outcome: read back +current durable state before any explicit recovery, and never replay a consumed +assertion or retry a stale version automatically. + +## Explicit, unapplied SQL and host adoption + +[`schema.sql`](../../agentidentity/postgres/schema.sql) is a **new, unapplied +host prerequisite**, not a constructor migration or a currently adopted host +schema version. It creates only the four service-identity tables, their indexes +and immutable-record triggers. Foreign keys reference only these new tables; +there is no assumed host users/tenant/factor schema. SQL enforces positive +versions/tenant IDs, bounded finite grant/assertion/token intervals and +consistent revocation metadata. Grant lifetime uses elapsed 720 hours, avoiding +session timezone/DST dependence. Audit, assertion-consume and token-revocation +rows reject updates, deletes and truncation. Expired hashes remain retained; +pruning them would reopen identity reuse. History retention/archival and an +outbox delivery cursor are separate approved host work, not automatic cleanup. + +Adoption requires a separately reviewed host migration that selects one trusted +shared namespace and runs this SQL in a caller-owned transaction with a reviewed +local `search_path`. The host must assign a new schema version and exact matched +image/continuity admission, preserve existing content/auth baselines, fence all +writers and refuse old writers after adoption. The current CMS schema10→11 +admission does not authorize this schema, nor does the proposed CMS10→12 history +sequence. Do not renumber or edit released migrations to make these tables fit. +The complete combined host target/admission is still an integration gate. + +An operator migration role owns the tables/triggers. A separate runtime role +needs namespace `USAGE`; grant `SELECT`/`INSERT` plus update of only +`version`, `revoked`, `revoked_by`, `revoked_at`; assertion/revocation +`SELECT`/`INSERT`; audit `INSERT`; and identity-sequence `USAGE`. Approved audit +readback/outbox consumers additionally need audit `SELECT`. The runtime role +must not own tables, disable triggers or obtain `DELETE`, `TRUNCATE`, schema +creation or human/factor/bootstrap access. If a later permission/key rotation +API is added, it must atomically increment the grant version and append an audit +event. No such mutation API is implemented here. + +SQL privileges, any database credential configuration, managed issuer adoption, +workload key custody and per-tenant owner activation remain explicit approval +and integration prerequisites. Use supported host/wfctl configuration and secret +references where available; do not paste personal credentials between sites. +This source change applies no migration, creates no real credential or grant, +and does not change a live or staging host. + +## Verification boundary + +Source tests prove constructor non-I/O, namespace rejection, bounded metadata +and redaction under a driver that produces synthetic secret-bearing errors. +Real PostgreSQL tests run only when `AGENTIDENTITY_TEST_DATABASE_URL` is explicitly +set. The accepted URI must name numeric loopback, an explicit port/password, +`/agentidentity_test` and only `sslmode=disable`; inherited personal libpq +profiles are disabled and passfile resolution points to a nonexistent fixture +path. Each test creates a random owned schema, adopts SQL only there and drops +only that schema. An absent variable skips; a configured bad/unavailable database +**fails**. CI supplies PostgreSQL 17 and a synthetic ephemeral test password. + +```sh +GOWORK=off go test -race ./agentidentity/... +GOWORK=off go vet ./agentidentity/... +``` + +The PG fixtures cover two independent tenant positives; owner list/resource +cross-negatives; 24 concurrent replay contenders across tenant grants; +actual service exchange/authorization over durable storage using a synthetic +in-memory signer (including cross-tenant replay and token/grant revocation); +global replay after a grant-version change; fresh connection-pool persistence +of public keys, revocation and replay; database-clock expiration; revoke/consume +serialization; SQL lifetime/metadata constraints; retained-row immutability; +and audit-failure rollback of create, revoke, consume and token revocation. +They use synthetic owners and public keys, not human login/factor credentials. + +CI/package proof does not replace fresh mounted host API, saved CMS history and +browser proof. A **separate fresh human owner passkey/TOTP continuity ceremony** +is required after host adoption. Agent CMS/frontend QA cannot supply that proof; +its absence remains a blocker even if package/PG/browser fixtures pass. diff --git a/go.mod b/go.mod index e1f5508..15d8658 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/go-webauthn/webauthn v0.16.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.2 github.com/pquerna/otp v1.5.0 github.com/wneessen/go-mail v0.7.2 golang.org/x/crypto v0.53.0 @@ -104,7 +105,6 @@ require ( github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect From 169e5e6e2920a60728f3d8e83980ec52006b49fc Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 9 Oct 2026 13:42:24 -0400 Subject: [PATCH 07/10] test: validate durable agent fixtures before database skips --- agentidentity/postgres/integration_test.go | 44 ++++++++-------- agentidentity/postgres/store_test.go | 61 ++++++++++++++++++++++ 2 files changed, 82 insertions(+), 23 deletions(-) diff --git a/agentidentity/postgres/integration_test.go b/agentidentity/postgres/integration_test.go index dcb5c95..b581772 100644 --- a/agentidentity/postgres/integration_test.go +++ b/agentidentity/postgres/integration_test.go @@ -454,29 +454,28 @@ func (p additionalOwnerPolicy) AuthorizeOwner(_ context.Context, owner agentiden } func TestPostgresOwnerListAndResourcePolicyApplyCurrentTenantAuthority(t *testing.T) { - f := newPGFixture(t) ctx := context.Background() one, two := fixtureGrant(t, "fixture-owner-one", 501), fixtureGrant(t, "fixture-owner-two", 502) two.PublicKey = one.PublicKey two.UpdatePageIDs = []string{"20"} two.Preview = &agentidentity.PreviewGrant{Audience: "https://second.example.test/audience", Origin: "https://second.example.test", Paths: []string{"/", "/music"}} - configs := []agentidentity.TenantConfig{} + configs := fixtureTenantConfigs(one, two) + policy, err := agentidentity.NewTenantPolicy(configs) + if err != nil { + t.Fatal("two-tenant policy failed before database skip") + } + f := newPGFixture(t) for _, g := range []agentidentity.Grant{one, two} { if f.store.CreateGrant(ctx, g) != nil { t.Fatal("owner list fixture create failed") } - configs = append(configs, agentidentity.TenantConfig{TenantID: g.TenantID, APIAudience: g.APIAudience, PagesPath: "/admin/pages", HistoryPath: "/admin/pages/history", Scopes: g.Scopes, UpdatePageIDs: g.UpdatePageIDs, Preview: g.Preview}) } - policy, err := agentidentity.NewTenantPolicy(configs) - if err != nil { - t.Fatal("two-tenant policy failed") - } - for _, g := range []agentidentity.Grant{one, two} { + for i, g := range []agentidentity.Grant{one, two} { loaded, err := f.reconnect(t).GetGrant(ctx, g.ID) if err != nil || policy.AllowGrant(ctx, loaded) != nil { t.Fatal("tenant policy positive failed") } - resource := agentidentity.Resource{TenantID: g.TenantID, Audience: g.APIAudience, Scope: agentidentity.PagesUpdate, PageID: g.UpdatePageIDs[0], Path: "/admin/pages/" + g.UpdatePageIDs[0]} + resource := agentidentity.Resource{TenantID: g.TenantID, Audience: g.APIAudience, Scope: agentidentity.PagesUpdate, PageID: g.UpdatePageIDs[0], Path: configs[i].PagesPath + "/" + g.UpdatePageIDs[0]} if policy.AllowResource(ctx, loaded, resource) != nil { t.Fatal("tenant resource positive failed") } @@ -485,7 +484,7 @@ func TestPostgresOwnerListAndResourcePolicyApplyCurrentTenantAuthority(t *testin t.Fatal("cross-tenant resource admitted") } resource.TenantID = g.TenantID - resource.PageID, resource.Path = "999", "/admin/pages/999" + resource.PageID, resource.Path = "999", configs[i].PagesPath+"/999" if policy.AllowResource(ctx, loaded, resource) != agentidentity.ErrDenied { t.Fatal("other tenant/page ceiling admitted") } @@ -495,12 +494,12 @@ func TestPostgresOwnerListAndResourcePolicyApplyCurrentTenantAuthority(t *testin if err != nil { t.Fatal("durable management fixture service failed") } - handler, err := service.ManagementHandler(agentidentity.ManagementOptions{Path: "/admin/agent-grants", OwnerOrigin: "https://owner.example.test", Owners: owners}) + handler, err := service.ManagementHandler(agentidentity.ManagementOptions{Path: fixtureManagementPath, OwnerOrigin: "https://owner.example.test", Owners: owners}) if err != nil { t.Fatal("management fixture handler failed") } for _, owner := range []string{"current-one", "current-two"} { - r := httptest.NewRequest("GET", "https://owner.example.test/admin/agent-grants", nil) + r := httptest.NewRequest("GET", "https://owner.example.test"+fixtureManagementPath, nil) r = r.WithContext(context.WithValue(r.Context(), ownerContextKey{}, agentidentity.Owner{ID: owner})) w := httptest.NewRecorder() handler.ServeHTTP(w, r) @@ -510,7 +509,7 @@ func TestPostgresOwnerListAndResourcePolicyApplyCurrentTenantAuthority(t *testin } } // Request metadata cannot substitute for the verified owner context. - r := httptest.NewRequest("GET", "https://owner.example.test/admin/agent-grants", nil) + r := httptest.NewRequest("GET", "https://owner.example.test"+fixtureManagementPath, nil) r.Header.Set("X-Owner-ID", "current-one") w := httptest.NewRecorder() handler.ServeHTTP(w, r) @@ -541,7 +540,6 @@ type fixtureAccessClaims struct { } func TestPostgresServiceExchangeAuthorizeAcrossTenantsAndRevocations(t *testing.T) { - f := newPGFixture(t) ctx := context.Background() issuerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -553,16 +551,16 @@ func TestPostgresServiceExchangeAuthorizeAcrossTenantsAndRevocations(t *testing. } one, two := fixtureGrant(t, "service-one", 501), fixtureGrant(t, "service-two", 502) one.PublicKey, two.PublicKey = &workload.PublicKey, &workload.PublicKey - configs := []agentidentity.TenantConfig{} + configs := fixtureTenantConfigs(one, two) + policy, err := agentidentity.NewTenantPolicy(configs) + if err != nil { + t.Fatal("service tenant fixture policy failed before database skip") + } + f := newPGFixture(t) for _, g := range []agentidentity.Grant{one, two} { if f.store.CreateGrant(ctx, g) != nil { t.Fatal("service grant fixture create failed") } - configs = append(configs, agentidentity.TenantConfig{TenantID: g.TenantID, APIAudience: g.APIAudience, PagesPath: "/admin/pages", HistoryPath: "/admin/pages/history", Scopes: g.Scopes, UpdatePageIDs: g.UpdatePageIDs, Preview: g.Preview}) - } - policy, err := agentidentity.NewTenantPolicy(configs) - if err != nil { - t.Fatal("service tenant fixture policy failed") } const endpoint = "https://issuer.example.test/agent/token" issuer := agentidentity.IssuerFunc(func(_ context.Context, r agentidentity.IssueRequest) (string, error) { @@ -614,7 +612,7 @@ func TestPostgresServiceExchangeAuthorizeAcrossTenantsAndRevocations(t *testing. if i == 1 { raw = second.AccessToken } - resource := agentidentity.Resource{TenantID: g.TenantID, Audience: g.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: "/admin/pages"} + resource := agentidentity.Resource{TenantID: g.TenantID, Audience: g.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: configs[i].PagesPath} principal, err := newService().Authorize(ctx, raw, resource) if err != nil || principal.TenantID != g.TenantID { t.Fatal("durable current tenant service authorization failed") @@ -631,13 +629,13 @@ func TestPostgresServiceExchangeAuthorizeAcrossTenantsAndRevocations(t *testing. if f.store.RevokeToken(ctx, firstPrincipal.JTIHash, "current-owner", now, now.Add(time.Minute)) != nil { t.Fatal("service token revocation fixture failed") } - if _, err := newService().Authorize(ctx, first.AccessToken, agentidentity.Resource{TenantID: one.TenantID, Audience: one.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: "/admin/pages"}); err != agentidentity.ErrDenied { + if _, err := newService().Authorize(ctx, first.AccessToken, agentidentity.Resource{TenantID: one.TenantID, Audience: one.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: configs[0].PagesPath}); err != agentidentity.ErrDenied { t.Fatal("durable revoked token admitted after new service/pool") } if f.store.RevokeGrant(ctx, two.ID, 1, "current-owner", time.Now()) != nil { t.Fatal("service grant revoke fixture failed") } - if _, err := newService().Authorize(ctx, second.AccessToken, agentidentity.Resource{TenantID: two.TenantID, Audience: two.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: "/admin/pages"}); err != agentidentity.ErrDenied { + if _, err := newService().Authorize(ctx, second.AccessToken, agentidentity.Resource{TenantID: two.TenantID, Audience: two.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: configs[1].PagesPath}); err != agentidentity.ErrDenied { t.Fatal("durable revoked grant token admitted after new service/pool") } } diff --git a/agentidentity/postgres/store_test.go b/agentidentity/postgres/store_test.go index e2c85e1..31065ad 100644 --- a/agentidentity/postgres/store_test.go +++ b/agentidentity/postgres/store_test.go @@ -10,6 +10,8 @@ import ( "database/sql/driver" "encoding/hex" "errors" + "fmt" + "net/http" "strings" "sync/atomic" "testing" @@ -20,6 +22,8 @@ import ( const syntheticLeak = "postgres://synthetic-secret@example.invalid/db Bearer synthetic-token -----BEGIN PRIVATE KEY-----" +const fixtureManagementPath = "/api/v1/admin/agent-grants" + var driverOpens atomic.Int64 var driverCalls atomic.Int64 @@ -69,6 +73,63 @@ func fixtureHash(label string) string { return hex.EncodeToString(h[:]) } +// Source tests and PG tests use the same declarative routes. No database or +// environment-dependent skip precedes this configuration's source check. +func fixtureTenantConfigs(grants ...agentidentity.Grant) []agentidentity.TenantConfig { + configs := make([]agentidentity.TenantConfig, 0, len(grants)) + for _, g := range grants { + pages := fmt.Sprintf("/api/v1/admin/tenants/%d/pages", g.TenantID) + configs = append(configs, agentidentity.TenantConfig{TenantID: g.TenantID, APIAudience: g.APIAudience, PagesPath: pages, HistoryPath: pages + "/history", Scopes: g.Scopes, UpdatePageIDs: g.UpdatePageIDs, Preview: g.Preview}) + } + return configs +} + +type fixtureNoOpOwnerPolicy struct{} + +func (fixtureNoOpOwnerPolicy) ResolveOwner(*http.Request) (agentidentity.Owner, error) { + return agentidentity.Owner{}, agentidentity.ErrDenied +} +func (fixtureNoOpOwnerPolicy) AuthorizeOwner(context.Context, agentidentity.Owner, string, agentidentity.Grant) error { + return agentidentity.ErrDenied +} + +func TestPGFixtureTenantAndManagementConfigurationWithoutDatabase(t *testing.T) { + one, two := fixtureGrant(t, "source-one", 501), fixtureGrant(t, "source-two", 502) + two.UpdatePageIDs = []string{"20"} + two.Preview = &agentidentity.PreviewGrant{Audience: "https://second.example.test/audience", Origin: "https://second.example.test", Paths: []string{"/", "/music"}} + configs := fixtureTenantConfigs(one, two) + policy, err := agentidentity.NewTenantPolicy(configs) + if err != nil { + t.Fatal("shared PG fixture tenant configuration invalid before database skip") + } + for i, g := range []agentidentity.Grant{one, two} { + if policy.AllowGrant(context.Background(), g) != nil { + t.Fatal("shared PG fixture grant ceiling invalid") + } + resource := agentidentity.Resource{TenantID: g.TenantID, Audience: g.APIAudience, Scope: agentidentity.PagesRead, Method: "GET", Path: configs[i].PagesPath} + if policy.AllowResource(context.Background(), g, resource) != nil { + t.Fatal("shared PG fixture route invalid") + } + } + db, err := sql.Open("agentidentity-postgres-redaction-fixture", "synthetic-unused") + if err != nil { + t.Fatal("driver fixture unavailable") + } + defer db.Close() + store, _ := New(db, "agent_fixture") + before := driverOpens.Load() + service, err := agentidentity.NewService(agentidentity.Options{IssuerURL: "https://issuer.example.test", TokenEndpoint: "https://issuer.example.test/agent/token", IssuerKeys: map[string]*ecdsa.PublicKey{"issuer-kid": one.PublicKey}, Store: store, Audit: store, Policy: policy, Issuer: agentidentity.IssuerFunc(func(context.Context, agentidentity.IssueRequest) (string, error) { return "", agentidentity.ErrDenied })}) + if err != nil { + t.Fatal("shared PG fixture service configuration invalid") + } + if _, err := service.ManagementHandler(agentidentity.ManagementOptions{Path: fixtureManagementPath, OwnerOrigin: "https://owner.example.test", Owners: fixtureNoOpOwnerPolicy{}}); err != nil { + t.Fatal("shared PG fixture management path invalid") + } + if driverOpens.Load() != before { + t.Fatal("fixture configuration source check performed database I/O") + } +} + func TestConstructorHasNoIOAndValidatesNamespace(t *testing.T) { db, err := sql.Open("agentidentity-postgres-redaction-fixture", "synthetic-unused") if err != nil { From 77fa16d69f1f195ffe03701515fa81b57195b146 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 9 Oct 2026 14:17:26 -0400 Subject: [PATCH 08/10] fix: honor wrapped preview policy in owner agent UI --- agentidentity/http.go | 19 ++++- agentidentity/http_test.go | 147 +++++++++++++++++++++++++++++++++++ docs/agentidentity/README.md | 8 ++ 3 files changed, 171 insertions(+), 3 deletions(-) diff --git a/agentidentity/http.go b/agentidentity/http.go index e7bd3d5..17e80fb 100644 --- a/agentidentity/http.go +++ b/agentidentity/http.go @@ -71,6 +71,11 @@ type ManagementOptions struct { OwnerOrigin string PagePath string Owners OwnerPolicy + // Tenants supplies immutable owner-UI declarations when Policy is wrapped + // by a host's current preview gate. It confers no authority: every visible + // proposal must also pass the service's required Policy and OwnerPolicy. + // If omitted, the UI uses Policy directly when it is a *TenantPolicy. + Tenants *TenantPolicy } type proposal struct { @@ -124,14 +129,18 @@ func (s *Service) ManagementHandler(o ManagementOptions) (http.Handler, error) { w.WriteHeader(http.StatusMethodNotAllowed) return } - policy, ok := s.options.Policy.(*TenantPolicy) - if !ok { + policy := o.Tenants + if policy == nil { + policy, _ = s.options.Policy.(*TenantPolicy) + } + if policy == nil { httpError(w, ErrUnavailable) return } visible := []TenantConfig{} for _, c := range policy.configs() { - if o.Owners.AuthorizeOwner(r.Context(), owner, "propose", Grant{TenantID: c.TenantID, APIAudience: c.APIAudience}) == nil { + proposed := Grant{TenantID: c.TenantID, APIAudience: c.APIAudience} + if o.Owners.AuthorizeOwner(r.Context(), owner, "propose", proposed) == nil && s.options.Policy.AllowGrant(r.Context(), proposed) == nil { visible = append(visible, c) } } @@ -211,6 +220,10 @@ func (s *Service) ManagementHandler(o ManagementOptions) (http.Handler, error) { httpError(w, ErrDenied) return } + if err = s.options.Policy.AllowGrant(r.Context(), g); err != nil { + httpError(w, ErrDenied) + return + } if err = s.record(r.Context(), Audit{Operation: "grant-revoke", Decision: "allow", Reason: "approved", GrantID: g.ID, GrantVersion: g.Version, TenantID: g.TenantID, OwnerID: owner.ID, Subject: g.Subject}); err != nil { httpError(w, err) return diff --git a/agentidentity/http_test.go b/agentidentity/http_test.go index 00bdb86..3bce97c 100644 --- a/agentidentity/http_test.go +++ b/agentidentity/http_test.go @@ -230,3 +230,150 @@ func TestOwnerPageUsesConfiguredTenantsAndExistingSession(t *testing.T) { t.Fatal("anonymous owner page admitted") } } + +// The declarations remain fixed while the host's current domain authority can +// change independently. UI declarations must never replace this policy. +type currentPreviewPolicyFixture struct { + Policy + live map[int64]bool + unavailable bool + proposed []Grant +} + +func (p *currentPreviewPolicyFixture) AllowGrant(ctx context.Context, g Grant) error { + p.proposed = append(p.proposed, g) + if p.unavailable { + return ErrUnavailable + } + if p.live[g.TenantID] { + return ErrDenied + } + return p.Policy.AllowGrant(ctx, g) +} + +func (p *currentPreviewPolicyFixture) AllowResource(ctx context.Context, g Grant, r Resource) error { + if err := p.AllowGrant(ctx, g); err != nil { + return err + } + return p.Policy.AllowResource(ctx, g, r) +} + +type tenantOwnerFixture struct { + ownerFixture + allowed map[int64]bool +} + +func (p *tenantOwnerFixture) AuthorizeOwner(_ context.Context, o Owner, _ string, g Grant) error { + if p.deny || o.ID != "verified-human-owner" || (g.TenantID != 0 && !p.allowed[g.TenantID]) { + return ErrDenied + } + return nil +} + +func TestOwnerPageUsesDeclarationsThroughCurrentWrappedPolicy(t *testing.T) { + f := setup(t) + tenants, err := NewTenantPolicy(tenantConfigs()) + if err != nil { + t.Fatal(err) + } + gate := ¤tPreviewPolicyFixture{Policy: tenants, live: map[int64]bool{345: true}} + opts := f.s.options + opts.Policy = gate + s, err := NewService(opts) + if err != nil { + t.Fatal(err) + } + owners := &tenantOwnerFixture{allowed: map[int64]bool{234: true, 345: true}} + management := ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", PagePath: "/admin/agent-access", Owners: owners, Tenants: tenants} + h, err := s.ManagementHandler(management) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest("GET", "https://owner.example.test/admin/agent-access", nil) + r.Header.Set("Cookie", "existing-human-session=test-fixture") + check := func(handler http.Handler, status int, visible, hidden string) { + t.Helper() + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != status || (visible != "" && !strings.Contains(w.Body.String(), visible)) || (hidden != "" && strings.Contains(w.Body.String(), hidden)) { + t.Fatal("declarations bypassed current policy or owner authority", w.Code) + } + } + check(h, http.StatusOK, testAPI, "https://other-preview.example.test/api") + if len(gate.proposed) != 2 { + t.Fatal("both declared proposals must consume the wrapped policy") + } + for _, g := range gate.proposed { + if g.TenantID == 0 || g.APIAudience == "" || g.ID != "" || g.Subject != "" || len(g.Scopes) != 0 || g.Preview != nil { + t.Fatal("owner UI did not use a minimal tenant/audience proposal") + } + } + gate.live[234] = true + delete(gate.live, 345) + check(h, http.StatusOK, "https://other-preview.example.test/api", testAPI) + owners.allowed[345] = false + check(h, http.StatusUnauthorized, "", "https://other-preview.example.test/api") + owners.allowed[345] = true + gate.unavailable = true + check(h, http.StatusUnauthorized, "", testAPI) + management.Tenants = nil + unavailable, err := s.ManagementHandler(management) + if err != nil { + t.Fatal(err) + } + check(unavailable, http.StatusServiceUnavailable, "", testAPI) +} + +func TestOwnerManagementAndAuthorizationRetainWrappedPolicy(t *testing.T) { + f := setup(t) + tenants, err := NewTenantPolicy(tenantConfigs()) + if err != nil { + t.Fatal(err) + } + gate := ¤tPreviewPolicyFixture{Policy: tenants, live: map[int64]bool{}} + opts := f.s.options + opts.Policy = gate + s, err := NewService(opts) + if err != nil { + t.Fatal(err) + } + h, err := s.ManagementHandler(ManagementOptions{Path: "/api/v1/admin/auth/agent-grants", OwnerOrigin: "https://owner.example.test", PagePath: "/admin/agent-access", Owners: &tenantOwnerFixture{allowed: map[int64]bool{234: true, 345: true}}, Tenants: tenants}) + if err != nil { + t.Fatal(err) + } + g := f.store.grant + token, err := s.Exchange(context.Background(), ExchangeRequest{GrantID: g.ID, Assertion: f.assertion(t, "wrapped-policy-before", nil), Audience: testAPI, Scopes: []string{PagesRead}}) + if err != nil { + t.Fatal(err) + } + gate.live[234] = true + list := httptest.NewRequest("GET", "https://owner.example.test/api/v1/admin/auth/agent-grants", nil) + list.Header.Set("Cookie", "existing-human-session=test-fixture") + w := httptest.NewRecorder() + h.ServeHTTP(w, list) + if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != "[]" { + t.Fatal("inventory exposed a currently live tenant") + } + for _, r := range []*http.Request{ + manageRequest("/api/v1/admin/auth/agent-grants", publicProposal(t, f)), + manageRequest("/api/v1/admin/auth/agent-grants/revoke", map[string]any{"id": g.ID, "expected_version": g.Version, "confirmed": true}), + } { + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized || f.store.grants != nil { + t.Fatal("management bypassed the current wrapped policy", w.Code) + } + } + if _, err := s.Authorize(context.Background(), token.AccessToken, apiRead()); err != ErrDenied { + t.Fatal("issued token bypassed the current domain gate", err) + } + if _, err := s.Exchange(context.Background(), ExchangeRequest{GrantID: g.ID, Assertion: f.assertion(t, "wrapped-policy-after", nil), Audience: testAPI, Scopes: []string{PagesRead}}); err != ErrDenied { + t.Fatal("exchange bypassed the current domain gate", err) + } + delete(gate.live, 234) + w = httptest.NewRecorder() + h.ServeHTTP(w, manageRequest("/api/v1/admin/auth/agent-grants/revoke", map[string]any{"id": g.ID, "expected_version": g.Version, "confirmed": true})) + if w.Code != http.StatusNoContent { + t.Fatal("approved owner revoke denied after current gate reopened", w.Code) + } +} diff --git a/docs/agentidentity/README.md b/docs/agentidentity/README.md index 125f2c1..0d42fd8 100644 --- a/docs/agentidentity/README.md +++ b/docs/agentidentity/README.md @@ -77,6 +77,11 @@ revocation, never silent key generation. This package is not another issuer/IDP. for current tenant authority. POSTs require exact Origin/Host and same-origin fetch metadata; agent bearers cannot be projected into the human resolver. Ordinary owner cookies stay in the browser and are never exported to an agent. + Hosts wrapping the required service `Policy` with current preview-domain + checks can supply `ManagementOptions.Tenants` for immutable UI declarations. + Each displayed tenant/audience proposal still passes that wrapped policy and + owner authorization. If omitted, declarations come from `Policy` when it is a + `*TenantPolicy`; this option never replaces the service's authority. 3. Select a configured preview, stable assistant subject and registered public workload key. Approve finite expiry and permissions. Read/history are selected initially; body edits and protected viewing are separate unchecked options. @@ -89,6 +94,9 @@ revocation, never silent key generation. This package is not another issuer/IDP. 5. Revoke from the same page. The durable store checks the loaded grant version and records the change transactionally. Rotation uses a separately approved public key/new grant and revokes the old grant; there is no implicit renewal. + Revocation also requires the current service policy. If its preview gate is + closed, owner cleanup requires a current approved preview declaration or a + separately approved offline cleanup; the handler never bypasses that gate. For an approved local workload, existing `wfctl secrets set --provider keychain --service ` offers hidden entry, and `--from-file` From 05097c9d8967dc81a975f8f2b3c0764c25fe5c34 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 10 Oct 2026 16:28:27 -0400 Subject: [PATCH 09/10] fix: wrap long scope boundaries in agent owner UI --- agentidentity/ui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentidentity/ui.go b/agentidentity/ui.go index 5ab71db..2f5a859 100644 --- a/agentidentity/ui.go +++ b/agentidentity/ui.go @@ -17,7 +17,7 @@ func managementHTML(o ManagementOptions, tenants []TenantConfig) ([]byte, error) } const managementTemplate = `Agent access - +

      Agent access

      Approve a separate assistant for a preview site. Access expires automatically and can be revoked here. Your personal account and login stay separate.

      Approve access

      From 7971f869c9dfadf39f9cc29c02d672c89dfa4438 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 10 Oct 2026 17:04:43 -0400 Subject: [PATCH 10/10] feat(agentidentity): bind exact preview query targets to current grants --- agentidentity/policy.go | 7 +- agentidentity/preview_target.go | 53 ++++++++++++++ agentidentity/preview_target_test.go | 106 +++++++++++++++++++++++++++ agentidentity/service.go | 9 ++- agentidentity/types.go | 11 ++- docs/agentidentity/README.md | 13 +++- 6 files changed, 188 insertions(+), 11 deletions(-) create mode 100644 agentidentity/preview_target.go create mode 100644 agentidentity/preview_target_test.go diff --git a/agentidentity/policy.go b/agentidentity/policy.go index c710a04..b9dc21d 100644 --- a/agentidentity/policy.go +++ b/agentidentity/policy.go @@ -68,7 +68,7 @@ func NewTenantPolicy(config []TenantConfig) (*TenantPolicy, error) { } seen = map[string]bool{} for _, route := range c.Preview.Paths { - if !previewPath(route) || seen[route] { + if !previewTarget(route) || seen[route] { return nil, ErrUnavailable } seen[route] = true @@ -122,7 +122,8 @@ func (p *TenantPolicy) AllowResource(ctx context.Context, g Grant, r Resource) e } c := p.tenants[g.TenantID] if r.Scope == PreviewRead { - if c.Preview == nil || r.Origin != c.Preview.Origin || !slices.Contains(c.Preview.Paths, r.Path) { + target, valid := previewResourceTarget(r) + if !valid || c.Preview == nil || r.Origin != c.Preview.Origin || !slices.Contains(c.Preview.Paths, target) { return ErrDenied } return nil @@ -130,7 +131,7 @@ func (p *TenantPolicy) AllowResource(ctx context.Context, g Grant, r Resource) e // Resource paths must come from canonical host route resolution. Reject // encoded aliases, trailing slashes, query strings and accidental auth/admin // routes even if a host misclassifies one as a page-read permission. - if strings.ContainsAny(r.Path, "%?#\\") || path.Clean(r.Path) != r.Path { + if r.Query != "" || strings.ContainsAny(r.Path, "%?#\\") || path.Clean(r.Path) != r.Path { return ErrDenied } switch r.Scope { diff --git a/agentidentity/preview_target.go b/agentidentity/preview_target.go new file mode 100644 index 0000000..c13b8b6 --- /dev/null +++ b/agentidentity/preview_target.go @@ -0,0 +1,53 @@ +package agentidentity + +import "strings" + +// Query targets remain finite exact entries in both the current grant and the +// host's tenant declaration. This syntax bound prevents encoded/duplicate +// aliases; it does not grant every query on an otherwise allowed path. +func previewTarget(target string) bool { + pathname, query, queried := strings.Cut(target, "?") + return len(target) <= 2048 && previewPath(pathname) && (!queried || canonicalPreviewQuery(query)) +} + +func previewResourceTarget(r Resource) (string, bool) { + if !previewPath(r.Path) { + return "", false + } + target := r.Path + if r.Query != "" { + target += "?" + r.Query + } + return target, previewTarget(target) +} + +func canonicalPreviewQuery(query string) bool { + if query == "" || len(query) > 512 { + return false + } + pairs := strings.Split(query, "&") + if len(pairs) > 8 { + return false + } + previous := "" + for _, pair := range pairs { + key, value, found := strings.Cut(pair, "=") + if !found || !previewQueryAtom(key, 64) || !previewQueryAtom(value, 128) || key <= previous { + return false + } + previous = key + } + return true +} + +func previewQueryAtom(value string, max int) bool { + if len(value) == 0 || len(value) > max { + return false + } + for _, c := range value { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || strings.ContainsRune("-._~", c)) { + return false + } + } + return true +} diff --git a/agentidentity/preview_target_test.go b/agentidentity/preview_target_test.go new file mode 100644 index 0000000..c390a8e --- /dev/null +++ b/agentidentity/preview_target_test.go @@ -0,0 +1,106 @@ +package agentidentity + +import ( + "context" + "strings" + "testing" +) + +func TestPreviewQueryRequiresExactCurrentGrantAndTenantTarget(t *testing.T) { + f := setup(t) + configs := tenantConfigs() + target := "/contact?interest=voice-study" + configs[0].Preview.Paths = append(configs[0].Preview.Paths, target) + policy, err := NewTenantPolicy(configs) + if err != nil { + t.Fatal(err) + } + f.store.grant.Preview = configs[0].Preview + options := f.s.options + options.Policy = policy + f.s, err = NewService(options) + if err != nil { + t.Fatal(err) + } + out := f.exchange(t, []string{PreviewRead}, testView) + resource := Resource{TenantID: 234, Audience: testView, Scope: PreviewRead, Method: "GET", Origin: configs[0].Preview.Origin, Path: "/contact", Query: "interest=voice-study"} + for _, method := range []string{"GET", "HEAD"} { + resource.Method = method + if _, err := f.s.Authorize(context.Background(), out.AccessToken, resource); err != nil { + t.Fatalf("exact query %s refused: %v", method, err) + } + } + for _, mutate := range []func(*Resource){ + func(r *Resource) { r.Query = "interest=booking" }, + func(r *Resource) { r.Query = "interest=voice-study&other=1" }, + func(r *Resource) { r.Query = "interest=voice-study&interest=voice-study" }, + func(r *Resource) { r.Query = "interest=%76oice-study" }, + func(r *Resource) { r.Query = "interest=voice-study&" }, + func(r *Resource) { r.Query = "interest=voice-study;other=1" }, + func(r *Resource) { r.Path = "/contact?interest=voice-study"; r.Query = "" }, + func(r *Resource) { r.Path = "/admin" }, + func(r *Resource) { r.Method = "POST" }, + func(r *Resource) { r.Origin = configs[1].Preview.Origin }, + func(r *Resource) { r.Audience = configs[1].Preview.Audience }, + func(r *Resource) { r.TenantID = 345 }, + } { + changed := resource + mutate(&changed) + if _, err := f.s.Authorize(context.Background(), out.AccessToken, changed); err != ErrDenied { + t.Fatalf("query ceiling widened: %v", err) + } + } + // A current grant narrowing removes authority from the already-issued token. + f.store.grant.Preview = &PreviewGrant{Audience: testView, Origin: configs[0].Preview.Origin, Paths: []string{"/contact"}} + if _, err := f.s.Authorize(context.Background(), out.AccessToken, resource); err != ErrDenied { + t.Fatal("bare path inherited query authority") + } + if policy.AllowResource(context.Background(), f.store.grant, resource) != nil { + // The host declaration still lists the target; the service's current-grant + // check above is what denies the narrowed persisted grant. + t.Fatal("unchanged host declaration unexpectedly lost its target") + } + other := f.store.grant + other.TenantID, other.APIAudience, other.Preview = 345, configs[1].APIAudience, configs[1].Preview + other.UpdatePageIDs = configs[1].UpdatePageIDs + otherResource := resource + otherResource.TenantID, otherResource.Audience, otherResource.Origin = 345, configs[1].Preview.Audience, configs[1].Preview.Origin + if policy.AllowResource(context.Background(), other, otherResource) != ErrDenied { + t.Fatal("second tenant inherited the first tenant's query declaration") + } + apiFixture := setup(t) + apiToken := apiFixture.exchange(t, []string{PagesRead}, testAPI) + api := apiRead() + if _, err := apiFixture.s.Authorize(context.Background(), apiToken.AccessToken, api); err != nil { + t.Fatal("unchanged API read refused") + } + api.Query = "interest=voice-study" + if _, err := apiFixture.s.Authorize(context.Background(), apiToken.AccessToken, api); err != ErrDenied { + t.Fatal("preview query accepted on API plane") + } +} + +func TestPreviewQueryInvalidDeclarationsAndStoredGrantsRefuse(t *testing.T) { + for _, target := range []string{ + "/contact?", "/contact?interest=", "/contact?interest", "/contact?interest=voice-study&", "/contact?interest=voice-study&interest=voice-study", + "/contact?interest=%76oice-study", "/contact?%69nterest=voice-study", "/contact?interest=voice+study", "/contact?interest=voice-study#x", + "/contact?interest=voice-study;other=1", "/contact?z=1&a=2", "/contact?interest=" + strings.Repeat("a", 129), "/admin?interest=voice-study", + } { + t.Run(target, func(t *testing.T) { + configs := tenantConfigs() + configs[0].Preview.Paths = append(configs[0].Preview.Paths, target) + if _, err := NewTenantPolicy(configs); err != ErrUnavailable { + t.Fatal("invalid query declaration accepted") + } + f := setup(t) + f.store.grant.Preview.Paths = append(f.store.grant.Preview.Paths, target) + if ValidateGrantForStorage(f.store.grant) != ErrDenied { + t.Fatal("invalid persisted query grant accepted") + } + _, err := f.s.Exchange(context.Background(), ExchangeRequest{GrantID: f.store.grant.ID, Assertion: f.assertion(t, "invalid-query-grant", nil), Audience: testView, Scopes: []string{PreviewRead}}) + if err != ErrDenied { + t.Fatal("invalid current query grant issued a token") + } + }) + } +} diff --git a/agentidentity/service.go b/agentidentity/service.go index ad352ff..cd0e4c5 100644 --- a/agentidentity/service.go +++ b/agentidentity/service.go @@ -226,7 +226,7 @@ func validGrant(g Grant, now time.Time) bool { } seen = map[string]bool{} for _, p := range g.Preview.Paths { - if !previewPath(p) || seen[p] { + if !previewTarget(p) || seen[p] { return false } seen[p] = true @@ -259,11 +259,12 @@ func audienceScopes(g Grant, a string, scopes []string) bool { func resourceAllowed(g Grant, r Resource) bool { switch r.Scope { case PagesRead, HistoryRead: - return (r.Method == "GET" || r.Method == "HEAD") && len(r.Fields) == 0 && r.Audience == g.APIAudience && (r.PageID == "" || pageIdentifier(r.PageID)) + return r.Query == "" && (r.Method == "GET" || r.Method == "HEAD") && len(r.Fields) == 0 && r.Audience == g.APIAudience && (r.PageID == "" || pageIdentifier(r.PageID)) case PagesUpdate: - return r.Method == "PUT" && r.RootPage && !r.HasCanonicalBlocks && r.ExpectedVersion > 0 && len(r.Fields) == 1 && r.Fields[0] == "body_html" && slices.Contains(g.UpdatePageIDs, r.PageID) && r.Audience == g.APIAudience + return r.Query == "" && r.Method == "PUT" && r.RootPage && !r.HasCanonicalBlocks && r.ExpectedVersion > 0 && len(r.Fields) == 1 && r.Fields[0] == "body_html" && slices.Contains(g.UpdatePageIDs, r.PageID) && r.Audience == g.APIAudience case PreviewRead: - return g.Preview != nil && r.Audience == g.Preview.Audience && r.Origin == g.Preview.Origin && (r.Method == "GET" || r.Method == "HEAD") && len(r.Fields) == 0 && previewPath(r.Path) && slices.Contains(g.Preview.Paths, r.Path) + target, valid := previewResourceTarget(r) + return valid && g.Preview != nil && r.Audience == g.Preview.Audience && r.Origin == g.Preview.Origin && (r.Method == "GET" || r.Method == "HEAD") && len(r.Fields) == 0 && slices.Contains(g.Preview.Paths, target) } return false } diff --git a/agentidentity/types.go b/agentidentity/types.go index 6fe1a34..63219ce 100644 --- a/agentidentity/types.go +++ b/agentidentity/types.go @@ -28,9 +28,11 @@ var ( ) type PreviewGrant struct { - Audience string `json:"audience"` - Origin string `json:"origin"` - Paths []string `json:"paths"` + Audience string `json:"audience"` + Origin string `json:"origin"` + // Paths are exact canonical request targets, optionally including a finite + // literal query. A bare path never grants its query variants. + Paths []string `json:"paths"` } // Grant is a host-owned durable row. Rotation and any permission edit MUST @@ -177,6 +179,9 @@ type Resource struct { RootPage bool HasCanonicalBlocks bool Method, Origin, Path string + // Query is the raw canonical query of a preview request only. Path stays + // the matched path; API/history query authorization remains host-owned. + Query string } type Principal struct { diff --git a/docs/agentidentity/README.md b/docs/agentidentity/README.md index 0d42fd8..2914a34 100644 --- a/docs/agentidentity/README.md +++ b/docs/agentidentity/README.md @@ -35,7 +35,18 @@ require PUT, the granted root page, exactly `body_html`, positive loaded library cannot manufacture one. Reads cannot become writes, and no create/delete, publish-status, schedule, identity, bootstrap, platform, domain, reload or live authority is supported. Preview access permits exact-origin GET/HEAD only on -declared paths and never forwards a token to another origin. +declared targets and never forwards a token to another origin. + +`PreviewGrant.Paths` contains finite exact canonical request targets. For example, +`/contact?interest=voice-study` must be listed explicitly; `/contact` alone grants +no query variants. Hosts build preview resources with the actual matched +`Resource.Path` and literal raw `Resource.Query`. The complete target must match +both the current persisted grant and the tenant declaration. Targets are bounded +to 2048 bytes, queries to 512 bytes and eight unique sorted keys; names and values +use unreserved ASCII, with no empty values, encoding aliases or fragments. API +and saved-history query handling remains separate and unchanged. The CMS gate +also needs its finite per-tenant query ceiling before a queried target can reach +this service's authorization callback. ## Workflow issuer adapter