Skip to content

spec: v0.1.25.15 — dual-auth on /v1/auth/introspect (tenant-role forward-compat) - #43

Merged
amavashev merged 2 commits into
mainfrom
spec/tenant-introspect-v0.1.25.15
Apr 14, 2026
Merged

amavashev merged 2 commits into
mainfrom
spec/tenant-introspect-v0.1.25.15

Conversation

@amavashev

Copy link
Copy Markdown
Contributor

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

  1. Security block on GET /v1/auth/introspect — gains ApiKeyAuth alongside existing AdminKeyAuth.

  2. AuthIntrospectResponse schema — three additive 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"

    Also surfaces the optional manage_* / view_reservations capability flags that servers have been returning for several releases (pre-existing wire behavior, now formally documented).

  3. NORMATIVE capability-derivation table added to the capabilities schema description:

    Capability True when permissions contains any of
    view_budgets budgets:read · admin:read · admin:budgets:read
    view_policies policies:read · admin:read · admin:policies:read
    view_webhooks webhooks:read · admin:read · admin:webhooks:read
    view_events events:read · admin:read · admin:events:read
    view_tenants admin:tenants:read only (admin-plane)
    view_api_keys admin:apikeys:read only (admin-plane)
    view_audit admin:audit:read only (admin-plane)
    view_overview admin:read only (admin-plane)
    manage_* corresponding *:write permission

    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.

  4. 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)

  • 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 — different feature, different shape.

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 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_load parses cleanly (utf-8)
  • ApiKeyAuth: count increased by 1 (40 → 41)
  • Human review: capability table's admin-plane rows only fire on admin:* perms — default tenant keys can't elevate
  • CI passes

Implementation 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-admin or a future client chooses to implement, there's one source of truth for the rules.

…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.
@amavashev

Copy link
Copy Markdown
Contributor Author

Pushed review-round fixes (6aca3f9): all 6 audit findings applied.

Now in the spec:

  1. capabilities description clarifies required-vs-optional fields (required always present; manage_* / view_reservations may be omitted).
  2. NORMATIVE derivation table gains rows for view_reservations and manage_reservations (reservations don't have a :write wildcard — rule enumerates :list/:create/:commit/:release/:extend).
  3. New NORMATIVE clause decoupling admin-plane capability gating from admin:read/admin:write wildcard semantics. Prevents accidental admin-UI elevation for tenant keys carrying legacy admin:* perms.
  4. scope_filter description fixed (referenced Permission.scope_filter; actual field is on ApiKey).
  5. Endpoint backward-compat prose rewritten — cleanly separates pre-0.1.25.15 servers from 0.1.25.15+ servers opting out of tenant introspect.
  6. 401 response description updated from 'Missing or invalid admin API key' to the v0.1.25.7 dual-auth convention.
  7. (Clarity) Sentence documenting deliberate absence of manage_events/manage_audit/manage_overview.

Gates re-verified:

  • spectral lint: 0 errors (64 pre-existing warnings unchanged)
  • merge script: idempotent
  • YAML parses cleanly

PR now has 2 commits — original additive extension + this hardening commit. Ready for merge.

@amavashev
amavashev merged commit 5650da9 into main Apr 14, 2026
1 check passed
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.
@amavashev
amavashev deleted the spec/tenant-introspect-v0.1.25.15 branch June 18, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant