Repository navigation
spec: v0.1.25.15 — dual-auth on /v1/auth/introspect (tenant-role forward-compat) - #43
Merged
Merged
Conversation
…ard-compat) Additive spec extension reserving the contract for tenant-role introspect. No server implementation required in this revision; no downstream repos touched. Purely forward-compatibility stake so future multi-role clients don't re-litigate the capability shape or drift across implementations. Changes (all additive, zero wire impact on existing admin callers): 1. Security block on GET /v1/auth/introspect gains ApiKeyAuth alongside existing AdminKeyAuth. Same dual-auth pattern used for budgets/policies (#36), reservations (#37), and webhooks (#40). 2. AuthIntrospectResponse schema gains three fields, populated only when auth_type=tenant: - tenant_id (REQUIRED under tenant auth; MUST be absent under admin) - scope_filter (OPTIONAL; non-empty when key has scope restrictions) - auth_type enum gains value "tenant" alongside existing "admin" 3. Also surfaces the optional manage_* / view_reservations capability flags that servers have been returning for several releases (pre- existing wire behavior, now formally documented in the schema). 4. NORMATIVE capability-derivation table added to the capabilities schema description. Servers MUST derive the capabilities map from a tenant key's permissions per the published rules. Guarantees any future server or client produces identical capability maps from identical permission sets. Admin-plane views (view_tenants, view_api_keys, view_audit, view_overview) only fire on admin:* permissions, so default tenant keys cannot accidentally access admin-only pages. 5. Scope-filtered keys documented as NOT altering the capability map — filters constrain which records are visible at each endpoint, not whether a page is accessible. Enforcement stays server-side. Non-goals (explicit to prevent scope creep when implementation lands): - No tenant-scoped /v1/admin/overview (admin-only). - No tenant-scoped /v1/admin/audit/logs (admin-only). - No admin-on-behalf-of introspect of a specific tenant key. Version: editorial 0.1.25.14 -> 0.1.25.15. semantic_base unchanged at 0.1.25.9 (no wire-breaking change). cycles-spec-index.yaml editorial pin bumped to match. Gates: - spectral lint: 0 errors (64 pre-existing warnings unchanged) - merge_specs.py: idempotent (rerun produces no further diff) - yaml.safe_load parses cleanly - ApiKeyAuth count: +1 (41 total, was 40) Implementation impact: none required. Compliant servers continue returning admin-only introspect until they explicitly extend to ApiKeyAuth. Rollout is opt-in; this revision just reserves the shape.
Six audit findings addressed on the tenant-introspect spec extension (pre-merge review of PR #43): 1. Clarify required-vs-optional fields on capabilities map. Previous description said 'all fields are always present' but the PR added 7 optional flags. Now explicitly: required flags always present; manage_* / view_reservations MAY be omitted. 2. Add derivation rules for view_reservations and manage_reservations. Schema declared them but the NORMATIVE table had no rule — a compliant server would have no way to populate them. Reservations don't have a :write wildcard; each action has its own perm (:list, :create, :commit, :release, :extend), so the derivation enumerates them. Admin:read / admin:write cover the admin-auth path. 3. Add explicit NORMATIVE clause preventing admin-plane capability elevation via legacy admin:* permissions on tenant keys. The v0.1.25.7 wildcard semantics (admin:read satisfies any *:read for endpoint access) technically would have enabled view_tenants for a tenant key carrying admin:read. This clause decouples capability gating from endpoint-access wildcards — intentional behavior, now called out explicitly to prevent implementation drift. 4. scope_filter description referenced Permission.scope_filter; the field actually lives on ApiKey. Fixed. 5. Endpoint backward-compatibility prose was awkwardly phrased (v0.1.25.15+ servers 'MAY still reject'). Rewritten to cleanly separate pre-0.1.25.15 (reject with 401) from compliant servers that elect not to implement tenant introspect (SHOULD return 401 too). 6. Endpoint 401 response description still said 'admin API key' after dual-auth was added. Updated to match the v0.1.25.7 dual-auth 401 convention ('API key / admin key') plus the deployment-opt-out note. 7. (Clarity) Added a sentence documenting that no manage_events / manage_audit / manage_overview flags exist by design — read-only surfaces with no write operations to gate. Prevents future confusion without introducing new fields. Gates: - spectral lint: 0 errors (64 pre-existing warnings unchanged) - merge_specs.py: idempotent - YAML parses cleanly No wire-contract change; purely documentation tightening on an already-additive spec revision.
Contributor
Author
|
Pushed review-round fixes (6aca3f9): all 6 audit findings applied. Now in the spec:
Gates re-verified:
PR now has 2 commits — original additive extension + this hardening commit. Ready for merge. |
4 of 6 tasks
amavashev
added a commit
that referenced
this pull request
Jun 14, 2026
Five findings from the APS maintainer on the #43 design comment, all applied to the draft: - Add `signer_authority_failed` disposition (Medium): resolution SUCCEEDED but the key is not authorized — DID↔server_id hash mismatch, no window-covering key, raw-hex key absent from the set, ambiguous/duplicate-kid selection. Kept distinct from `signer_resolution_failed` (fetch failed) and `signature_invalid` (forgery): a successful-fetch-but-unauthorized case is neither. - Redefine `binding_only` vs PR #45 (Medium): now "signature valid, authority not established," subsuming PR #45's `signature_valid`; an `issuer_pin_matched` companion boolean carries the `pinned_issuer` facet. Closes the unpinned-valid gap. - Make the .well-known path explicitly server_id-relative / API-base-relative, NOT RFC 8615 origin (Medium): stated intent + rationale — server_id (path included) is the hash anchor, pure string append avoids the origin-parsing normalization split, and allows co-hosting. - Define exact server_id_hash input bytes (Low): sha256 over the verbatim UTF-8 server_id string, NO URL normalization; producer emits one canonical form. - Add deterministic JWK selection rules (Low): kid uniqueness, duplicate-kid / overlapping-window / multiple-raw-hex-match → authority error; malformed x / missing cycles_nbf_ms → excluded; unparseable set → resolution error. Draft-only; spectral 0 errors, no merged drift.
amavashev
added a commit
that referenced
this pull request
Jun 15, 2026
* spec(evidence): add signer-key resolution (v0.2 additive) to the draft Per the design agreed with APS on cycles-protocol#103 / aeoess#43. Adds the signature-AUTHORITY layer (the (b) half; (a) signature-validity already ships in APS #45), additive over v0.1 so raw-hex signer_did + expected_signer pinning keep working unchanged. - signer_did: promote to TWO valid forms — raw 64-hex pubkey (v0.1) OR did:cycles:<sha256(server_id)>#<kid> (resolvable). - New CyclesEvidenceJwks / CyclesEvidenceJwk schemas: the server_id-relative JWK Set (GET {server_id}/.well-known/cycles-jwks.json), Ed25519 OKP keys with cycles_nbf_ms / cycles_exp_ms / status; MUST include retired keys. - Normative WINDOW selection rule (pick the key whose [nbf,exp) covers issued_at_ms — never "the current key"; that easy impl breaks archival verification), the raw-hex<->JWK x bridge, and the four distinct verify dispositions (authentic / binding_only / signer_resolution_failed / signature_invalid — the latter two MUST stay distinct: a network failure is not a forgery). - Out-of-scope note + spec-index evidence_envelope entry updated to reflect the layer is now specified (moves to cycles-evidence-v0.2.yaml on the normative cut). Draft-only; spectral 0 errors, no merged drift, changelog valid. * spec(evidence): refine signer-key resolution per APS review (aeoess#43) Five findings from the APS maintainer on the #43 design comment, all applied to the draft: - Add `signer_authority_failed` disposition (Medium): resolution SUCCEEDED but the key is not authorized — DID↔server_id hash mismatch, no window-covering key, raw-hex key absent from the set, ambiguous/duplicate-kid selection. Kept distinct from `signer_resolution_failed` (fetch failed) and `signature_invalid` (forgery): a successful-fetch-but-unauthorized case is neither. - Redefine `binding_only` vs PR #45 (Medium): now "signature valid, authority not established," subsuming PR #45's `signature_valid`; an `issuer_pin_matched` companion boolean carries the `pinned_issuer` facet. Closes the unpinned-valid gap. - Make the .well-known path explicitly server_id-relative / API-base-relative, NOT RFC 8615 origin (Medium): stated intent + rationale — server_id (path included) is the hash anchor, pure string append avoids the origin-parsing normalization split, and allows co-hosting. - Define exact server_id_hash input bytes (Low): sha256 over the verbatim UTF-8 server_id string, NO URL normalization; producer emits one canonical form. - Add deterministic JWK selection rules (Low): kid uniqueness, duplicate-kid / overlapping-window / multiple-raw-hex-match → authority error; malformed x / missing cycles_nbf_ms → excluded; unparseable set → resolution error. Draft-only; spectral 0 errors, no merged drift. * spec(evidence): correct pin trust-anchor + OAS 3.1 nullability (aeoess#43) Second APS review pass on the signer-key resolution draft: - High — `issuer_pin_matched` was wrongly equated with APS PR #45's `pinned_issuer`. They are DIFFERENT trust anchors: `issuer_pin_matched` is this (Cycles) verifier pinning the Cycles ENVELOPE signer (`signer_did`); PR #45's `pinned_issuer` pins the APS RECEIPT issuer (the APS object's own `signer`). Reworded to state the Cycles-side anchor and call out the distinction explicitly; dropped the "subsumes pinned_issuer facet" claim. - Medium — OAS 3.1.0 nullability: `cycles_exp_ms` switched from the OAS-3.0 `nullable: true` to JSON-Schema `type: [integer, "null"]` so tooling accepts the documented absent/null. - Low — `alg`: clarified it is OPTIONAL (RFC 7517) but a present-but-non-EdDSA value makes the JWK invalid and excludes it from candidacy, same as a wrong `kty`/`crv`; absent `alg` is fine. Added to both the field and the deterministic-selection rule. - Low — spec-index `evidence_envelope` owns-list said "four verify dispositions"; corrected to five (now naming all of them). Draft-only; spectral 0 errors, no merged drift. * spec(evidence): rename issuer_pin_matched -> signer_pin_matched (aeoess#43) The companion boolean on `binding_only` records a Cycles-side pin of the Cycles ENVELOPE signer (`signer_did`); "issuer" read like APS PR #45's receipt-issuer anchor (a different trust anchor). `signer_pin_matched` is clearer while the text is still draft. No semantic change. * spec(evidence): say "Cycles signer trust" in out-of-scope note (aeoess#43) After the issuer_pin_matched -> signer_pin_matched rename, the v0.1 out-of-scope note still described expected_signer as "pinning for issuer trust", which reintroduces the APS receipt-issuer ambiguity. expected_signer pins the Cycles ENVELOPE signer; reworded accordingly. (The one remaining "issuer" is the deliberate reference to APS PR #45's receipt-issuer anchor.) * spec(evidence): record authority-scope + RFC 8414 rationale for the JWKS path (aeoess#43) APS signed off on keeping the .well-known path API-base-relative. Folding their reasoning into the spec text so the choice reads as deliberate: - Lead with AUTHORITY SCOPE (the decisive reason): the did:cycles hash covers server_id with its path, so origin-rooting would let the host-root principal answer for a signer whose DID only committed to {host}/v1 — a cross-tenant authority broadening on path-multi-tenant hosts. Keeping the JWK Set under the same base holds key authority and identity anchor in one scope. - Keep the determinism reason (pure lexical append, no URL-normalization drift; same rule as the hash input). - Position vs RFC 8414: OAuth AS metadata inserts .well-known host-rooted; OIDC's append-after-path is the interop wart. We take append-after-path on purpose because host-rooted insertion splits metadata authority from the committed path — turns "that's not RFC 8615" into a one-line answer for a standards reviewer. - Note the literal .well-known segment is kept for its "this is metadata, do not route it" signal. Draft-only; spectral 0 errors, no merged drift. * spec(evidence): JWKS additionalProperties:true + tighten resolution-vs-authority wording (aeoess#43) Two APS review findings on #112: - Medium — CyclesEvidenceJwks had additionalProperties:false, contradicting the "RFC 7517 JWK Set" framing. RFC 7517 §5 lets a JWK Set carry extra top-level members and requires ignoring unknown ones (the child CyclesEvidenceJwk already does this). A harmless set-level extension would have spuriously failed schema validation. Set to true with a note. - Low — signer_resolution_failed said "the kid lookup could not be resolved/reached", which an implementer could read as mapping a missing kid in a fetched set to resolution failure — but the deterministic-selection rule already classes that as signer_authority_failed. Reworded: resolution_failed is about OBTAINING the set (DID method / fetch / parse), not searching it; a missing/duplicate kid or no covering key in a parsed set is authority_failed. Draft-only; spectral 0 errors, no merged drift.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Additive spec extension reserving the contract for tenant-role introspect. No server implementation required in this revision; no downstream repos touched. Purely a forward-compatibility stake so future multi-role clients (tenants signing in to the admin dashboard, SDK-embedded dashboards, tenant consoles) don't have to re-litigate the capability shape or drift across implementations.
Follows the same additive dual-auth pattern we've shipped three times this month: budgets/policies (#36), reservations (#37), webhooks (#40).
Changes
Security block on
GET /v1/auth/introspect— gainsApiKeyAuthalongside existingAdminKeyAuth.AuthIntrospectResponseschema — three additive fields, populated only whenauth_type=tenant:tenant_id(REQUIRED under tenant auth; MUST be absent under admin)scope_filter(OPTIONAL; non-empty when key has scope restrictions)auth_typeenum gains value"tenant"alongside existing"admin"Also surfaces the optional
manage_*/view_reservationscapability flags that servers have been returning for several releases (pre-existing wire behavior, now formally documented).NORMATIVE capability-derivation table added to the
capabilitiesschema description:view_budgetsbudgets:read·admin:read·admin:budgets:readview_policiespolicies:read·admin:read·admin:policies:readview_webhookswebhooks:read·admin:read·admin:webhooks:readview_eventsevents:read·admin:read·admin:events:readview_tenantsadmin:tenants:readonly (admin-plane)view_api_keysadmin:apikeys:readonly (admin-plane)view_auditadmin:audit:readonly (admin-plane)view_overviewadmin:readonly (admin-plane)manage_**:writepermissionAdmin-plane views (
view_tenants,view_api_keys,view_audit,view_overview) only fire onadmin:*permissions, so default tenant keys cannot accidentally access admin-only pages.Scope-filtered keys — documented as NOT altering the capability map. Filters constrain which records are visible at each endpoint, not whether a page is accessible. Enforcement stays server-side.
Non-goals (explicit, prevents scope creep when implementation lands)
/v1/admin/overview— admin-only./v1/admin/audit/logs— admin-only.Version
Editorial
0.1.25.14→0.1.25.15.semantic_baseunchanged at0.1.25.9(no wire-breaking change).cycles-spec-index.yamleditorial pin bumped to match.Gates
spectral lint cycles-governance-admin-v0.1.25.yaml --fail-severity=error→ 0 errors (64 pre-existing warnings unchanged)python scripts/merge_specs.py→ idempotent (rerun produces no further diff from committed artifact)yaml.safe_loadparses cleanly (utf-8)ApiKeyAuth:count increased by 1 (40 → 41)admin:*perms — default tenant keys can't elevateImplementation impact
None required. Compliant servers continue returning admin-only introspect until they explicitly extend to
ApiKeyAuth. Pre-0.1.25.15 clients that parse the response as{authenticated, auth_type: "admin", permissions, capabilities}are unaffected — the three new fields are strictly additive.Rollout is opt-in; this revision just reserves the shape. When/if
cycles-server-adminor a future client chooses to implement, there's one source of truth for the rules.