| Field | Value |
|---|---|
| RFC | 0049 |
| Title | A portable role→scope binding (reusing the existing API-key scope grammar) plus a standardized, redaction-safe authorization.decided event, with a normative fail-closed default — so a host's RBAC becomes observable, auditable, and conformance-testable |
| Status | Accepted |
| Author(s) | David Tufts (@davidscotttufts) |
| Created | 2026-05-24 |
| Updated | 2026-05-25 (Draft → Active → Accepted — MyndHyve workflow-runtime advertises the capability live on https://api.myndhyve.ai/.well-known/openwop (curl-verified 2026-05-25) and the cohort conformance scenarios pass against it: @openwop/openwop-conformance@1.6.0, revision workflow-runtime-00211-69w, commit 85275cdf87972e02c2e588cba481415f3e0edb15, 28 PASS / 0 FAIL across RFCs 0045/0046/0047/0048/0049/0051/0052/0053. Implementation per docs/openwop-adoption/0045-0054-cohort-summary.md; promoted per RFCS/0001 §"Promotion to Accepted".) |
| Affects | schemas/capabilities.schema.json (additive authorization.roles advertisement) · schemas/run-event-payloads.schema.json (additive authorization.decided event) · spec/v1/auth.md (role→scope binding; the scope grammar this reuses) · RFC 0009/0010 (feeds the audit-log integrity profile) · SECURITY/invariants.yaml (new authorization-fail-closed invariant) · new conformance scenarios |
| Compatibility | additive |
| Supersedes | — |
| Superseded by | — |
Summary
Bind the RFC 0048 principal's role to scopes (reusing openwop's existing API-key scope grammar in spec/v1/auth.md) and standardize an authorization.decided { principal, action, resource, allowed, reason } event so denials are observable, auditable, and conformance-testable — including a normative fail-closed default: an absent or unseeded role denies. This makes a host's RBAC (which is otherwise invisible above the protocol) a certifiable surface and feeds the existing audit-log integrity profile (RFC 0009/0010).
Motivation
MyndHyve enforces workspace roles (owner/admin/editor/viewer) and CMS RBAC entirely host-side, fail-closed — a cache miss returns { allowed: false }. openwop has a scope vocabulary for API keys (the scope table in spec/v1/auth.md: manifest:read, runs:create, runs:read, runs:cancel, …, matched by the host's authorization layer) but no contract tying roles → scopes → a decision on a run or node. So MyndHyve's RBAC is invisible to the protocol: a federated peer can't tell why a request was denied, and conformance can't assert that an unauthorized principal _is_ denied — or that denial is fail-closed rather than fail-open.
The spec is the right place because authorization decisions are an interop and audit concern: cross-host workflows and the audit-log integrity profile (RFC 0009/0010) need a portable decision shape, and the fail-closed default is exactly the kind of safety invariant that should be hoisted and certified rather than re-implemented per host.
Proposal
§A — Role→scope binding (additive advertisement)
Advertise the host's role catalog in capabilities:
"properties": {
+ "authorization": {
+ "type": "object",
+ "description": "RFC 0049. Maps RFC 0048 principal roles to scopes, reusing the API-key scope grammar (`spec/v1/auth.md`).",
+ "properties": {
+ "supported": { "type": "boolean" },
+ "failClosed": { "type": "boolean", "const": true, "description": "Absent/unseeded role denies. MUST be true — see SECURITY invariant `authorization-fail-closed`." },
+ "roles": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["role", "scopes"],
+ "properties": {
+ "role": { "type": "string", "minLength": 1 },
+ "scopes": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }
+ },
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": ["supported"],
+ "additionalProperties": false
+ }
}
Scope matching reuses the existing API-key scope grammar (a request is authorized when any of the principal's role-derived scopes matches the required scope, including the established per-segment wildcard + verb-implication rules the host's authorization layer already applies to API keys). This RFC adds no new grammar — it reuses what API keys already match against, now sourced from a role instead of a key.
§B — authorization.decided event (additive, redaction-safe)
Add to run-event-payloads.schema.json:
"authorization.decided": {
"type": "object",
"required": ["principal", "action", "resource", "allowed"],
"properties": {
"principal": { "type": "string", "description": "Opaque RFC 0048 principal id — never PII." },
"action": { "type": "string", "description": "The attempted action, e.g. `runs:cancel`." },
"resource": { "type": "string", "description": "The target, e.g. a runId or workflowId." },
"allowed": { "type": "boolean" },
"reason": { "type": "string", "description": "Human-readable, redaction-safe — no credential material." }
}
}
Every decision (allow and deny) MAY be emitted; every deny SHOULD be emitted and SHOULD feed the audit log per the RFC 0009/0010 audit-log integrity profile.
§C — Fail-closed MUST (normative) + SECURITY invariant
An absent or unseeded role MUST deny (allowed: false), never default-allow. Add to SECURITY/invariants.yaml:
- id: authorization-fail-closed
tier: protocol
severity: critical
threat_model: SECURITY/threat-model-secret-leakage.md
tests:
- conformance/src/scenarios/authorization-fail-closed.test.ts
note: |
RFC 0049 §C: hosts advertising `capabilities.authorization.supported` MUST deny when
a principal's role is absent, unseeded, or unresolvable (cache miss ⇒ deny). The
decision MUST NOT default-allow under any error condition. `authorization.failClosed`
is `const: true`.
Compatibility
Additive. New optional capability block; new optional event; no required-field change. Hosts without authorization.supported are unaffected; consumers ignore an authorization.decided event they don't understand. The scope grammar is reused unchanged, so no existing API-key authorization behavior shifts.
Depends on RFC 0048 (the principal the role binds to). Reuses the existing scope vocabulary (spec/v1/auth.md) and the audit profile (RFC 0009/0010).
Conformance
authorization-roles-shape.test.ts— theauthorization.rolesadvertisement validates; scopes are well-formed. (Always runs.)authorization-scope-match.test.ts— a scope-match matrix: each role's derived scopes allow exactly the actions they should and deny the rest. (Gated onauthorization.supported.)authorization-fail-closed.test.ts— an absent/unseeded role denies; a forced resolver error denies (never opens). (Gated; backs §C.)authorization-denial-audited.test.ts— a deny emits anauthorization.decided { allowed: false }and a corresponding audit-log entry. (Gated onauthorization.supported∧ the audit-log profile.)
Alternatives considered
1. Invent a fresh permission DSL for roles. Rejected — openwop already has a scope grammar that API keys match against; a second grammar would drift from the first and double the conformance surface. Roles should resolve _to_ existing scopes. 2. Put the full RBAC policy (role assignments, resource ACLs) on the wire. Rejected — assignment is host-internal state; the protocol's job is the _decision shape_ and the _fail-closed invariant_, not the policy store. (Same boundary RFC 0046 drew for credential storage.) 3. Make denial events optional with no audit link. Rejected — an unauditable denial defeats the enterprise/governance use case (MyndHyve's CMS audit trail). Tying deny to the existing audit profile is the point.
Unresolved questions
1. Role hierarchy / inheritance. admin ⊇ editor ⊇ viewer is common. Should the advertisement express inheritance, or must each role enumerate its full scope set? Start with full enumeration (simpler to certify); add inheritance if an adopter pulls. 2. Resource-scoped roles. A principal might be editor in workspace A and viewer in workspace B. The role binding is per-(principal, workspace) — should the event carry the workspace context explicitly? Likely yes; align with RFC 0048's owner.workspace. Resolve before Active. 3. Allow-event volume. Emitting authorization.decided for every allow could be noisy. Should allows be sampled while denies are always emitted? Operator-tunable; defer the normative stance.
Implementation notes (non-normative)
- Schema diffs (§A, §B) + the invariant (§C) land on
Activepromotion with the conformance scenarios. - Reference-adopter target: MyndHyve's
RBACService(local-mode, fail-closed) andRoleSeedServicebecome the resolver behind the contract; CMS audit events (cms.page.force_published, etc.) map ontoauthorization.decided+ the audit log they already feed.
Acceptance criteria
- [x] Spec text merged (this file).
- [x]
authorizationblock (top-level, per the schema convention) withroles+failClosedincapabilities.schema.json. - [x]
authorization.decidedevent inrun-event-payloads.schema.json(+RunEventTypeenum entry). - [x]
authorization-fail-closedinvariant inSECURITY/invariants.yamlwith its test. - [x] Role→scope binding + scope-grammar reuse documented in
spec/v1/auth.md§"Role-based authorization (RFC 0049)". - [~] Conformance — 2 of 4 landed:
authorization-roles-shape.test.ts(shape, always runs) +authorization-fail-closed.test.ts(fail-closed MUST-NOT, capability-gated,authorization/decideseam soft-skips). The scope-match matrix + denial-audited scenarios are deferred until a host advertisesauthorization. - [x] CHANGELOG entry under
[Unreleased]. - [ ] A non-steward host advertises
authorizationand passes the fail-closed + denial-audited scenarios.
Implementation note (2026-05-25): Capability block (top-level capabilities.authorization, per the schema convention) + authorization.decided event + the authorization-fail-closed invariant + the auth.md §"Role-based authorization" section + the two shape/fail-closed scenarios landed on main. Binds to the RFC 0048 principal/workspace already on main. Status stays Draft.
References
RFCS/0048-tenant-workspace-principal-identity-model.md— theprincipal/workspacethe role binds to (hard dependency).RFCS/0009-production-profile-conformance.md·0010— the audit-log integrity profile denials feed.spec/v1/auth.md— the scope table + API-key scope grammar this RFC reuses.RFCS/0051-approval-deployment-gate-primitive.md— binds approvals to the roles defined here.