ADR-0001: Unified Permission Model
Status
Section titled “Status”Proposed (under review). Date: 2026-07-10.
scope: the generic resource API (/api/v1/resources/...) and
ServiceAccount/Controller token issuance (the token subresource). It does
not change how rise project create, rise deployment create, or other
typed-table-backed CLI commands work; those converge onto this model
automatically once their tables migrate onto the generic resource store, which
is separate, already-planned work (ROADMAP.md §4, Typed-object migration).
Context
Section titled “Context”Rise today has several disjoint authorization mechanisms, each with its own
code path. The generic resource API is operator-only: access is gated on
membership in the auth.operator_users config allowlist, with no finer
granularity. The typed APIs (projects, groups, deployments, …) each carry their
own per-endpoint ownership and membership checks, with auth.admin_users
bypassing them wholesale. Controllers authenticate separately and are
authorized for status/finalizer writes through each ResourceDefinition’s
allowed_status_controller_ids allowlist. ServiceAccounts get tokens through
trust-policy-gated token exchange. Five subject populations, five ways of
deciding “may this caller do this.”
Almost none of this surface is load-bearing yet. The generic resource API’s
only production use today is seeding the default Organization resource from
config, and controller authentication is entirely unused. This design
therefore carries no backwards-compatibility constraints on the resource-store
API or on controller authentication: where the model below conflicts with the
current surface, the surface changes — breaking changes are acceptable and
preferred over compatibility shims that would bake in tech debt before there
is anything to be compatible with.
The requirements come from the multi-tenancy split: three distinguishable tiers — platform operator, org admins, org users — with per-org asymmetric restrictions. The operator must be able to impose a restriction on one specific org (a compliance-restricted customer). Within each organization where they are an admin, an org admin has every authority not removed by that platform ceiling; the same User may independently be an admin in several organizations. Org admins must be able to delegate access further, but never beyond their own boundaries. And it should be one runtime-configurable mechanism covering Users, Groups, ServiceAccounts, Controllers, and Operators alike — not five code paths that happen to agree.
The design below was converged through multiple independent adversarial-review rounds; its wording is deliberate, particularly around the security-sensitive edges (label-write gating, token minting, operator bootstrap).
Decision
Section titled “Decision”Every actor in Rise — a person, a group, a CI service account, a controller process, or a platform operator — is a subject. Every subject’s access to every resource is decided the same way, by the same evaluator, regardless of what kind of subject it is.
Resources live in a tree — an Organization contains Projects, and a Project
contains Environments, Deployments, and other children (e.g. Environment
env-prod under Project app in the org acme-corp).
Access is granted by binding a Role (a named bundle of permissions — “can
update Deployments, can read Environments,” built from verbs like
get/update/delete) to a subject, placed at some point in that tree. A
binding’s grant applies to everything at or below where it’s placed, and —
optionally — can be narrowed further to only the resources carrying a specific
label.
A subject’s effective access on a resource is the combination of everything
its applicable bindings grant, minus every applicable Deny retained for
that caller’s tier. Among retained statements, Denial always wins over
allowance — there is no “more specific wins” precedence between ordinary
bindings. Platform Denies apply to everyone except operators; org Denies apply
to ordinary org members and workloads but are ignored by current admins of
that org (§5). This is what lets the platform cap an org’s admins while also
letting those admins impose a lower ceiling on their ordinary population.
After tier filtering, a restriction is just one more Deny folded into the
same combination, never a separate capping step on top of it.
A restriction (“cap”) is itself just a Deny binding, folded into the
same Deny-wins combination as every other binding while retaining its binding
placement as provenance. An operator may place a platform Deny instance-wide
or scope it to one org; an org admin may place an org Deny to narrow ordinary
subjects in that org without narrowing current admins. There is one further
rule, applied only
at the moment of a write: whoever authors a grant — binding a Role to a
subject, or editing a Role’s own definition — must already hold everything they
are handing out. The check compares net before/after EffectivePolicy, so it
also catches authority exposed by removing a Deny or changing admin status;
you cannot hand out authority outside your own current effective boundary.
Every request starts from current authorization facts in the database — no policy snapshot is baked into a token, and any memoization is confined to that one immutable request snapshot (§5), so tightening a cap or narrowing a Role takes effect immediately for everyone relying on it. This is what makes revoking a Role exactly as effective as revoking a token before it expires — but only for the identity the token belongs to: narrowing what a ServiceAccount itself can do immediately narrows every outstanding token for it. Revoking the separate grant that let someone mint that token in the first place does not reach back and affect a token already issued (§7).
Ownership works through this same mechanism, not a separate one. A resource
can carry a label — rise.dev/owner: group:platform or
rise.dev/owner: user:u-01jz… — naming the group or person it
belongs to. The platform ships one built-in rule: whoever that label names
automatically gets an owner-level Role on the resource. That rule is the
entirety of what “ownership” is — the engine itself has no ownership concept;
remove or replace the rule and the word means nothing, or means whatever the
replacement says. Nothing about that
rule is hardcoded to any specific label key — it’s ordinary Role/binding data,
and any organization can replace it with their own version using the exact
same tools they’d use for any other grant. Because relabeling a resource can
silently redirect who holds that owner-level Role, writing such a label goes
through the identical check as writing a binding directly: you can only
redirect access you already hold.
The numbered sections below (§1–§10) are the concrete design this plain-language model compiles down to.
1. Subjects
Section titled “1. Subjects”Five kinds of subject exist, in two groups.
Org-agnostic — User, Controller. A single identity can legitimately hold different roles in multiple different organizations: a person is a member of two customers’ orgs with different access in each; a single Controller process reconciles resources across many orgs. Nothing about the subject’s identifier ties it to one organization. A binding for one of these kinds has a normalized scope (§4) — either one specific org, or a wildcard ("*") meaning “the default for every org this identity touches, unless a more specific binding exists for that org.”
Org-native — Group, ServiceAccount. These exist within exactly one organization by construction: a Group has one owning org, and a ServiceAccount is created and lives directly under an org (serviceaccount:acme-corp/ci-bot) — a sibling of Project in the resource tree, not nested under any one Project. This is deliberate: a ServiceAccount’s reach comes entirely from what it’s bound to via ordinary RoleBindings (§4), which can span any number of Projects/Environments in its org; tying its identity to a single “home” Project would suggest a relationship that has no bearing on what it can actually do, and would couple its effectiveLabels-inherited attribution (§6.1) to whichever Project happened to parent it. The org is baked into the identifier itself. A static binding (a literal, fixed subject — §4) for these kinds either omits an explicit scope (normalized to the subject’s own Organization scope) or, if one is supplied, it must lie within the subject’s own org. In particular, explicit scope: "*" is invalid for a static org-native subject: a wildcard in the bound Role’s kinds still ranges only within the binding’s scope and does not make an org-scoped binding reach root-scoped kinds. A dynamic binding (a subject template — §4, §6.3) has no concrete subject to infer an org from until it is evaluated against a specific resource; §6.3 states how its resolved subject’s org is determined in that case.
Canonical subject identifiers. Authorization never operates on an unparsed string. One shared SubjectId type accepts exactly these concrete forms:
user:<name>controller:<name>group:<org>/<name>serviceaccount:<org>/<name>org:<name>system:authenticatedsystem:operators<org> and <name> use the generic resource API’s canonical resource-name grammar. Empty components, extra / or :, dot segments, query/fragment syntax, and non-canonical encodings are rejected; there is no permissive fallback interpretation. A literal User, Controller, Group, ServiceAccount, or org:<name> in a binding must resolve at write time to an existing Rise resource (including resources created in the same atomic transaction). The two system: forms are virtual and follow the additional authoring restrictions below. Only exact dynamic forms declared by the closed template grammar may contain a marker: kind-fixed templates such as group:${ref.name} and user:${ref.name}, plus the typed ${ref.subject} template defined in §6.1. Arbitrary interpolation syntax is rejected when the binding is written. Dynamic substitution produces a concrete canonical SubjectId and is parsed again at evaluation time, failing closed if invalid. For an org-native template the matched resource supplies <org> as defined in §6.3, so group:${ref.name} on an acme resource resolves to group:acme-corp/<value> rather than to an incomplete literal.
Subject records and their relationships are built-in resources. All persisted identity, membership, and authentication-policy objects use the existing built-in rise.dev/v1alpha1 API group; there is no separate authorization.rise.dev group. The entire rise.dev API group is reserved for platform-owned built-ins, including future kinds; extension ResourceDefinitions must use another group. Placement is fixed by kind:
| Kind | Parent | Purpose |
|---|---|---|
User | root | Stable human identity and non-authoritative profile fields |
UserIdentity | User | One external SSO (issuer, subject) mapping |
Controller | root | Stable org-agnostic workload identity |
ControllerTrustPolicy | Controller | One accepted external issuer/audience/claims policy |
Group | Organization | Org-native group identity |
GroupMembership | Group | One name-bound membership marker for a User |
ServiceAccount | Organization | Stable org-native workload identity |
ServiceAccountTrustPolicy | ServiceAccount | One accepted external issuer/audience/claims policy |
The separate trust-policy kinds are intentional: a generic kind has exactly one declared parent, so one TrustPolicy kind cannot be parented under both Controller and ServiceAccount without violating the store’s exact-parent invariant. Likewise, Group membership is one child resource per edge rather than an array in Group.spec: this avoids whole-object lost updates, gives each membership its own grant-gated lifecycle/audit identity, and supports lookups in both directions. A GroupMembership is an empty marker whose immutable metadata.name must equal the canonical name of an existing User when the membership is created. The ordinary per-parent resource-name uniqueness rule therefore permits at most one edge for a User in a Group. Changing the member is a delete plus create, so adding the replacement User passes the ordinary membership grant gate. Membership itself is boolean for authorization — any descriptive membership role is ordinary metadata and never a second permission system.
Owner references are optional lifecycle metadata, not authorization ownership. Generic resources may carry metadata.ownerReferences: UID-authoritative typed references to other resources that supplement, but never change, the resource’s structural parent or URL. Admission requires each reference’s kind, canonical name, and UID to resolve to the same live resource; garbage collection follows the UID, so deleting and recreating the same name never transfers lifecycle ownership. Deleting an owner initiates finalizer-respecting deletion of its dependents. blockOwnerDeletion defaults to false; when explicitly true, that dependent retains the owner’s aggregate cascade finalizer until it is collected. Owner-reference graphs must be acyclic. Owner references confer no permissions, do not participate in subject expansion, label inheritance, or policy evaluation, and are never inferred or stamped by the generic API.
A GroupMembership may omit owner references, in which case deleting its named User leaves the marker in place and recreating that User name deliberately reactivates the name-bound membership. Alternatively, it may carry exactly one owner reference, which must identify that same User; deleting that User then garbage-collects the membership. Backend-managed JIT or directory synchronization should attach this reference when it owns the membership lifecycle, avoiding zombie membership markers. An operator creating durable policy by hand may omit it intentionally. In both cases the membership’s structural parent remains its Group, so deleting the Group also deletes the membership.
User names are stable generated identifiers, not email addresses. A User gets an immutable, collision-resistant DNS-safe resource name such as u-<lowercase-ulid> (with the store’s ordinary uniqueness constraint as the final authority). The generic resource-name grammar remains unchanged and does not admit @; relaxing it for one identity kind would either weaken every resource path or require kind-specific metadata-name parsing in the core. User.spec may carry presentation fields such as displayName and primaryEmail, but email is mutable, case-sensitive in troublesome ways, and non-unique across issuers, so it is never a subject key and Rise never auto-links accounts by email. UserIdentity.spec carries the authoritative external issuer and subject; that pair is globally unique among live UserIdentity resources. Issuers must already use their canonical URL spelling, including the no-trailing-slash convention; admission rejects aliases instead of silently repairing an authentication identifier. Both kinds also carry a platform-managed spec.active boolean, defaulting to true. An inactive User cannot log in and every already-issued token for that User fails authentication. An inactive UserIdentity blocks login through that exact upstream identity and is excluded from operator-selector matching, but does not invalidate sessions established through the User’s other identities. These fields are governed resource data, but shipped policy does not let Users edit their own identity records. UI/CLI surfaces resolve and display email/name while bindings, references, URLs, and audit records use the canonical User name.
Literal subjects, roleRefs, and Group memberships are deliberately name-bound: deleting and recreating an identity or Role under the same canonical name makes existing policy refer to the replacement, matching Kubernetes RBAC’s name-based references. Credentials add a separate immutable binding. Every persisted User, ServiceAccount, and Controller has a store-assigned UID, and every Rise-issued token carries both the canonical sub and that UID in rise_uid. Authentication requires both values to identify the same live, enabled resource. Recreating serviceaccount:acme/ci may intentionally reactivate name-bound RoleBindings for the replacement, but every token issued for the old UID fails immediately. Dynamic label subjects follow the same name-bound policy semantics. Optional owner references affect lifecycle only: they remain UID-bound and can remove a membership before a same-name User replacement appears, but never change which User a surviving membership names.
Deferred constrained product operation for Project ServiceAccounts. Generic create/delete on rise.dev/ServiceAccount remains ordinary RBAC authority and is shipped only to org admins and operators; it may intentionally exercise the name-bound behavior above and is not implicitly transformed into a second grant-gate rule. A future user-facing Rise operation will preserve today’s “create a ServiceAccount for this Project” flow as a trusted, fixed-shape compound operation authorized against that Project. In one transaction it allocates a fresh collision-resistant canonical ServiceAccount name that the product flow never reuses, stores any friendly requested name as presentation data, creates the Organization-owned ServiceAccount, and creates only the platform-defined Project-scoped bindings and trust-policy data for that flow. The product-created identity does not receive an ownership label that would incidentally expose generic deletion; its lifecycle remains behind the constrained operation. The caller supplies no arbitrary Role or RoleBinding policy and need not hold generic permission to create those resources; instead, the compound operation applies §5’s effective-delta subset check to the resulting ServiceAccount policy and rejects any bundle outside the caller’s current capped EffectivePolicy on that Project. Its paired deletion operation disables or removes the identity and cleans up only the flow-owned authorization data; the retired canonical name remains unavailable to the product flow. These are constraints on the future design; its route, authorization tuple, execution shape, implementation, and conformance case are deferred to ADR-0002/ROADMAP and do not block this ADR’s initial conformance suite.
Trust-policy resources contain public matching configuration (issuer, audience, required claim constraints), never private signing keys or bearer credentials. UserIdentity and workload trust-policy writes are ordinary governed resource writes, with their schemas and the authentication-specific validation in §7 applied before persistence. org:<name>, system:authenticated, and system:operators remain virtual predicates and have no corresponding identity row; an Operator remains an active User with at least one live, active UserIdentity selected by restart-loaded configuration.
Operator is a platform-wide root identity selected by configuration. Each process loads an immutable set of (issuer, subject) pairs from operatorIdentities at startup; it does not resolve those pairs to a frozen UID set. After a credential resolves to User UID U, U is an operator iff U is active and any live, active UserIdentity child of U has an exact pair in that configured set. The configured identity is therefore a lookup selector for the User, not a requirement that the current login used that same identity: logging in through any active secondary UserIdentity already attached to U yields the same User UID and operator status.
Interactive login provides the bootstrap path. After validating an enabled upstream IdP credential, Rise looks up its exact (issuer, subject), including inactive live rows. An inactive mapping, or an active mapping whose parent User is inactive, fails authentication and is never treated as unknown for JIT provisioning. If no live mapping exists, one authentication-plane transaction creates a generated User and that UserIdentity; a unique-index conflict caused by concurrent first logins retries by loading the winning mapping. This includes a pair whose previous mapping was deleted: deletion is unlinking, not durable deactivation, and a later valid login provisions a fresh User and UserIdentity with a fresh UID. Old tokens therefore never revive. Durable disablement uses active: false; operators should normally deactivate Users or identities rather than delete them.
The new User has no org authority by default, but if the new identity pair is in operatorIdentities, the same login immediately qualifies the User as an operator. Consequently deleting a configured mapping revokes the old User’s operator expansion only until the next valid login recreates the pair; durable operator removal requires deactivating that live UserIdentity or removing the configured selector and restarting/draining all instances. This fixed JIT operation is the configuration-rooted exception to ordinary grant-gated identity linking. It never attaches an unknown identity to an existing User: a genuine secondary identity must be linked through an explicit governed flow, and Rise never infers account equivalence from email or profile fields. Once attached, every active identity authenticates as the same active User UID.
Plain OAuth does not define end-user identity claims, so an OAuth-only upstream needs a provider adapter that supplies the same stable issuer/subject identity contract. Email is never an operator selector. The configured selector set is not hot-reloaded: changing it takes effect only after restart and revocation is complete only after every API instance using the old configuration has been drained. UserIdentity rows remain live authorization facts, however; adding, activating, deactivating, or deleting a configured identity under a User changes that User’s operator status on the next request in every running process, subject to the JIT recreation rule above.
What changes is how operator status is expressed: rather than a hardcoded bypass branch in the evaluator, operator status is membership in one reserved subject, system:operators (a system:-prefixed name is reserved for platform-recognized pseudo-subjects, never an ordinary User/Group/ServiceAccount/Controller row). The platform seeds exactly one binding for it:
subject: system:operatorsscope: "*"roleRef: { kind: PlatformRole, name: system-admin } # a PlatformRoleBinding (§3)where PlatformRole/system-admin allows every verb on every main resource and
every registered subresource (§2).
An operator’s request runs through the same evaluation algorithm as anyone else’s (§4 steps 1–3) — no separate code path. One thing is special-cased for any request whose membership expansion (§4 step 1) includes system:operators — i.e. any request by a current operator: the Deny-wins union of steps 1–3 is overridden, so no Deny collected in step 1 can reduce an operator’s effective access. This is load-bearing because a cap is itself a Deny binding (§5): an operator caps every other subject, including org-admins, but an operator’s own request ignores every Deny — otherwise an operator could accidentally lock themselves, and everyone else, out by placing an instance-wide cap that only they can author, with no one above an operator able to fix it. The granter-subset half of the write-time grant gate (§5) needs no special-casing at all: since system:operators always holds every main-resource and subresource permission, any grant an operator hands out trivially satisfies ⊆ their own effective permissions.
system: names are reserved, and system:operators is never a binding target. The system: prefix is reserved, enforced at both subject creation and binding-subject authoring: an unrecognized system:-prefixed subject (anything but the platform-recognized system:operators and system:authenticated) is rejected wherever it appears. Among the recognized names, system:operators may never be named as the subject of an ordinary binding — only the platform-seeded bootstrap binding above targets it; an ordinary RoleBinding or PlatformRoleBinding whose subject is system:operators is rejected at write time. Otherwise an org could author a {Deny: * on *} binding catching operators and lock them out of an org with no in-model recovery — the exact state §1 exists to prevent. (system:authenticated and org:<name> remain ordinary group predicates a binding may target — §4’s list authorization and general org-wide grants use them, independent of §9 — bounded by the recipient boundary below.)
Membership never replaces a caller’s own identity. When User user:u-01jz… makes a request, that remains the caller’s subject regardless of which UserIdentity authenticated it. What differs is membership expansion (§4 step 1): evaluation considers not only bindings targeting the User directly, but also bindings targeting a current Group and, when any live, active UserIdentity child matches restart-loaded operatorIdentities, system:operators. Group and operator membership are live instances of the same expansion rule. Since the only binding targeting system:operators grants system-admin, the User’s combined policy includes every main-resource and registered-subresource permission, unioned with whatever they separately hold. Deactivating or removing the matching UserIdentity revokes that expansion on the next request; changing profile email or logging in through another already-linked identity has no effect. A configuration change additionally requires restart and draining old processes. The default ownership binding (§6.2) similarly resolves either the User directly or one of their Groups; Group-targeted ownership reaches each member through this same expansion.
Group subjects. Two reserved group forms exist beyond persisted Group resources: system:authenticated — every authenticated subject, of any kind — and org:<name> — every subject belonging to that organization: its org-native subjects (Groups, ServiceAccounts) and its User members alike. A User belongs to org:<name> iff they are a current member of at least one Group owned by that org or are directly targeted by a qualifying org-admin RoleBinding (§5). The latter is the bootstrap edge that lets the first administrator govern a newly created organization without inventing a magic Group; it is policy data, not a second general membership mechanism. Removing a User’s last Group tie and last direct admin binding removes that membership live. An ordinary org RoleBinding, including one directly naming user:, is intersected with this boundary, so a group-less, non-admin User receives no org-authored grant. To grant ordinary org-private access, first place the User in a Group. An operator may deliberately reach a group-less User through a scoped PlatformRoleBinding, outside org governance.
SSO/directory synchronization creates and deletes ordinary GroupMembership resources, normally attaching the synchronized User as lifecycle owner so User deletion also removes backend-managed edges. A directory-wide Group is the normal way to make every synchronized User an org member. The synchronizer authenticates with a Rise-issued principal and its membership writes pass the same grant gate as manual writes; mapping an upstream directory Group to an admin-bearing Rise Group therefore requires admin-level delegation authority. ServiceAccount inclusion in org:<name> remains deliberate: org-wide grants must reach CI identities too.
The absolute org:<name> form is useful to an operator binding an org population to a root-scoped resource (§9). Inside an org-parented binding the name is redundant — system:authenticated already clamps to that binding’s org — but the absolute form retains one meaning everywhere and is not overloaded with relative names such as org:admins.
Org bindings target only their own org. An org-parented RoleBinding’s grant to any subject is intersected with live membership in that binding’s own org — a subject receives the grant only while it is a current member of that org. An org-native subject (Group/ServiceAccount) whose baked-in org differs from the binding’s org is provably foreign and contributes no grant; a User subject receives the grant only while a live member of the binding’s org through a Group tie or the direct admin bootstrap edge above (so a user in acme and beta gets an acme-scoped binding’s grant in acme alone, and loses it on leaving acme); system:authenticated inside an org binding auto-clamps to that org’s authenticated members and cannot expose platform-wide. A Controller — org-agnostic, a member of no single org — likewise receives no org-binding grant. These semantically inert bindings remain valid, safe policy data and are surfaced by policy auditing rather than rejected synchronously. PlatformRoleBindings are root-placed and normalize to subjectMembership: Any, allowing deliberate cross-org or platform-wide operator grants. A PlatformRoleBinding may opt back into contextual containment with ResourceOrganization (§4); the seeded ownership binding does so, while RuntimeClass availability and explicit non-member grants use Any as appropriate. This bans cross-org sharing through org bindings by construction and prevents contextual ownership from outliving org membership; a first-class tenant-authorable cross-org sharing primitive is deferred (§10).
The binding is data; the operator predicate is derived — deliberately. system:operators’s grant (the binding above) is a stored row, same table as every other binding. Membership is never stored as a separate RBAC record: evaluation intersects restart-loaded operatorIdentities configuration with the active User’s live, active UserIdentity children. This is forced by the bootstrap problem the Operator concept exists to solve: if the initial relationship required an already-authorized RBAC write, nothing could create the first operator. The configured selector set is the one root of trust originating outside the system Rise governs; UserIdentity rows remain ordinary inspectable resources plus the narrow JIT bootstrap path above.
The binding has no equivalent forcing problem — it’s never granted by anyone at runtime, only seeded once at bootstrap — so it can safely be data, with one refinement. Being immutable through the ordinary write path (§5’s seeded Role-ownership tier: no write path can ever modify it, not even an operator) only protects against mutation through this model’s own API — it says nothing about a bad migration, a restore from an old backup, or direct database access losing the row entirely, outside any write path this model governs. That residual risk is unacceptable for the one subject with no recovery authority above it. Operator status is a property of the requesting caller, not of any one subject row. Whenever a request’s live membership expansion (§4 step 1) includes system:operators, the evaluator yields the complete main-resource and registered-subresource policy for that request unconditionally — it ignores every Deny collected in step 1 from any subject, including a Deny targeting the caller’s own user: identity or any cap binding. No binding can reduce an operator’s effective access. The write-time rejection of bindings that target system:operators (above) remains as defence-in-depth but is not load-bearing on its own: configured selectors can change across restarts and their matching UserIdentity rows can change live after a binding is written, so the guarantee must hold at evaluation regardless of what Deny rows exist. This guarantee is hardcoded in the evaluator — not something solely read from, and therefore losable with, a table row. The row is still materialized alongside that guarantee, purely so the same explain/audit tooling that inspects everyone else’s access can inspect this one too without a special case; if it’s ever found missing or altered outside the write path, that’s healed by re-materializing it, not a live authorization dependency.
This mirrors how Kubernetes actually handles system:masters: a hardcoded superuser check in the authorizer grants it full access with no ClusterRole or ClusterRoleBinding required at all, and, redundantly, an ordinary cluster-admin ClusterRoleBinding also binds the same group to the same power as a stored object — kept self-healing (missing permissions/subjects on default, kubernetes.io/bootstrapping=rbac-defaults-labeled objects are restored automatically) rather than merely immutable. Every other system:-prefixed built-in role (system:node, system:kube-scheduler, etc.) gets only the self-healing-data half, no hardcoded bypass, because losing one of those is recoverable by whoever holds system:masters — the same distinction already drawn above between system-admin (nothing above it, needs the hardcoded guarantee) and org-admin (recoverable by an operator, doesn’t). Kubernetes’ authorization decisions are live on every request in both cases, same as this model’s throughout (§5); what’s actually startup-scoped there is narrower — only the drift-repair of default objects’ stored contents, not authorization itself.
Wildcard resolution. When two bindings target the same (subject, labelSelector-key-if-any) pair — one with scope: "*" and one with a more specific scope — the more specific one replaces the wildcard outright, for that scope — it does not merge with it. “Same subject” for this comparison means the same literal subject, or the same subject template text; a dynamic binding on labelSelector: {key: rise.dev/owner} never collides with one on labelSelector: {key: rise.dev/squad}, even if both use the identical template ${ref.subject} — they are different rules. This comparison is always performed on the binding’s authored subject field exactly as written — literal SubjectId against literal SubjectId, or raw template string against raw template string — never on a resolved value: a literal binding (subject: group:acme-corp/platform) and a dynamic one (subject: group:${ref.name}) never collide with each other, even where the template resolves to that same concrete Group, so a platform-wide dynamic default is never silently discarded just because one particular resource’s resolved subject happens to match some unrelated static binding. Where a labelSelector’s optional value also differs between two otherwise-colliding bindings, replacement is evaluated per-resource, at the same point §4 step 1 collects applicable bindings, not as a blanket scope-wide swap — a value-narrowed selector only matches (and so only competes with and replaces a broader same-key selector for) resources whose label actually equals that value; resources carrying any other value never collect the narrowed binding in step 1, so the broader selector continues to govern them, undiminished. This replacement rule applies to any subject (not only Controller) whenever a wildcard scope is in play, including the dynamic ownership bindings in §6 — and crucially it applies across placement tiers: an org-parented RoleBinding may replace a root-parented PlatformRoleBinding, which is exactly what lets an org override the platform-seeded ownership default (§6.5), whose default is a PlatformRoleBinding. What replacement may never do is subtract a Deny: it preserves every Deny statement the superseded binding carried and may drop only the wildcard binding’s Allow content. That single invariant — not a blanket placement prohibition — is what stops an org from escaping an operator’s platform restriction: a restriction expressed as a Deny survives replacement regardless of who authored the superseding binding, while an all-Allow default (like resource-owner, §6.2) remains freely overridable. It exists to keep “what does this rule resolve to, in this org” a single, unambiguous answer instead of an additive combination of whatever bindings happen to apply — the one place bindings do not simply combine (§4 covers the ordinary, additive case).
Accepted risk. Because replacement is outright rather than merged, an org-specific binding can unintentionally discard everything a wildcard binding provided beyond what the org-specific one restates — e.g. narrowing a shared Controller’s wildcard grant in one org for one reason can silently strip that Controller of unrelated capabilities it still needed there. There is no dry-run/impact-preview step (§5’s cap-tightening risk acceptance applies equally here) — this is an accepted, unmitigated footgun for both cap tightening and wildcard replacement. The footgun is one of capability loss (dropped Allows), not escalation: were replacement to drop the superseded binding’s Deny statements too, losing a Deny would be a privilege gain — the escalation direction, not merely lost capability — which is exactly why replacement preserves Deny (above). A platform-hard restriction is therefore durable whether an operator expresses it as an operator cap Deny (a PlatformRoleBinding no org can remove, and whose Deny survives replacement anyway) or as a Deny statement inside a wildcard binding (preserved through any override); the accepted, unmitigated part is only that an org override can silently drop an operator’s wildcard Allows — a capability-loss footgun, never a restriction-bypass one.
2. Verbs and subresources
Section titled “2. Verbs and subresources”The ordinary verbs are get, list, create, update, and delete — Rise’s
existing action vocabulary — plus use, the right to reference a
resource from another resource’s fields. use is distinct from reading or
editing and is checked at write time of the referencing resource, against its
writer, wherever a ResourceDefinition declares a reference (§9). Granting
get without use makes a catalog browsable but not selectable; use without
get allows selection by name without exposing the referenced object’s
contents. (Precedent: the Kubernetes use verb on PodSecurityPolicies.)
Like Kubernetes, Rise models a secondary operation as an orthogonal
subresource, not by inventing a compound verb. An authorization request is
therefore (verb, ResourceKind, subresource?): status update is
(update, rise.dev/Deployment, status), while delegated ServiceAccount token
issuance is (create, rise.dev/ServiceAccount, token). The main resource has no subresource value.
Permissions never flow implicitly between the two: update on rise.dev/Deployment
does not authorize (update, rise.dev/Deployment, status), and a status grant does not
authorize the main update endpoint. This keeps authorization aligned with the
API route and leaves room for Kubernetes-shaped endpoints such as logs,
scale, and proxy without expanding the verb vocabulary into getLogs,
updateScale, and so on.
A subresource is an API routing and authorization boundary over a parent
resource, not necessarily a second stored object. status is a field in the
parent kind’s schema and is returned by an authorized get of the main object;
/status is the restricted mutation path for that field. token is instead a
create-only operation that returns a credential without persisting a Token
resource. ResourceDefinition declares which named subresources a kind
supports and which shared handler strategy each uses. Requests for undeclared
subresources fail before authorization, and declarations use canonical
lowercase names from a closed platform registry for the initial model:
status, finalizers, and token.
Each registered strategy also declares its supported HTTP/RBAC verbs; for
example, token accepts create but not get or update, while status and
finalizers accept their defined read/update operations. A Role may contain a
broader wildcard, but it can authorize only an operation the registered route
actually serves.
status follows Kubernetes’ split-object semantics. A kind that declares
status still defines and stores status in the one resource envelope. An
authorized main-resource get returns the complete object, including status;
there is no separately stored DeploymentStatus kind. Main-endpoint
POST/PUT/PATCH/apply operations ignore proposed status changes and
preserve the stored value, while writes through /status ignore every change
except status. This makes manifests copied from a read safe to apply without
overwriting controller-owned observations. Status-only writes do not increment
metadata.generation, but they do use the normal schema validation, admission,
resource-version concurrency, persistence, and audit path. A kind without a
declared status subresource has no such field-level separation.
This separation is generic resource-API machinery, not code every handler
reimplements. For a replace, the main strategy restores old status and the
status strategy restores every non-status field; patch/apply exclude protected
fields while calculating the mutation so the caller neither changes nor
acquires field ownership for them. The shared layer then validates and stores
the resulting whole object. A ResourceDefinition may supply an optional
kind-specific validator for legal status transitions, but routing,
authorization, projection, merge behavior, concurrency, and persistence remain
shared. Rise applies the same machinery to /finalizers as a deliberate
hardening extension: finalizers remain part of metadata, but main writes
preserve them and only (update, ResourceKind, finalizers) may change them.
The target model removes ResourceDefinition.allowedStatusControllerIds and
the corresponding collection metadata. A Controller is an authenticated
subject, so its status and finalizers access comes solely from ordinary
RoleBindings over the registered subresource tuples above. The legacy allowlist
remains transitional only until controller requests pass through the unified
authorization choke point; it is not a second authorization mechanism in the
target API.
This ADR standardizes that shared authorization and handler seam, plus
the concrete status, finalizers, and token strategies. Streaming,
connection, proxy, and virtual-projection contracts needed by possible future
logs, proxy, or scale subresources are explored in
ADR-0002. Adding one
later does not change the RBAC algebra: it registers a handler and is authorized
by the same (verb, ResourceKind, subresource) tuple.
3. Roles and the Allow/Deny evaluator
Section titled “3. Roles and the Allow/Deny evaluator”A Role is a named, reusable policy: an order-irrelevant list of statements,
{ effect: Allow | Deny, kinds: ["rise.dev/Deployment"] | ["rise.dev/*"] | "*", verbs: ["update", "delete"] | "*", subresources?: ["status"] | "*" }A ResourceKind is the canonical, version-independent <api-group>/<Kind>
pair: rise.dev/Deployment or widgets.example.com/Widget. An exact value
matches one kind; rise.dev/* matches every Kind in that API group; bare "*"
matches every group and Kind. Versions are absent because all served versions
of one (group, Kind) share authorization. Unqualified Kind strings are
rejected, closing the ambiguity allowed by storage, where different API groups
may legitimately use the same Kind name.
A statement with omitted subresources matches only the main resource. A
statement with subresources: ["status"] matches only that subresource, and
subresources: "*" matches every subresource registered for the matching
kind, but still not the main resource. Consequently, granting * on * does
not silently grant a subresource registered in the future; a Role that truly
needs both writes two statements. The seeded system-admin policy does so:
{ effect: Allow, kinds: "*", verbs: "*" }{ effect: Allow, kinds: "*", verbs: "*", subresources: "*" }A subject’s access on (verb, ResourceKind, subresource?) under a given policy is permitted iff at least one Allow statement matches and no applicable Deny statement matches — Deny wins after §5’s placement-tier filtering. Pure-additive permission sets cannot express subtraction from an open-ended wildcard; a Deny expresses it directly:
Allow: * on *Deny: delete on rise.dev/EnvironmentRoles and RoleBindings are data (rows), not compiled match arms — operators and org admins configure who can do what at runtime, no redeploy required. Restrictions (§5) are Deny-bearing bindings of this same policy shape, not a separate construct: platform placement caps admins and ordinary subjects, while org placement caps only ordinary org subjects. A Role’s own statement list is, like any other resource, ordinarily writable by whoever holds update on its kind — but because editing a widely-bound Role changes what every subject bound to it can do, that write is gated the same way a RoleBinding write is (§5).
Two kind pairs, one per placement level. The resource store’s parent model is exact — a kind declares one parent, not a choice of parents — so policy objects come as two same-shaped pairs, the same fork Kubernetes resolves with ClusterRole/Role: Role and RoleBinding are parented under an Organization (org-level policy, authored by whoever holds create/update there — org-admins by default, further delegable like anything else), while PlatformRole and PlatformRoleBinding are parented at the root (platform-level policy — operator-authored, not by a bespoke rule but because only system:operators holds create at root under ordinary default-deny). Where this document says “binding” or “Role” without qualification, the statement applies to both pairs alike.
4. RoleBindings — targeting a subject to a slice of the resource tree
Section titled “4. RoleBindings — targeting a subject to a slice of the resource tree”A RoleBinding attaches a Role to a subject, at a Scope, optionally narrowed by a labelSelector. Serialized fields are lower camel case:
subject: <literal SubjectId, e.g. group:acme-corp/platform> | <subject template, e.g. group:${ref.name} or ${ref.subject}>subjectMembership: Any | ResourceOrganization # always present after normalizationscope: <path, e.g. rise.dev/Environment/acme-corp/app/env-prod> | "*" # always present after normalizationlabelSelector?: { key: <label key>, value?: <fixed value> } # optional narrowing filterroleRef: { kind: PlatformRole | Role, name: <name> }subject is deliberately singular in the initial model. Giving the same Role to unrelated subjects uses one binding per subject; giving it to a population uses a Group, org:<name>, or system:authenticated. A future subjects: convenience may normalize each entry into an independent virtual binding without changing policy semantics, but storing several subjects in one normative binding is rejected for now. In this model subject identity participates in wildcard replacement, dynamic-template resolution, recipient-boundary validation, grant-gating, and audit explanation; keeping it singular avoids partial replacement or partial mutation semantics that Kubernetes’ additive-only bindings do not have.
subjectMembership is a closed enum whose serialized values are PascalCase. It is accepted only on PlatformRoleBinding; org RoleBindings reject the field because their parent-org recipient boundary is already structural. Omitted platform input normalizes to the persisted value Any; explicit null and unknown values are invalid. Any adds no membership constraint, including for deliberate scoped grants to non-members.
ResourceOrganization adds a constraint only when the target resource is org-contained and the concrete subject being tested is not inherently org-scoped. A User then needs a current Group tie or direct qualifying admin affiliation to that target org. A Controller has no such membership and therefore does not match; controller grants that merely need geographic narrowing use an explicit scope with Any. For ${ref.subject}, the check occurs after resolving the concrete User or Group. For system:authenticated, it applies to the actual requesting principal, so a demo-org read binding can choose Any to include every authenticated principal or ResourceOrganization to include only that org’s members. Group, ServiceAccount, and org:<name> subjects already carry their organization, so the additional constraint is a no-op for them; it is likewise a no-op on root-scoped targets. These no-op combinations are valid policy data. The normalized value is retained in binding provenance and participates in matching, EffectivePolicy, wildcard replacement simulation, the grant gate, request snapshots, and explain output.
The scope field is always present after write-time normalization and establishes the binding’s applicability domain. If omitted on an org-parented RoleBinding, it defaults to that binding’s parent rise.dev/Organization/<name>; if omitted on a root-parented PlatformRoleBinding with a static Group or ServiceAccount subject, it defaults to that subject’s rise.dev/Organization/<name>; in every other PlatformRoleBinding case it defaults to "*". labelSelector, when present, narrows the grant to matching resources inside the Scope. A Role statement’s kinds: "*" means every qualified ResourceKind inside that domain; only scope: "*" reaches both org-contained and root-scoped resources.
A Scope path is a resource URL with only the version segment removed: the target’s API group and Kind first, then ancestor names root-first, then its own name. rise.dev/Environment/acme-corp/app/env-prod identifies Environment env-prod below Project app; rise.dev/Organization/acme-corp identifies the org; and rise.dev/RuntimeClass/standard identifies a root-scoped instance. Thus /rise.dev/v1alpha1/Project/acme/app normalizes to rise.dev/Project/acme/app. The leaf’s qualified ResourceKind deterministically selects its ResourceDefinition; ancestor groups and Kinds are derived from that definition’s parent chain.
Scope is likewise a shared parsed type, never an opaque string. At write time the parser accepts exactly "*" or <api-group>/<Kind>/<names...>; rejects unknown or unqualified ResourceKinds, empty/extra components, dot segments, query/fragment syntax, embedded separators, and non-canonical encodings; resolves exact (group, Kind) through the registry; and verifies following names against the parent chain. The target must exist or be created in the same atomic transaction. The normalized Scope is persisted and compared everywhere.
Two write-time validation rules tie a binding’s placement (§3) to its content. Containment: an org-parented RoleBinding’s scope must lie within its own parent org’s subtree; a root-parented PlatformRoleBinding’s scope is unrestricted — "*", any org path, or a root-scoped instance such as rise.dev/RuntimeClass/gpu-b (§9). This makes “org admins cannot author platform-wide or cross-org grants” structural, not asserted. Reference direction: the structured roleRef names its target with separate kind and name fields — { kind: PlatformRole, name: resource-owner } or { kind: Role, name: deploy-viewer }. An org RoleBinding may reference its own org’s Roles or any PlatformRole (how platform-shipped Roles are bound org-locally without duplication); a PlatformRoleBinding may reference only PlatformRoles — org-authored policy can never escape its org through a platform-wide binding. References are never resolved by bare-name fallback: an org creating a Role named resource-owner shadows nothing, because every existing { kind: PlatformRole, name: resource-owner } reference keeps meaning exactly that.
Static targeting — a fixed subject:
subject: group:acme-corp/platformscope: rise.dev/Environment/acme-corp/app/env-prodroleRef: { kind: Role, name: deployment-editor }subject: group:acme-corp/platformlabelSelector: { key: rise.dev/group, value: "platform" }roleRef: { kind: Role, name: project-editor }(Role names other than resource-owner, §6.2, are illustrative throughout this document — project-editor, deployment-editor, etc. are examples of Roles an operator or org would define, not literal platform-shipped defaults.)
A labelSelector carrying a value pairs naturally with a static Subject — an equality filter on an already-fixed grant, no extraction needed. One without a value pairs with a dynamic Subject — an existence match whose matched value feeds ${ref.name} or ${ref.subject} (below). A static Subject combined with a value-less labelSelector is rejected at write time: it would grant a fixed subject access to any resource carrying any value for that key, regardless of what it says, which is never the intent of a literal, non-templated binding. A dynamic Subject may also use a value-carrying selector: the fixed value is still resolved per matched resource, which matters for org-relative Group references whose canonical organization comes from that resource.
Dynamic targeting — the subject is resolved from the matched label’s own value at evaluation time. A kind-fixed template interpolates a bare resource name:
subject: group:${ref.name}labelSelector: { key: rise.dev/squad }roleRef: { kind: Role, name: project-editor }The typed form instead requires the label value to carry its User-or-Group kind:
subject: ${ref.subject}labelSelector: { key: rise.dev/owner }roleRef: { kind: PlatformRole, name: resource-owner }Evaluating a dynamic binding first resolves the labelSelector against the resource’s effectiveLabels. ${ref.name} substitutes the raw value into its binding-declared kind and then parses the concrete subject. ${ref.subject} parses the value through §6.1’s closed SubjectRef grammar and canonicalizes it against the matched resource’s organization. Both paths finish in the same concrete SubjectId parser used for static bindings and fail closed if resolution is invalid or nonexistent. §6.3 covers org-relative resolution.
Evaluation algorithm, for subject S requesting (verb, ResourceKind, subresource?) on resource r:
- Expand
S’s current Groups and virtual memberships, and determine whetherSis an org admin inr’s org from exactorg-adminRoleBindings (§5). - Collect every binding targeting
S, an expanded Group/virtual subject, or a template resolving to any of those subjects againstr, whose Scope, optionallabelSelector, and normalizedsubjectMembershipmatch. Retain each contribution’s binding UID and platform/org placement tier. - Apply wildcard replacement (§1): suppress superseded wildcard Allow content but preserve all Deny contributions and their provenance.
- Filter Denies by tier (§5): operators ignore all; an org admin ignores org-binding Denies in that org; every other principal retains both tiers.
- Union the surviving statements and permit the qualified tuple iff an Allow matches and no retained Deny matches. Intersect that result with the token authorization-detail ceiling (§7).
A worked trace: group:acme-corp/platform requests delete on rise.dev/Deployment/acme-corp/app/foo, which carries rise.dev/owner: group:platform.
- Collection finds two bindings: (a) a scope binding at
rise.dev/Project/acme-corp/appgranting{Allow: * on rise.dev/Deployment}; (b) the seeded ownership binding, resolving togroup:acme-corp/platform. No wildcard collision or retained Deny applies. - The union allows
deleteonrise.dev/Deployment, and the caller’s token ceiling does not narrow it. - Result: allowed.
Now suppose an operator has separately authored a PlatformRoleBinding at
rise.dev/Project/acme-corp/app with Role
{Deny: delete on rise.dev/Environment}. That statement is unioned into the same
combined policy for Environment resources under that scope. For the Environment
itself, Deny wins and deletion is blocked despite the broader Allow. Narrow
placement alone subtracts nothing; the applicable platform Role must carry the
matching Deny (§5).
Collection (list) authorization and read granularity. get and list are two independently evaluated read granularities. A collection request is rooted at the requested scope node and a Kind; its result contains exactly the items of that Kind under the scope that the caller holds list on, each independently evaluated through the full §4 algorithm (per-item effectiveLabels, wildcard-replacement, Deny-wins union with any applicable cap). It is filtered per-item, never scope-level all-or-nothing. For each included item, the response projector constructs a fresh object from an explicit base-field allowlist: apiVersion, kind, and metadata (name, labels, effectiveLabels, timestamps). It must not implement metadata-only output by deleting known fields such as spec and status, because generic resources may carry arbitrary other top-level fields. If the caller also holds get on that individual item, the projector returns the full stored object, including any kind-specific top-level fields; otherwise it returns only the three allowlisted base fields. This permits the common list-and-inspect path to avoid follow-up get round trips without letting list alone disclose resource data. Items the caller cannot list are omitted and their existence is masked: a caller with no applicable list grant receives a masked-empty result, not a 403 that would confirm the scope is populated (consistent with §6.6/§9 existence-masking). Note the corollary of returning effectiveLabels: because §6.1 resolves every label key nearest-wins down the tree, a list grant exposes an ancestor’s inherited label values (not just rise.dev/owner) on the listed children — org-wide by construction, and same-org only since inheritance never crosses the org boundary. An org that puts sensitive metadata in an ancestor label should not grant broad list beneath it.
This separates existence/owner visibility from data visibility, each grantable independently per RoleBinding: e.g. list on Project granted to system:authenticated at rise.dev/Organization/acme (auto-clamped to acme members by §1’s recipient boundary) lets every acme member see all acme project names and owner labels — resolving name-conflict friction — while get/update/delete stay narrow to owned projects via the ownership binding. Cross-org isolation holds: with no list binding on another org’s collection, that org’s resources are masked entirely. Name-uniqueness is enforced per-parent (an org’s Project names are unique within that org, not globally), so a create-conflict can only reveal a sibling’s existence within a scope the creator can already create in — an intra-scope existence hint, never a cross-org leak (per-parent uniqueness, and no non-operator creates at the root where globally-named kinds live). list and create are independent verbs, so this hint does not depend on the creator holding list.
Adding a subject to a Group is grant-gated. Group membership drives direct grants, org membership, and potentially org-admin classification. Adding User M to Group G therefore computes M’s complete effective before/after delta (§5), including transitive org:<name> grants and any org Denies that cease to apply if G is admin-bound. Otherwise bare Group-update authority would permit self-promotion.
Removing a subject from a Group is not grant-gated. It removes that Group’s grants. If this was the User’s last Group tie and they have no direct qualifying org-admin binding, it also removes every org-parented grant and org-tier Deny because the User is no longer governed as a member of that org. A separate operator-authored Allow may still reach the non-member, but every platform Deny remains applicable to the resource independently of membership. Escaping an org ceiling while retaining a platform grant is therefore an explicit operator-governance case, not authority the departed org may continue to control.
Parents are immutable. A resource cannot be re-parented through this API: a “move” is a delete at the old location plus a create at the new one, each independently gated (§5). There is thus no parent-change write for the grant gate to police — the model relies on the store’s exact, immutable-parent property.
5. Deny tiers, org-admin assignment, and the write-time grant gate
Section titled “5. Deny tiers, org-admin assignment, and the write-time grant gate”Restrictions apply through the same evaluator, but a Deny’s binding placement
determines its tier. The referenced Role kind does not: a Deny reached through a
root-parented PlatformRoleBinding is platform policy, while a Deny reached
through an org-parented RoleBinding is that organization’s policy.
The evaluator retains this provenance until Deny filtering:
- a request whose live expansion includes
system:operatorsignores every Deny; - an org admin in the target resource’s org ignores Denies contributed by that
org’s
RoleBindings, but remains subject to every applicable platform Deny; - every other principal is subject to both platform and applicable org Denies.
This is the tier contract: an organization may impose a lower ceiling on its ordinary Users and workloads without limiting its own administrators; the platform may limit those administrators; operators remain the recovery tier. It is still one algorithm — filter Deny contributions by caller tier and binding provenance, then run the ordinary Allow-and-no-Deny evaluator (§3).
Org-admin status comes from RoleBindings, not a magic Group name.
PlatformRole/org-admin ships with the global baseline:
{ effect: Allow, kinds: "*", verbs: "*" }{ effect: Allow, kinds: "*", verbs: "*", subresources: "*" }The shipped default is Allow-only, but PlatformRole/org-admin uses the
ordinary Role schema rather than a special validator. An operator may edit it
to change the global admin baseline for every organization. A Deny added to it
and delivered through a qualifying org RoleBinding is org-tier and therefore
ignored by the current admin it establishes; it does not form a ceiling.
Per-org or instance-wide admin ceilings remain operator-authored Denies
delivered through PlatformRoleBindings.
A User is an org admin in Organization O iff their live
membership-expanded subjects match an org-parented RoleBinding that:
- is parented under
O; - has exact normalized
scope: rise.dev/Organization/O; - has no
labelSelector; and - has
roleRef: { kind: PlatformRole, name: org-admin }.
This structural predicate is computed before Deny filtering and is not inferred
from the current contents of the Role. The binding may target the User directly
or any ordinary Group. It is therefore possible to assign one person, a
manually managed Group, or an SSO-synchronized directory Group without a
reserved org-admins Group or a second membership system. Listing an org’s
admins means listing these exact bindings and expanding their current Groups.
Organization creation is one atomic operator transaction: create the
Organization and one exact, scope-only RoleBinding from
PlatformRole/org-admin to an operator-selected existing User. Failure of any
part rolls the whole transaction back. Further admins are added by creating
another qualifying RoleBinding or adding a User to a Group already targeted by
one. Removing the last qualifying relationship revokes admin status live;
operator recovery can always create a replacement binding.
Role authority follows placement. The evaluator-guaranteed
PlatformRole/system-admin and its system:operators binding are seeded,
immutable, and healed if missing. Other PlatformRoles — including
resource-owner and org-admin — are root resources editable by principals
holding the corresponding root permission, operators by default. Org Roles are
parented under one Organization and editable there. Every edit that changes
effective authority still passes the grant gate below.
The grant gate compares effective before/after deltas. For every authorization-changing write, compute pointwise over the affected domain:
newly_implied = EffectivePolicy(recipient, after) − EffectivePolicy(recipient, before)
require newly_implied ⊆ EffectivePolicy(writer, before)EffectivePolicy includes membership expansion, org-admin classification,
wildcard replacement, binding-tier Deny filtering, Deny-wins evaluation, and
the principal’s token authorization-detail ceiling (§7). It is the net set of
allowed (verb, ResourceKind, subresource?) tuples over the exact
Scope ∩ labelSelector domain, not the raw Allow body of a Role. This is why
an admin covered by a platform Deny can add another admin covered by the same
Deny: denied tuples appear in neither effective policy and are never delegated.
Conversely, promotion to admin may remove org-tier Denies, so that newly exposed
authority is part of the delta and blocks self-promotion by a narrower writer.
The gate covers creating, deleting, or editing Roles and bindings; changing
subject, subjectMembership, scope, labelSelector, or roleRef; adding
GroupMembership; access-driving label changes (§6.6); and creating or
retargeting identity mappings. Deleting a Deny is a grant because it can enlarge
the effective Allow set. Group removal follows §4’s explicit governance-boundary
rule: losing Group ties removes Group-derived grants and, when no direct admin
bootstrap edge remains, the last tie ends the org’s authority over that
non-member; any surviving operator-authored access remains governed by platform
Denies.
For a Role edit, the before/after calculation spans every live binding of that
Role. For a binding move it spans old and new domains. Creating or retargeting a
UserIdentity, activating a User or UserIdentity, or creating/retargeting a
ControllerTrustPolicy or ServiceAccountTrustPolicy treats the parent
identity’s complete effective policy as newly reachable; external identity
fields and parent references are immutable, so remapping is delete plus create.
Tightening or deleting an identity mapping, including setting active: false,
introduces no authority and needs only ordinary write access. Deletion is
nevertheless not durable disablement for a JIT-managed UserIdentity because a
later valid upstream login may provision the pair again (§1, §7).
The §1/§7 JIT login transaction is narrower: an unknown validated external
identity may create only a new generated User and its first UserIdentity, never
attach itself to an existing User. That empty User has no grants unless the
pair is explicitly trusted by restart-loaded operatorIdentities, which is the
external bootstrap authority. Linking any secondary identity to an existing
User remains subject to the general grant gate above.
The check and mutation are one serializable operation. Every writer that
changes authorization facts — Roles, bindings, GroupMemberships,
identity/trust mappings, or access-driving labels — uses the same Postgres
transaction path at SERIALIZABLE isolation with bounded retry. It re-reads
the writer, recipient, relevant memberships, policies, and before/after
resource state inside the transaction. Predicate reads participate, so a
concurrent revocation, cap change, insertion, or membership write either
precedes the check or forces a retry. Ordinary READ COMMITTED
check-then-write is forbidden; uniqueness constraints remain the final
authority for duplicate mappings and membership edges.
The subset comparison is Deny-aware and scope-exact. It is computed
intensionally over policy domains, never merely over resources that exist now.
A narrow Scope cannot justify a broader grant. Same-key label domains order as
no-selector ⊒ {key} ⊒ {key,value}; selectors on different keys are
treated as possibly intersecting and fail closed. A union of value-restricted
Allows never covers an unrestricted-selector domain.
The gate is write-time only. If the writer later loses authority, existing grants remain until their own binding/membership changes or a live Deny removes their effect. Narrowing the target identity’s Role still affects every outstanding token for that identity (§7).
Live enforcement permits request-local memoization, not stale policy. Each
request may build one immutable AuthorizationSnapshot containing the typed
principal and UID, token ceiling, expanded Groups, org-admin status, applicable
bindings/Roles, and their provenance. Primary checks, secondary references,
list projection, grant simulation, and explain output may memoize decisions
against that snapshot. No snapshot is reused across requests in the initial
implementation: every request obtains current database facts.
A future cross-request cache requires a transactionally incremented global
authorization_epoch on every authorization-changing write and keys entries
by at least principal UID, token-cap hash, epoch, and relevant resource
identity. A global epoch is deliberately preferred first because Role edits and
ancestor-label changes can affect whole subtrees; fine-grained invalidation is
unsafe until proven.
Restrictions remain transparent. Explain output retains binding UID and placement tier for every contribution, including Denies ignored because the caller is an operator or org admin. Where granted read access, org admins may also inspect platform bindings covering their org.
6. Ownership and attribution
Section titled “6. Ownership and attribution”There is no authorization ownership primitive in this model. Nothing in §1–§5 knows what an “owner” is: the evaluator sees subjects, bindings, and labels — nothing more. Generic metadata.ownerReferences are lifecycle-only garbage-collection edges and grant no access. Authorization “ownership” exists only as the effect of one binding (§6.2) that happens to grant owner-like permissions to whoever a label names. Remove that binding and the concept vanishes from the platform without touching the engine; override it (§6.5) and ownership means something else in that org. The label is likewise just a convenient, inspectable targeting mechanism: rise.dev/owner carries no authorization semantics of its own — no label key does — a key becomes access-relevant exactly when, and only for as long as, some binding’s labelSelector references it (§6.6 step 2). What this section defines is therefore not an authorization ownership feature but a shipped default convention: a reserved key, one seeded dynamic binding, and the write-gating that any access-driving label automatically inherits. (A dedicated single-subject authorization ownerRef field was considered and rejected; see Alternatives considered.)
6.1 — Attribution is one governed label
Section titled “6.1 — Attribution is one governed label”A single reserved key, rise.dev/owner, holds a typed SubjectRef in one of two forms:
user:<name> # absolute root User, e.g. user:u-01jz…group:<name> # Group relative to the matched resource's OrganizationThis is deliberately narrower than SubjectId: ServiceAccounts, Controllers, virtual subjects, full cross-org Group identifiers, aliases such as user:me, and malformed/unknown kinds are rejected. The relative Group form canonicalizes to group:<org>/<name> only during binding evaluation; it fails closed on a root resource with no Organization. UI aliases may be resolved before submission, but persisted values always contain the stable User name or Group name above. SubjectRef is generic dynamic-binding input rather than hardcoded owner behavior: any value-less labelSelector paired with subject: ${ref.subject} receives the same parsing and resolution.
Nested resources without their own value inherit one through effectiveLabels — a computed field, always resolved live (never stored or cached, consistent with §5’s live-evaluation philosophy — both the read-path display value and the authorization-path match in §4 are the same computation), resolved by walking the already-fetched ancestor chain leaf-to-root, nearest value wins per key:
Project "secret-app" rise.dev/owner: group:platform └─ Environment "prod" rise.dev/owner: group:devops # more specific, set later
effectiveLabels for "prod": { "rise.dev/owner": "group:devops" }A more specific descendant’s label shadows its ancestor’s; it does not additionally union with it. Restoring broader access on a shadowed resource is always possible — bind another Role at the broader scope — it is simply not automatic. effectiveLabels is the one ancestor-inheritance mechanism in the system; ownership reuses it rather than maintaining a parallel one.
6.2 — The default ownership rule
Section titled “6.2 — The default ownership rule”One platform-seeded dynamic binding replaces any implicit “you can act on what you own” logic:
subject: ${ref.subject}subjectMembership: ResourceOrganizationlabelSelector: { key: rise.dev/owner }roleRef: { kind: PlatformRole, name: resource-owner }scope: "*"resource-owner is a literal platform-shipped PlatformRole, defined as:
resource-owner = { Allow: [get, list, update, delete] on * }— deliberately excluding create and every subresource. Ownership alone never grants the ability to update /status or /finalizers, create a token for an owned ServiceAccount, or create new child resources; those require a separately-granted Role. Both resource-owner and org-admin are ordinary operator-editable PlatformRoles with shipped defaults; changing org-admin changes the global admin baseline, while scoped platform Denies provide per-org ceilings (§5).
Unlike the operator and org-admin tiers, the org-user tier ships no baseline create-granting Role: ordinary org-user access beyond ownership is configured through org-admin delegation, and resource-owner intentionally omits create.
When the resolved subject is the caller, self-ownership falls out without a separate condition type, but subjectMembership: ResourceOrganization keeps that access live only while the User remains affiliated with the resource’s org. An explicit static PlatformRoleBinding with subjectMembership: Any is the deliberate operator escape hatch for non-member access.
6.3 — Resolving a dynamic subject’s organization
Section titled “6.3 — Resolving a dynamic subject’s organization”A dynamic binding’s subject template has no concrete identity until it is evaluated against a specific resource. For group:${ref.name}, and for a group:<name> value consumed by ${ref.subject}, the resolved Group’s organization is the matched resource’s own organization. Thus rise.dev/owner: group:platform on an acme resource resolves to group:acme-corp/platform, never a platform-named Group elsewhere. user:<name> remains root-absolute.
The binding’s own scope governs which resources consider the rule, not the resolved Group’s organization, which is derived per resource. subjectMembership: ResourceOrganization then constrains a resolved User against that same org. This is why §6.2’s binding can validly carry a labelSelector and scope: "*" without becoming a cross-org User grant. On a root-scoped resource, an org-relative Group reference still has no organization and fails closed; the membership constraint itself is simply a no-op because there is no resource Organization, and never defaults to an arbitrary org.
6.4 — Individual ownership and organization-specific grouping need no new subject kind
Section titled “6.4 — Individual ownership and organization-specific grouping need no new subject kind”Subject kind stays closed (User, Group, ServiceAccount, Controller — each carries real membership-resolution machinery, not worth making pluggable). Label keys are open — any organization can introduce one:
# individual ownership — same mechanism, a different kind and keysubject: user:${ref.name}labelSelector: { key: rise.dev/assignee }roleRef: { kind: PlatformRole, name: resource-owner }
# an org's own grouping concept — reuses Group, never registers a new kindsubject: group:${ref.name}labelSelector: { key: rise.dev/squad }roleRef: { kind: Role, name: project-editor }A “squad” never exists as a subject kind — it is a Group, targeted via a label key the organization chose to call rise.dev/squad. This covers grouping concepts whose membership is ordinary Group membership; it does not provide a way to define a group with genuinely different membership semantics (externally-synced, rotation-based, non-exclusive overlapping groups, etc.) — that would require a real pluggable subject-kind registry, which is deliberately out of scope (Alternatives considered).
6.5 — Organizations can override the default
Section titled “6.5 — Organizations can override the default”The seeded ownership binding is ordinary scope: "*" data. §1’s wildcard-replace rule governs overrides the same way it governs any other wildcard — an org-specific binding for the same (subject, labelSelector key) pair replaces the platform default outright for that org:
subject: ${ref.subject}labelSelector: { key: rise.dev/owner }roleRef: { kind: Role, name: project-viewer } # read-only ownership; an org-parented RoleBindingscope: rise.dev/Organization/acme-corpThe override write still passes the ordinary write-time grant gate (§5) — no override-specific mechanism.
This override works because the default uses wildcard scope: "*"; two non-wildcard bindings union rather than replace. An org may narrow ordinary subjects below an existing non-wildcard Allow with an explicit org-tier Deny. Current org admins ignore that Deny, while a platform-tier Deny still limits them (§5).
6.6 — Label writes that retarget access are gated by the write-time grant gate itself
Section titled “6.6 — Label writes that retarget access are gated by the write-time grant gate itself”There is no hardcoded list of protected fields. On any write — creation or update — that sets or changes metadata.labels[K]:
- If the value for
Kis unchanged from the resource’s current effective value, no gate — ordinaryupdatepermission suffices. - If no binding anywhere applicable to this location in the tree (by Scope and ResourceKind, regardless of whether it currently matches this resource’s present labels) selects on
Kvia itslabelSelector, no gate. This check is evaluated against binding applicability, not the resource’s pre-write label state. - Otherwise, resolve effective permissions before and after the proposed value and diff them — where “the value” is the
effectiveLabels-resolved ownership (§6.1), inherited ancestor values included and nearest-wins applied, not the resource’s own stored label read in isolation. Removing an access-driving label is a “change” like any other and is gated: dropping a child’s ownrise.dev/ownermakes it inherit an ancestor’s owner via nearest-wins — an escalation if that ancestor names the writer’s group, which a diff over the resource’s own stored label would misread asvictim → absentand wave through as de-escalation. The diff is simulated, computed atomically with the write so a concurrent binding change cannot open a window between simulation and commit. The newly-implied grant is computed over all subjects any selecting binding resolves to before and after — not only the writer’s own access — and each such grant must be⊆the writer’s own current effective permissions — §5’s general write-time grant gate, applied here. Moreover, the before/after diff spans not onlyrbut every resource that inheritsr’s value forKthrougheffectiveLabels(§6.1) —r’sK-inheriting subtree — since relabelingrcan newly grant access over descendants that inherit the changed value.
A key becomes gated the moment some binding’s labelSelector references it, and stays ungated otherwise: protection is a consequence of binding existence, never a hardcoded field name.
Implementation note: the subtree diff (step 3) is a cold path, implementable via a recursive parent_uid query; its atomicity is covered by §5’s existing write-consistency requirement for the grant gate, needing no §6.6-specific mechanism.
A narrow, explicit exception applies at creation. A subject holding create on a kind may, in that same creation request, set an access-driving label only when every selecting binding resolves its value to the creator’s own canonical User subject or to a Group they currently belong to (itself an ordinary grant-gated fact — joining a Group is its own gated write, not something a creator can manufacture on the fly to widen this exception). For the seeded owner binding, this means rise.dev/owner: user:<caller's-stable-name> or rise.dev/owner: group:<one-of-the-caller's-groups>. No immediate RoleBinding is created: the already-seeded dynamic binding applies to the newly persisted label in the same transaction and makes the owner grant effective immediately. This is not displacement because the resource has no prior owner. A different User or a Group the creator does not belong to falls back to the general subset rule. If recipient constraints make the proposed policy a no-op, its effective grant delta is empty and the write is safe to persist; if it grants authority, the creator must independently hold the complete authority being granted.
“Creation” here means bringing a genuinely new, previously-nonexistent resource identity into being — never a write that targets an identity that already exists in the store, even one currently soft-deleted or otherwise inactive. Restoring a soft-deleted resource, or an upsert-style write that would create-or-update depending on whether the target already exists, is not creation for this exception’s purposes and is unconditionally subject to the general rule instead: an implementer must resolve “does this identity already exist” before deciding whether the exception can apply, exactly because the exception’s own safety rests on there being no prior owner to displace — which is only true for a genuinely new identity. The exception applies exactly once, under that definition — every later write to the same label, including the very next update, is unconditionally subject to the general rule above.
When the value resolves through multiple applicable templated bindings on the key, the exception applies only if every concrete SubjectId is the creator or one of their Groups; if any resolved subject is not claimable, the general subset rule governs the whole write. A delete+create sequence that reclaims a freed resource name is the sanctioned move primitive (§4), not an ownership-takeover flaw: reclaiming a name follows from holding delete+create on that scope — a permission-configuration decision — and defending against it would break “move.”
The check is a genuine subset comparison, not merely “does this write avoid dropping access to zero.” An editor with no independent claim to resource-owner could relabel rise.dev/owner: group:platform → group:their-own-group without ever dropping the resource’s access to zero — they would simply redirect it to themselves. The subset check blocks this; a caller who currently holds the role being handed off (the resource’s actual current owner, or an org-admin whose access is independent of any label, §6.7) passes trivially, so legitimate transfers are unaffected.
Referential-integrity validation (§6.7) runs only after this gate passes — a caller who would be denied by this check never learns whether the value they attempted resolves to a real Group/User, avoiding turning the validation step into an unauthenticated existence oracle.
6.7 — Orphan prevention is separate from escalation prevention
Section titled “6.7 — Orphan prevention is separate from escalation prevention”Escalation — an unauthorized party redirecting access to themselves — is §6.6’s job. Orphaning — a legitimate write accidentally locking everyone out, typically a typo — needs two different mechanisms:
- Referential integrity at write time. A value written to a label some binding selects on must parse through that binding’s declared template and resolve to every required concrete subject. For
${ref.subject}, that means an existing User or same-org Group. Invalid kind prefixes and nonexistent subjects are rejected synchronously, with an authorized fuzzy-match suggestion where appropriate. Current membership is deliberately not a validity condition: a foreign or group-less User may be recorded as an owner whileResourceOrganizationmakes the binding grant nothing, and a later membership change may make it effective only through the ordinary serializable grant gate. - Admin access stays independently derived, enforced structurally.
PlatformRole/org-adminmay only be referenced by an exact org-root, scope-only RoleBinding, never alabelSelectorbinding (§5). Admin status therefore cannot depend on an access-driving label, and an operator can recover an orphaned resource without any magic Group name.
Semantically inert policy is an auditing concern, not a write-time validity error. A future Role/policy auditing workflow should flag ownership labels that currently grant nobody, selectors that match no resources, recipient-boundary or subjectMembership combinations that are no-ops, stale references, and Allows shadowed by replacement or Deny. Explain output must show why such data contributes no effective tuple. The synchronous mutation path retains only the checks needed for safe interpretation and non-escalation: closed-schema parsing, referential integrity, structural scope/placement constraints, and the effective-delta grant gate.
7. Rise-issued identities and token issuance
Section titled “7. Rise-issued identities and token issuance”Authentication proves which known Rise identity a credential represents; authorization decides what that identity may do. The engine accepts only:
AuthenticatedPrincipal { subject: SubjectId, subject_uid: ResourceUid, provenance: AuthenticationProvenance, actor: optional ActorChain, authorization_cap: AuthorizationCap}Every adapter validates signature, issuer, audience, time bounds, and credential
type, then resolves (sub, rise_uid) to the same live, active User,
ServiceAccount, or Controller. Group and virtual subjects can never be token
principals. Operator status is derived only after User authentication. For a
Rise-issued principal, unknown, deleted, inactive, malformed, or UID-mismatched
identities fail before authz; §1’s JIT rule separately governs a validated
upstream interactive login whose mapping is absent.
There are three issuance flows.
- Interactive User login. Rise acts as an OIDC relying party, validates the
upstream identity, and resolves its exact
UserIdentity.spec.(issuer, subject), including inactive live mappings. An inactive identity or inactive parent User fails without JIT. If no live mapping exists, the fixed JIT transaction from §1 creates a new generated User plus that first identity and converges concurrent attempts through the unique mapping constraint, even if a prior mapping for the pair was deleted. It never links to an existing User implicitly. Rise then issues a session for the parent User UID and derives operator membership from any of that User’s live, active identities, not only the credential used for this login. Provider adapters may ingest verified profile email, but email never replaces(issuer, subject)as the authoritative key or links accounts. - Workload token exchange. An external workload JWT is accepted only at
the intended ServiceAccount or Controller’s
/tokensubresource. The URL supplies the target; Rise checks only trust-policy children of that exact target and issues a token for exactly that target. This is authentication as the workload’s configured Rise identity, so it has no RBAC token-create check and never performs a global source-identity search. - Delegated token issuance. An already Rise-authenticated User,
ServiceAccount, or Controller may create another target’s
/tokenonly when its current EffectivePolicy contains(create, target ResourceKind, token)on that exact target. No external assertion or target trust-policy check participates.
The two /token modes are disjoint: a request supplies either an external
subject assertion for workload token exchange or a Rise bearer for delegated
issuance, never both. A trust policy may not name Rise’s issuer as an external
source. External workload assertions are rejected by every ordinary endpoint.
Workload-exchange failures after entering a registered ServiceAccount or
Controller /token route are deliberately indistinguishable. A nonexistent,
soft-deleted, or disabled target; a UID-addressed route that resolves to the
wrong kind; zero or multiple matching trust policies; and invalid
issuer/audience/signature/claims all return the same coarse authentication
failure and never reveal whether the named identity or policy exists. A kind
that does not register token — for example Deployment — has no such route and
returns the ordinary route-not-found response before authentication. Target
resolution is part of authentication for a valid workload-exchange route, even
though ordinary delegated requests resolve their parent after authentication.
create, rather than get, is intentional for delegated issuance: it is a
non-idempotent operation returning a new credential without persisting a Token
row. A Controller is root-scoped, so its token-create grant must reach that root
resource through a PlatformRoleBinding. A ServiceAccount is org-native and may
be reached by an org RoleBinding.
Delegation may chain only across explicit grants. A delegated token
exercises the target’s live EffectivePolicy, not the caller’s, so token-create
is intentionally elevation-capable. The target may mint again only if it
itself holds token-create on the next target. Every delegated token records a
bounded nested act chain for audit; actor data never grants access, and an
issuance exceeding the platform chain-length limit is rejected.
Structured authorization details form a signed Allow ceiling. A narrowed
Rise token uses the RFC 9396 authorization_details claim. Each entry has
exactly one qualified Scope and one or more permission statements:
{ "authorization_details": [ { "type": "rise.dev/rbac", "scope": "rise.dev/Project/acme/app", "permissions": [ { "verbs": ["get", "list"], "kinds": ["rise.dev/Deployment"] } ] }, { "type": "rise.dev/rbac", "scope": "rise.dev/Project/acme/catalog", "permissions": [ { "verbs": ["get"], "kinds": ["rise.dev/Deployment"], "subresources": ["status"] } ] } ]}Entries union with each other; statements within one entry union over that entry’s singular Scope. The resulting union is an Allow ceiling, never a grant:
token EffectivePolicy = live RBAC ∩ union(rise.dev/rbac details)Permission statements reuse Role grammar exactly. Omitted subresources
means the main resource only; a non-empty list names only those subresources;
"*" means every registered subresource but not the main resource. An empty
scope, verbs, kinds, or subresources list is invalid rather than
a no-op or wildcard. Qualified ResourceKind and Scope parsers are shared with
policy data. Duplicate entries are allowed because distinct Scopes may carry
different permissions; malformed entries and unknown authorization-detail
types are rejected for a Rise API token rather than ignored.
Omitted authorization_details on an internally issued token means the full
live target policy. A present but invalid/empty detail set never falls back to
full access. The parsed union travels as AuthorizationCap on
AuthenticatedPrincipal and participates in every primary and secondary
decision, list projection, reference check, label/grant gate, and explain
result. aud remains the separate standard claim controlling where the
credential is accepted.
Both workload exchange and delegated issuance may accept the same
authorization_details request structure. It can only narrow the issued
target token. For delegated issuance, the caller’s current capped
EffectivePolicy must authorize token creation, but the child does not silently
inherit the caller’s cap: token-create is the explicit delegation boundary and
the requested child details constrain the target.
Tokens carry identity and a ceiling, never a snapshot of grants. Every request re-resolves the target identity, Groups, Roles, bindings, and Denies. Narrowing or deleting the target affects outstanding tokens immediately; recreating the same name under a new UID does not revive them. Revoking a caller’s token-create grant stops new issuance but does not revoke already-issued target tokens. Tokens remain short-lived under one platform-global maximum TTL.
8. One canonical qualified ResourceKind — no plural forms
Section titled “8. One canonical qualified ResourceKind — no plural forms”A resource kind has one qualified identity: <api-group>/<Kind>, for example rise.dev/Deployment. Role statements and authorization details use that exact ResourceKind; Scope paths start with it; reference declarations store the same (group, Kind) pair. HTTP retains the served version between group and Kind: {group}/{version}/{Kind}/{ancestor…}/{name}. ResourceDefinition no longer declares a plural. This preserves one Kind vocabulary while preventing two API groups’ same-named Kinds from colliding in authorization. Version conversion cannot change authority because all served versions normalize to the same ResourceKind.
9. References to platform-provided resources
Section titled “9. References to platform-provided resources”Deferred. This section describes a designed but deferred capability — platform-provided selectable resources (e.g.
RuntimeClass). It is not part of the initial model or its conformance suite, and §5–§7 do not depend on it (it excises cleanly). Theuseverb (§2) and thereferences:ResourceDefinitiondeclaration are retained now as reserved vocabulary to avoid a later schema migration. Deferred, to be decided and implemented as a tracked follow-up: reference materialization at deployment creation, the per-orguse-against-consuming-resource’s-org check, and the default-label owner/admin-tier write gate — together with the deferred feasibility items (theat:reference-path grammar, and restricting declared references to root-scoped platform-provided referent kinds).
Some resources exist to be referenced rather than contained: a platform-level RuntimeClass (root-scoped, operator-managed) describes how project deployments are reconciled, and organizations select one rather than own one. Some classes are for every org; others are provisioned for one specific customer. The interesting permission is not CRUD on the class — that stays operator-only by ordinary default-deny — but who may select it.
Reference declarations. A ResourceDefinition may declare that a field (or label key) of its kind references another kind:
references: - at: spec.runtimeClass # a field path or a label key kind: rise.dev/RuntimeClass verb: useDeclared once at kind registration, as data — the same family as ResourceDefinition-declared subresources (§2), never per-field engine code. Any write that sets or changes a declared reference additionally requires the writer to hold use (§2) on the referenced instance, evaluated by the ordinary algorithm (§4). An unchanged value on a later write is not re-checked (same rule as §6.6 step 1), and the check runs before existence disclosure (same ordering as §6.6/§6.7): a writer without use cannot probe whether a class exists.
Availability is instance-targeted bindings. A root-scoped instance is a node in the tree, so §4’s Scope targets it with nothing new:
# everyone may use the standard classsubject: system:authenticatedscope: rise.dev/RuntimeClass/standardroleRef: { kind: PlatformRole, name: rc-user } # PlatformRoleBindings (§3)
# gpu-b is provisioned for acme-corp onlysubject: org:acme-corpscope: rise.dev/RuntimeClass/gpu-broleRef: { kind: PlatformRole, name: rc-user }Here PlatformRole/rc-user = { Allow: use on rise.dev/RuntimeClass }.
Multiple orgs → one binding each: explicit and auditable. “Org A cannot select org B’s class” is not a rule anyone writes — it is the absence of a grant: org A’s subjects hold no use binding on gpu-b, default-deny (§4 step 3) rejects the write without confirming the class exists, and org A cannot self-serve the grant — a binding whose Scope reaches a root-scoped instance must be a PlatformRoleBinding (§4’s containment rule; org-parented RoleBindings cannot leave their org’s subtree), only operators can create those, and the write-time grant gate’s subset check independently blocks handing out use they don’t hold.
Per-org use is checked against the consuming resource’s org. For a platform resource provisioned to one org (e.g. gpu-b granted only to org:acme-corp), the use grant is evaluated against the consuming resource’s organization, not solely the acting subject’s group membership. A use grant addressed to org:acme-corp authorizes selection only from resources within acme-corp’s subtree: a User who is a live member of both acme-corp and beta-corp cannot select acme-corp’s private gpu-b while deploying into beta-corp, because the resource being written lives under beta-corp and no use binding grants gpu-b there. Checking only the subject’s own membership would break §9’s cross-org isolation invariant. A consuming resource with no organization (a root-scoped resource) lies within no per-org subtree, so a per-org use grant simply does not apply to it — fail closed, never falling back to the acting subject’s membership; instance-wide grants (system:authenticated) are unaffected since they are not per-org.
Defaults are product data, not permission data. Nothing product-specific accretes onto the RBAC core resources — Roles, RoleBindings, and cap Denys stay purely authorization data (§5). The global default is a label on the class itself — runtimeclass.rise.dev/is-default: "true", operator-writable because the class is operator-owned (the same pattern as Kubernetes’ storageclass.kubernetes.io/is-default-class). Org- and Project-level overrides are a label on the Organization or Project (runtimeclass.rise.dev/default: gpu-b), and the override cascade — Deployment-explicit → Project → Organization → global — is effectiveLabels’ nearest-wins walk (§6.1), with no new inheritance machinery. The default label key is itself covered by a reference declaration, so an org-admin setting their org’s default is use-checked like anyone else — an org cannot default itself onto a class it was never granted. Beyond the use check, writing a reserved default label is gated to the owner/admin tier of the resource it is set on: an org-level default (runtimeclass.rise.dev/default on an Organization) requires org-admin or owner of that org, and a project-level default requires the Project’s own owner — not the bare update an ordinary project editor holds. Otherwise a project editor holding only update could steer co-tenants’ workload placement by rewriting the inherited default.
Materialization at deployment creation. When a deployment is created, the effective class is resolved once and written onto the Deployment as its own concrete value; that materializing write is a reference write, use-checked against the deployer — the User or ServiceAccount driving the deployment. This is why org:<name> includes ServiceAccounts (§1): CI-driven deploys must pass exactly where a human’s would. The reconciler then reads only the materialized field and never evaluates use at all — every use check in the system has a well-defined, present subject. (Precedent: Kubernetes’ DefaultStorageClass admission stamps the default storageClassName onto a PVC at create time.)
This deliberately gives the reference snapshot semantics, not §6.1’s live semantics: the never-store rule exists for access-driving labels, where staleness is a security bug, whereas here the recorded value is the output of a decision made at a specific moment by a specific subject, and reproducibility is the point. The org’s default label remains live as an input to the next deployment. Revoking an org’s use grant therefore stops the next deployment, never a running one — consistent with the write-time grant gate applying at write time everywhere else (§5), and the right availability call: a revoked class ages out at the org’s next deploy or rollback (which creates a new deployment and re-resolves against current grants).
Boundary. Org-admins cannot sub-delegate or per-instance-restrict use of
platform-provided resources inside their org — those grants and ceilings are
PlatformRoleBindings, outside org authorship by placement (§3, §4). Their
local lever is the org default label; finer org-side restriction would need
resource admission policy, which is out of scope (§10).
10. Explicitly out of scope
Section titled “10. Explicitly out of scope”- Org-registrable Controllers/ResourceDefinitions — falls out for free once registration is just another grant-gated verb, not designed now.
- Migrating today’s typed-table-backed APIs (
Project,User, existingTeam,ServiceAccount,Deployment, …) onto this model — happens separately. Existing Teams becomeGroupresources andteam_membersbecomeGroupMembership; ServiceAccounts move from Project to Organization placement and cease masquerading as synthetic Users. - Ingress-level authentication for a deployed application’s own end users — a different problem domain entirely.
- A pluggable subject-kind registry letting organizations define groups with custom membership semantics (§6.4) — organization-specific naming of a grouping concept is supported today by pairing an existing kind with an organization-chosen label key; genuinely custom membership resolution is not, and would need a larger extension to the closed subject-kind list.
- A first-class cross-org sharing primitive — a deliberate grant reaching subjects of another org. The recipient boundary (§1) bans cross-org sharing through org bindings by construction; operator-authored
PlatformRoleBindings are the only cross-org grant path today. A tenant-authorable sharing mechanism is deferred; nothing here forecloses it. - Platform-provided selectable resources and reference materialization (§9) — designed but deferred. The
useverb (§2) and thereferences:declaration ship as reserved vocabulary, but reference materialization at deployment creation, the per-orguse-against-consuming-resource’s-org check, the default-label owner/admin-tier write gate, and the remaining feasibility items (theat:reference-path grammar, restricting referents to root-scoped platform-provided kinds) are a tracked follow-up, not part of the initial model. - Resource admission policies — org- or operator-authored rules constraining what may be written below a given scope (e.g. required labels, or per-instance restriction of which platform resources an org’s own subjects may reference, §9). A future mechanism; nothing here forecloses it.
- Concrete streaming, connection, proxy, and virtual-object subresource
contracts (
logs,proxy,scale, and similar). This ADR fixes their authorization tuple and the shared registration seam, but their handler interfaces, transport semantics, and response types are drafted in ADR-0002 (§3). - Extending the
tokensubresource to theUserkind — user self-service personal tokens, operator-delegated minting on behalf of a user, and exposing non-interactive external-assertion→Rise-token exchange (RFC 8693) for users through it. The interactive browser login flow stays separate: Rise remains an OIDC relying party (the upstream IdP is the authorization server), and the token-issuance logic is shared as one issuance core (rise-backend-auth) that both the login callback and/tokencall, rather than routing interactive login through the/tokenendpoint. Deferred with two hazards to design first: becauseUseris root-scoped,(create, rise.dev/User, token)is grantable only by aPlatformRoleBinding(§4 containment) — operator-only, so an org-admin structurally cannot grant it, and delegated minting would otherwise hand out a target user’s cross-org reach unless the minted token is clamped to the minter’s own scope authority; and self-service minting still needs a defined provisioning convention for a typed self-reference on each root User plus a narrowly-scoped dynamic token binding.${ref.subject}can express that relationship, but no such label or binding is shipped here, and a User cannot bootstrap a protected label on their already-existing root identity themselves. This deferral does not affect SSO login, which is authentication — a User’s own external credential mapped to a liveUserIdentity(§7), gated by that trust mapping, never by an RBAC(create, rise.dev/User, token)grant. Workload token exchange similarly authenticates as its configured target without RBAC; delegated issuance is the distinct token-create-gated mode (§7). Any future unification ontotokenmust keep that self-authentication leg trust-policy-gated, not RBAC-gated, to avoid a login bootstrap paradox.
Consequences
Section titled “Consequences”Positive.
- One evaluator decides access for every subject kind — Users, Groups, ServiceAccounts, Controllers, and Operators run the same algorithm, replacing five disjoint authorization code paths.
- Operator access becomes inspectable and auditable as data: the seeded
system:operatorsbinding is a stored row the same explain/audit tooling can read, instead of an invisible bypass branch (§1). - Who-can-do-what is runtime-configurable per deployment: Roles, RoleBindings,
and caps are all rows (a cap is just a
Denybinding), and platform-shipped defaults (org-admin,resource-owner) are operator-authored data, so a SaaS and a self-hosted instance get different postures from the same architecture (§5, §6.2). - Restrictions and grants use one statement algebra while retaining binding provenance: platform Denies limit org admins, org Denies limit ordinary org subjects, and the grant gate compares net EffectivePolicy deltas (§5).
- Revocation is live across requests: Denies and memberships are re-resolved on every request, so tightening a cap or narrowing an identity’s Role takes effect immediately — including for every outstanding token of that identity — while one request may safely memoize its AuthorizationSnapshot (§5, §7).
- An org-admin can see why they are capped: caps are inspectable bindings, and
the explain endpoint surfaces the applicable
Denyon any denial, so a denial is diagnosable rather than opaque (§5). - Reference authorization — who may select a platform-provided resource
like a
RuntimeClass— reuses the same evaluator, bindings, default-deny, and existence-masking as everything else; making a class available to an org is one auditable binding (§9). - Cross-tenant isolation is structural, not asserted: the recipient boundary
(§1) intersects every org binding’s grant with live membership in that
binding’s own org, so an org binding cannot reach a foreign or org-agnostic
subject; only operator-authored
PlatformRoleBindings cross org lines. - The operator tier cannot be locked out of an org: the operator guarantee is
keyed on the requesting caller, so whenever a request’s membership expansion
includes
system:operatorsthe evaluator yields unconditional access to every main-resource and registered-subresource tuple, ignoring everyDenyin the union — including one targeting the caller’s ownuser:identity (§1). - Collection (
list) authorization is per-item, not scope-level: every item in a listing is independently evaluated, projected to the explicit base-field allowlist, and expanded to the full object when the caller also holdsget; a caller with nolistgrant gets a masked-empty result rather than a 403 — so existence/owner visibility and full-object data visibility are grantable independently (§4). - Max token TTL is a single platform-global configuration constant, checked at issuance — not per-org, not composed — so it can never be unset and no misconfiguration can yield an unbounded-TTL token (§7).
Negative / accepted risks.
- Wildcard replacement is outright, not merged: an org-specific binding silently discards everything the wildcard binding provided beyond what it restates (§1).
- Cap tightening has no dry-run/impact-preview — an operator can strand subjects with no warning before committing the write (§5).
- A cap can
Denyspecific(verb, ResourceKind, subresource?)tuples but cannot restrict an org to a whitelist of kinds: the kind space is open-ended (new kinds register at runtime), so “only these kinds, nothing else” has no faithfulDenyencoding — the same open-kind problem §3 solves for grants. Verb caps (the real use case — “no token-create”, “nodeleteinprod”) are unaffected (§3). - There is no token-revocation list. Responding to a compromised minting caller means acting on the target identity’s own grants or waiting out the TTL (§7).
- Token-create can deliberately form delegation chains. Each edge is an explicit live RBAC grant and the actor chain is audited, but reviewers must reason about the transitive reach of identities allowed to mint (§7).
- The creation-time ownership exception depends on a precise “genuinely new identity” definition: implementations must correctly distinguish creation from restore/upsert, or the exception becomes an ownership-displacement hole (§6.6).
- The transparency the model is designed around (§1’s explain/audit tooling — “why can this subject do this?”) is only practical once the implementation builds a policy explain/simulator; that is additional, eventual work the model assumes.
userevocation takes effect at the next deployment — references are materialized and checked at write time, so running workloads are never retroactively broken, which also means a revoked class lingers until the org’s next deploy or rollback. Snapshot semantics are safe only where selection is not a security boundary (§9).- Platform-provided selectable references (§9) are designed but deferred: the
useverb and thereferences:declaration ship as reserved vocabulary, while materialization and its checks are a tracked follow-up (§9, §10). - Org-admins cannot per-instance restrict or sub-delegate
useof platform-provided resources inside their org; their local lever is the org default label until admission policies exist (§9, §10). - The resource-API RBAC items in
ROADMAP.md(and everything sequenced on them) are to be planned against this model.
Alternatives considered
Section titled “Alternatives considered”- Pure-additive, union-only permission sets, no Deny. Cannot express subtraction from a wildcard over an open-ended ResourceKind space. Rejected for tiered platform/org Denies (§3–§5).
- Folding ownership into wildcard statements covering both main resources and every subresource, rather than a distinct owner Role. Would silently over-grant: an owner would automatically gain token creation and finalizer updates alongside ordinary access, defeating the deliberate subresource separation in §2. Rejected in favor of the named
resource-ownerRole with the explicit main-resource verb list pinned down in §6.2 (get/list/update/deleteonly). - A dedicated single-subject authorization
ownerReffield alongside the Role/binding model, inherited down the parent chain as a union. Two independent inheritance and authorization mechanisms complicate explanations, and a descendant could never fully exclude an ancestor’s owner. Rejected; §6 expresses authorization ownership with Role/RoleBinding/label primitives, while an operator-authored platform Deny can still enforce a narrower hard restriction (§4, §5). This does not reject genericmetadata.ownerReferences, whose only effect is garbage collection (§1). - Labels driving RBAC directly, with no write-gate on the label itself. Ordinary
updateaccess on a resource would let any editor silently redirect which subject holds a derived Role — an ungated escalation path. Rejected in favor of §6.6’s binding-triggered write-time grant gate, which in turn is one instance of §5’s general rule that every write changing effective access — including RoleBinding and Role edits, not only labels — passes through the same check. - A dedicated bespoke verb per protected field (e.g.
setGroupLabel), rather than a generic mechanism. Every newly-sensitive field would need a new verb and new engine code. Rejected in favor of §6.6, where protection follows from alabelSelectorbinding’s existence. - Gating label writes on “does not drop access to zero” rather than the standard subset check. Defends availability only — it never checks who gains access, only that the total doesn’t hit zero — so it would still permit an unauthorized party to redirect access to themselves. Rejected in favor of the genuine subset comparison in §6.6.
- Applying org-authored Denies to org admins. Rejected because an org admin must retain the global admin baseline up to platform policy. Org Denies limit ordinary members and workloads; binding placement lets current admins ignore them without weakening platform Denies (§5).
- A cap-tightening (or wildcard-replacement) dry-run/impact-preview warning. Would require simulating the write’s effect across every subject with a live binding under the tightened rule before committing it — expensive and stateful in a way the rest of the write path deliberately isn’t, and it doesn’t integrate cleanly into a generic REST write path. Deferred; the footgun is accepted, not solved, for now (§1, §5).
- An open, pluggable subject-kind registry, to let organizations define arbitrary group types (e.g. “squad”) with their own membership resolution. Subject kind carries real infrastructure (membership resolution, org-native-vs-agnostic encoding, token-create semantics) not worth making pluggable. Rejected; §6.4 shows organization-specific naming of a grouping concept is expressible by pairing an existing kind (Group) with an organization-chosen label key — genuinely custom membership resolution is a separate, larger ask this does not address, and remains out of scope (§10).
- Clamping a minted token’s scope to the calling subject’s own permissions. Forecloses a legitimate privilege-elevation pattern — a low-privilege, long-lived caller minting a token for a higher-privilege, short-lived ServiceAccount, the same shape as AWS STS
AssumeRole. Rejected; §7 gates who may mint, not what the minted token may then do. - A single global namespace for Role names, with no placement. Any org editing any Role by name would make cross-org authority attribution ambiguous the moment a Role is bound in more than one org — against whose permissions is an edit checked? Rejected in favor of placement-derived authority (§3, §5):
PlatformRole(root-parented, operator-editable, bindable by any org) vs.Role(org-parented, org-editable, bindable only from its own org) — editing a Role always has exactly one unambiguous parent org to check the editor’s permissions against, fixed by its parent. - A variable parent kind for policy objects (“parented at root or under an Organization”), instead of two kind pairs. The store’s exact-parent model is load-bearing: ancestor kinds in URLs and
Scopepaths derive deterministically from the leaf kind’s single parent chain, and union parents reintroduce path ambiguity in the general case. Rejected; two same-shaped kind pairs (§3), the same fork Kubernetes resolves withClusterRole/Role— an enum entry is cheaper than an invariant. - A reserved “platform organization” holding platform-level Roles/bindings, keeping one kind pair. The
Scope-containment rule (§4) would immediately need an exception — platform bindings carryscope: "*", which no org subtree contains — and an org that isn’t a tenant is a modeling smell, not a simplification. Rejected in favor of root placement (§3). - Bare-name Role references with org-local-then-platform fallback resolution, instead of structured, kind-qualified references. An org creating a
Rolenamedresource-ownerwould shadow the platform Role and silently retarget any later binding written with the bare name — the same one-unambiguous-answer failure mode §1’s wildcard rules exist to prevent. Rejected;roleRefalways names both the targetkindandname(§4). - Multiple stored subjects on one RoleBinding, as Kubernetes supports. This saves duplicate binding objects, but subject identity participates in Rise’s wildcard replacement, dynamic resolution, org-recipient validation, grant-gating, and audit explanations. Adding or removing one entry would partially mutate a binding, and an org-specific collision might replace a wildcard for only a subset of its subjects — complexity Kubernetes’ additive-only model does not face. Rejected for the initial model in favor of one subject per binding and Group/
org:groups for populations; a futuresubjects:input may be pure syntactic sugar expanded into independent bindings (§4). - Email address as
User.metadata.name, requiring@in the resource-name grammar. Email is mutable, awkwardly normalized, may repeat across issuers, and would make an authentication attribute the stable authorization key; admitting it also weakens a path grammar shared by every kind. Rejected for generated DNS-safe immutable User names plus dedicated(issuer, subject)UserIdentity resources; UI/CLI translates to presentation fields (§1). - Embedding SSO mappings, trust policies, or member arrays in the parent identity’s
spec. This makes independently governed security edges share one revision and turns membership changes into whole-object rewrites; one generic TrustPolicy child kind also cannot have both Controller and ServiceAccount parents under the exact-parent model. Rejected for dedicated, fixed-parent UserIdentity, GroupMembership, ControllerTrustPolicy, and ServiceAccountTrustPolicy resources (§1). - A separate
authorization.rise.devAPI group for subject resources. Adds qualification/versioning vocabulary while subjects androleRefintentionally rely on one reserved built-in role/identity domain. Rejected; these built-ins userise.dev/v1alpha1, and custom same-named kinds in other groups never participate in SubjectId resolution or built-in indexes (§1). - A special one-hop token rule. Rejected because each delegated mint already
has an explicit
(create, target ResourceKind, token)authorization edge. It would make a valid Rise-issued workload identity behave differently from a User and prevent intentional automation chains. Boundedactchains provide audit; RBAC remains the authority (§7). - Applying the general subset check to owner-label writes at creation with no exception. Would mean a subject holding only
createon a kind could never become the resulting resource’s owner, since ownership (resource-owner) is strictly more thancreatealone implies — breaking the single most common operation the model exists to support. Rejected in favor of the narrow, membership-bounded creation-time exception in §6.6, which only ever lets a creator name themselves or a group they already belong to, never an arbitrary third party. - Permitting a workload trust policy to accept Rise’s own issuer as an external source. Rejected because Rise-issued callers use delegated mode; accepting the same credential through workload exchange creates two competing authorization paths for one request (§7).
- A general
fields:include/exclude axis on Role statements, replacing named subresources (and potentially §6.6’s label-write gate) with field-path matching on the ordinaryupdateverb. Rejected on both counts. Folding in §6.6 doesn’t work at all: its gate depends on whether some other, unrelated binding currently exists (a live property of the whole binding table, not data a Role statement can carry) and on whether a value changed, not merely which path was touched — properties no static field syntax can express, and the diff computation such a fold would require is vacuous everywhere except labels, since nothing else in this model resolves a Subject off a field value. Replacingstatus/finalizerssubresources does not work either: it destroysresource-owner’s secure-by-default-via-omission property (§6.2) and introduces an ambiguous, security-critical path-containment language into the write-time grant gate’s⊆check. Something as simple asstatus.*has two plausible readings (single-segment versus recursive wildcard), whilefields: ["metadata.*"]could silently includemetadata.finalizers. Named, registered subresources keep those boundaries structural and reuse the same(verb, ResourceKind, subresource)evaluator as non-field operations such as token creation. - Making
org-adminimmutable. Rejected: the operator may change the global admin baseline by editingPlatformRole/org-admin; per-org variation belongs in scoped platform Denies (§5). - Nesting a ServiceAccount under a single owning Project, as its tree position. Access reach is granted entirely through bindings (§4), independent of tree position, so a “home” Project does no real work — it only couples the SA’s inherited attribution (§6.1) to whichever Project happened to parent it, and requires re-parenting (or duplicating) the SA to give it first-class standing against a second Project it’s equally bound against. Rejected in favor of parenting ServiceAccount directly under its org, a sibling of Project (§1) — matching how Group is already positioned.
- Operator status as a hardcoded bypass branch in the evaluator, checked before Role/binding resolution rather than expressed as data. Makes operator access the one thing the model’s own explain/audit tooling can’t account for, and duplicates logic the ordinary evaluator already has (union bindings, evaluate Allow/Deny). Rejected in favor of
system:operators(§1): a reserved subject derived by matching restart-loaded configured identity selectors against an active User’s live, active UserIdentity children, then granted access through one seeded, immutable binding. Operators run the same algorithm as everyone else, differing only in that their own request ignores everyDenyso no cap can reduce their access. - Treating the seeded
system:operatorsbinding as immutable data only, with no evaluator-level guarantee behind it. Immutability through the ordinary write path (§5) protects only against mutation via this model’s own API — not a bad migration, a restore from an old backup, or direct database access losing the row entirely. That residual risk is unacceptable for the one subject with no recovery authority above it. Rejected in favor of a hardcoded, evaluator-guaranteed grant forsystem:operatorsspecifically, mirrored as a healable data row for audit/tooling parity — matching how Kubernetes redundantly hardcodessystem:mastersalongside its ordinary, self-healingcluster-adminClusterRoleBinding, rather than relying on either mechanism alone. - Making the
system:operatorsbinding fully virtual too, with no stored row at all (matching how membership itself is virtual). Would remove operator access from the same explain/audit tooling that inspects everyone else’s — exactly the gapsystem:operatorswas introduced to close by replacing a hardcoded bypass branch in the first place (above). Rejected; the binding stays data, mirrored and healable — only the evaluator’s guarantee of its effect is hardcoded, not its existence as an inspectable object. - Making the seeded
system-adminRole and its binding ordinary operator-editablePlatformRoledata rather than immutable. Would let an operator edit or delete their own bootstrap grant through the ordinary write path — trivially passing the subset check, since they hold everything — with no higher authority left to recover from it, unlike every other documented risk in this ADR. Rejected in favor of a third, seeded Role-ownership tier (§5) that no write path can modify, editable by no one. - Allowing a static Subject to pair with a value-less
labelSelector. Would grant a fixed subject access to any resource carrying any value for that label key, regardless of what it actually says — access disconnected from the value the selector nominally matches on. Rejected; value-less selectors are reserved for dynamic (templated) subjects, where the matched value is actually used (§4). - Kubernetes-style plural resource names. Rejected in favor of one
group-qualified ResourceKind, such as
rise.dev/Deployment, across policy, Scope, discovery, and versioned URLs (§8). - An unqualified or separately-delimited Scope Kind. Rejected for
<api-group>/<Kind>/<names...>, the version-independent normalization of the resource URL (§4, §8). getas the reference gate (“if you can read it, you can select it”), instead of a distinctuseverb. Couples two independent decisions: a catalog may be browsable without being selectable (visible-but-gated offerings), and selectable without being readable (a class’s internals — node selectors, cost plumbing — are not the selector’s business). Rejected in favor ofuse(§2, §9), mirroring the Kubernetesuseverb on PodSecurityPolicies.- An
allowedOrgslist on the referenced resource’s spec as the availability mechanism. Moves an authorization decision out of the one system built to answer authorization questions, needs its own evaluation and audit path, and caps out at org granularity. Rejected; availability is ordinary instance-targetedusebindings (§9), which also express group- or ServiceAccount-narrow grants with no extra machinery. - Encoding product defaults (e.g. the default
RuntimeClass) in the RBAC core resources. Would accrete product-specific settings onto authorization data. Rejected; the core stays agnostic — defaults live on the product resources themselves as labels, and the override cascade iseffectiveLabels(§6.1, §9). - Scope-level all-or-nothing
listauthorization (or a 403 on inaccessible collections), instead of per-item filtering. Rejected in favor of per-item filtering with existence-masking and per-itemgetexpansion (§4): all-or-nothing either over-discloses — returning full items to anyone who can list the scope — or leaks scope population, since a 403 confirms the collection is non-empty, and it cannot express “see names org-wide, data only for owned.” - Live
usere-evaluation at reconcile time, instead of materializing the resolved class onto the Deployment at creation. Leaves the check with no well-defined subject (a reconciler acts for nobody in particular) and turns a grant revocation into retroactive breakage of running workloads. Rejected; the effective class is materialized at deployment creation anduse-checked against the deployer (§9), matching Kubernetes’ DefaultStorageClass admission behavior — revocation applies from the next deployment. - Server-auto-stamping the
rise.dev/ownerlabel at resource creation, so a creator never has to write it. Would force the generic resource core to hardcode knowledge of the ownership label — the one thing §6 exists to keep out of the engine, where ownership is purely the emergent effect of a seeded binding over an ordinary label. Rejected in favor of the creation-time exception (§6.6): the creator writes the label, gated to claiming only themselves or a group they belong to, and the core stays agnostic. - Passing a validated JWT’s raw
substring into authorization. Signature validation proves who issued bytes, not thatsubnames an existing Rise principal or even a principal-capable subject kind; an external token could otherwise spellsystem:operators, a group, or a malformed lookalike and rely on downstream parsing. Rejected: authentication maps credentials to an existing active Rise identity, constructs a typed canonicalSubjectId, and the resource API accepts external workload credentials only at token exchange (§1, §7). - Adding arbitrary JSON-path filters or index declarations to the generic resource API for identity lookup. Makes storage projections part of the public resource abstraction before any general use case exists. Rejected for fixed partial expression indexes over the built-in identity kinds and narrow Postgres lookup adapters; ordinary resource writes maintain the indexes transactionally without changing
ResourceStoreor client APIs (Implementation structure). - Allowing org bindings to target arbitrary subjects (a cross-org grant). Would let an org author a binding whose grant reaches a foreign org’s subjects, or an org-agnostic Controller, with no membership relationship to the granting org. Rejected in favor of the recipient boundary’s org-membership intersection (§1) — an org binding’s grant reaches only live members of its own org — with deliberate cross-org sharing deferred to a future first-class primitive (§10).
Implementation structure
Section titled “Implementation structure”Where the code lives, not what the model is. This realizes the sections above; it is a design intent, not a normative rule.
The evaluation logic is security-critical, and the value of a small, auditable core is highest exactly there. The carve-up’s goal is that the decision logic — union, Deny-wins, the subset check, wildcard replacement, the label-write gate — can be read, fuzzed, and tested without a database and without any Rise product concept. What a Deployment is, what rise.dev/ means, and how rows reach Postgres must never leak into it. One fact drives most of the structure: the RBAC objects — Role, RoleBinding, PlatformRole, PlatformRoleBinding — are all resources in the generic store (§3, §5), so reading a subject’s bindings, cap Denys included, is an ordinary ResourceStore read, not a bespoke authorization data path. Max token TTL is not among these reads: it is a platform-global config constant checked at token issuance (rise-backend-auth, §7), never a store-resolved fact.
Three tiers, separating security decisions, fact-retrieval, and product meaning:
- Tier 0 — pure policy algebra (new crate, e.g.
rise-authz-policy; ~zero deps). The Allow/Deny evaluator over(Verb, ResourceKind, Option<Subresource>), the Deny-aware subset check, Scope/selector lattice, wildcard replacement, and subject substitution — pure functions over small canonical types. - Tier 1 — the evaluation engine (new crate, e.g.
rise-authz). The §4 algorithm, Group expansion, structural org-admin detection, Deny-tier filtering, request-localAuthorizationSnapshot, effective-label diffing, recipient boundary, and list filtering. Its entry point accepts only a typedAuthenticatedPrincipal; no JWT claims or raw strings cross this boundary. - Tier 2 — Rise wiring (
rise-deploy). Authentication adapters, JIT User/UserIdentity provisioning, Group membership and configured-operator-identity resolution, seed data (system-admin, editableresource-owner/org-admin, and bootstrap bindings), the centralized authz choke point, handlers, list projection, and token wiring. Only/tokenaccepts external workload assertions.
The facts come from the store crate, not scattered in rise-deploy. The tree and binding reads are the existing ResourceStore trait, grown with generic hierarchy/label operations implemented in rise-resource-store-postgres’s Postgres store: ancestor chain, the K-inheriting subtree (WITH RECURSIVE over parent_uid), effectiveLabels resolution, and list-by-kind-under-scope — product-agnostic operations over a labeled hierarchical store. This matches the repo’s SQLX split (rise-resource-store-postgres owns resource-store SQLX; rise_deploy::db owns legacy typed-table SQLX). The authz engine’s product-specific seam remains MembershipResolver: its target implementation reads GroupMembership resources, derives ordinary org membership from their Group parents, and tests the active User’s live, active UserIdentity children against the process’s restart-loaded operator selector set; the engine adds §5’s direct qualifying admin binding as the sole bootstrap org-affiliation edge from bindings it already loaded. During migration only, a compatibility implementation may read legacy team_members, but that table is not part of the target model.
Postgres secondary indexes are storage projections, not API features. The generic resource API and ResourceStore trait do not gain arbitrary JSON-path search. Instead, rise-resource-store-postgres migrations add partial expression indexes over the built-in rise.dev kinds in resource_store.resources, following the existing ResourceDefinition index precedent:
- a unique live
UserIdentityindex on canonical(spec->>'issuer', spec->>'subject'), intentionally including inactive rows so deactivation cannot be bypassed by inserting or JIT-provisioning a duplicate active mapping; - a parent-and-issuer index over live
ControllerTrustPolicyandServiceAccountTrustPolicyrows, used to narrow policies beneath the explicitly targeted identity before claim-pattern evaluation in Rust; - a reverse live membership index on
GroupMembership.metadata.name; the generic(parent_uid, group, kind, name)uniqueness rule already prevents duplicate edges for the same User in one Group, and Group-to-members lookup already uses the generic(parent_uid, group, kind)index.
Every predicate includes the rise.dev API group, exact built-in Kind, and deletion_timestamp IS NULL, so a custom kind with the same name in another group cannot collide. Because these are expression indexes on the canonical JSONB rows, ordinary generic create/update/delete transactions maintain them automatically — no trigger-maintained mirror table or application dual-write can drift. Typed validation guarantees the indexed fields and canonical issuer form before persistence; the unique index remains the concurrency authority.
Authentication and membership use narrow IdentityLookup/MembershipLookup Postgres adapters alongside PgStore, backed by those indexes and returning typed facts or resource UIDs. They are not methods on the generic ResourceStore, are not exposed as client-selectable filters, and do not introduce identity lookup/index semantics into rise-resource-api or the pure authorization crate beyond the already-shared SubjectId. The identity indexes require storage migrations and a small Postgres adapter, but do not change URL shape, ResourceDefinition API, or authorization algebra. Generic indexed-field declarations for user-defined kinds are a separate future feature and are unnecessary for these fixed built-ins.
Lifecycle prerequisite: the generic resource envelope, store, and garbage collector carry optional metadata.ownerReferences, transactional reverse lookup, cycle prevention, finalizer-respecting dependent collection, and optional blockOwnerDeletion semantics. The canonical JSONB owner-reference array is the only stored representation; a GIN containment index supports reverse UID lookup without a mirror edge table or dual-write synchronization. Cycle validation covers structural parent and owner-reference edges as one lifecycle DAG, and deletion of any referenced owner starts dependent collection. Structural children and opted-in cross-tree dependents retain one aggregate cascade finalizer. An operator-only deletion-blockers subresource computes the concrete blockers from canonical rows, while best-effort structured lifecycle audit logs record why each newly tombstoned dependent entered deletion. Durable lifecycle events require a transactional outbox or Event resource in a later increment. GroupMembership admission then enforces the kind-specific optional-User-owner rule from §1. This lifecycle facility is independent of authorization evaluation and lands before GroupMembership is activated for runtime writes.
Prerequisite refactor: move the ResourceStore contract and canonical SubjectId, SubjectRef, ResourceKind, and Scope types into dep-light rise-resource-api, leaving PgStore + SQLX in rise-resource-store-postgres. Delegated (create, ResourceKind, token) and parsed authorization caps are engine concerns; workload trust validation, UID lookup, signing, act, and TTL live in rise-backend-auth. Per-item list filtering is engine work; projection is an API/server concern.
rise-authz-policy (pure algebra; own Verb/ResourceKind/Statement types; ~zero deps) ▲rise-authz (engine) ──► rise-resource-api (envelope types + ResourceStore trait, defines MembershipResolver canonical identity types, no sqlx) ▲ ▲ │ │ implrise-deploy ──► rise-resource-store-postgres (PgStore + identity/membership index adapters: impl MembershipResolver ancestors, effectiveLabels, indexed built-in lookups; over GroupMembership, UserIdentity + config; the sqlx home) seed data; authz.rs choke point; HTTP; list projection; token wiringThe payoff: the pure algebra and engine are testable with fakes and no Postgres, so the acceptance suite partitions three ways — pure-logic → Tier 0 unit tests; tree/membership → Tier 1 with fake stores; wiring (masking, list projection, token endpoint) → server integration — and the most security-sensitive code has the fewest dependencies. Two structure choices are left revisitable: whether tiers 0 and 1 are one crate (modules policy/engine) or two — leaning one with a hard internal boundary, split when the pure tier earns it (as rise-backend-docker was extracted only once its seam matured, #377) — and whether tier 0 reuses rise-resource-api’s verb/kind types or defines its own (leaning own, for a standalone, portable policy library at the cost of a thin mapping layer). Leaving the ResourceStore trait in the sqlx-bearing crate and letting the engine take the transitive database dependency was considered and rejected — it bloats the security core’s dependency graph and undercuts fake-testability.
Appendix: acceptance scenarios (normative)
Section titled “Appendix: acceptance scenarios (normative)”The initial conformance suite covers every scenario except those explicitly
tagged §9 deferred or product-operation deferred.
Resource and subject identity (§1, §3, §4, §8)
Section titled “Resource and subject identity (§1, §3, §4, §8)”- ResourceKind is group-qualified. Given two registered Kinds
alpha.example/Widgetandbeta.example/Widget, a Role allowing onlyalpha.example/Widgetnever authorizes the beta Kind. UnqualifiedWidgetis rejected in Roles, authorization details, and Scope parsing. - Version is not authority. Served versions
rise.dev/v1alpha1/Deploymentandrise.dev/v1/Deploymentnormalize to the samerise.dev/DeploymentResourceKind and policy. - Scope is qualified and canonical.
rise.dev/Project/acme/appparses; missing/unknown group or Kind, wrong ancestor count/order, dot/empty/extra components, query/fragment, and non-canonical encodings fail. A target in the same atomic transaction is accepted. - Scope omission is org-sensitive. An org RoleBinding defaults to its
parent
rise.dev/Organization/<name>; a PlatformRoleBinding for staticgroup:acme/platformorserviceaccount:acme/cidefaults to acme; every other PlatformRoleBinding defaults to"*". Explicit wildcard Scope for a static org-native subject is rejected. - Subject grammar is fail-closed.
group:acme/platformandserviceaccount:acme/ciparse; the oldacme/group:platformform, missing org/name, extra separators, unknown kinds, unrecognizedsystem:forms, and nonexistent literals are rejected. A${ref.subject}label value accepts only absoluteuser:<name>or org-relativegroup:<name>and canonicalizes the latter against the matched resource’s org; other kinds, aliases, full cross-org Group paths, and root-relative Groups fail closed. - One lowercase subject field. Serialized bindings accept
subject,subjectMembership,scope,labelSelector, androleRef; capitalized or pluralsubjectsforms are rejected by the closed schema. Platform bindings persist the PascalCase enumAnyorResourceOrganization; omission normalizes toAny, while explicitnulland other values fail closed. Org RoleBindings reject the field as structurally redundant. - Name-bound policy, UID-bound credentials. A privileged generic
recreation of
serviceaccount:acme/ciintentionally reactivates its name-bound policy, but a token containing the old UID fails authentication. The constrained Project product flow never reuses that retired canonical name. - Built-in placement. User and Controller are root resources; Group and ServiceAccount are Organization children; UserIdentity, GroupMembership, and workload trust policies have only their declared fixed parent.
- User identity is not email. Duplicate live
(issuer, subject)UserIdentity mappings fail; profile email may repeat or change without changing User name, UID, bindings, or login mapping. An inactive exact mapping is found and denied, never treated as unknown for JIT. - Operator identities select Users, not login methods. The selector set is
loaded at startup. A first validated login for an unknown pair atomically
creates a generated User and UserIdentity, including on a fresh store, and
concurrent attempts converge on the unique pair. A configured pair makes
that User an operator immediately; any already-linked secondary identity
authenticates as the same operator User, while an unknown identity creates
a distinct User and is never email-linked. Setting the User inactive blocks
every login and existing User token; setting one UserIdentity inactive
blocks that login and its operator-selector contribution without disabling
other identities. Deleting a configured mapping revokes the old User’s
operator expansion but a later valid login provisions the pair with a fresh
User UID; durable removal uses
active: falseor removes the selector and restarts/drains every old API instance. No old token revives.
Evaluation, Deny tiers, and membership (§1, §4, §5)
Section titled “Evaluation, Deny tiers, and membership (§1, §4, §5)”- Union and default deny. Applicable Allows union; absent Allow denies. An applicable retained Deny wins over every Allow.
- Platform Deny reaches admins. An acme org admin allowed all main and
subresources is denied a tuple removed by a PlatformRoleBinding Deny scoped
to
rise.dev/Organization/acme. - Org Deny exempts only that org’s admins. An acme RoleBinding Deny blocks ordinary acme Users and ServiceAccounts but not a current acme admin, and it has no effect in beta. An equivalent beta RoleBinding Deny blocks the same User in beta when they are not a beta admin and has no effect in acme.
- Operator ignores all Denies. A caller whose authenticated User UID is
in
system:operatorsretains every tuple despite platform, org, Group, or direct-User Denies. - Deny provenance survives replacement. Wildcard replacement may discard superseded Allow content but retains each Deny and its platform/org binding tier before §5 filtering.
- Membership expansion and lifecycle are live. Removing a User from a Group removes its Group-derived access on the next request. Deleting a User garbage-collects GroupMemberships that name it and carry its matching owner reference; markers without that reference survive and deliberately apply again if a User with the same canonical name is recreated. Neither form grants access while no live User of that name exists.
- Ordinary org membership requires a Group tie. A group-less User receives
no grant from an ordinary org RoleBinding naming that User or
system:authenticated. Adding any governed GroupMembership activates the boundary. A direct qualifying org-admin binding is the sole bootstrap exception. A PlatformRoleBinding withsubjectMembership: ResourceOrganizationobeys the same live resource-org clamp, whileAnymay deliberately reach a non-member. Omitted platform input normalizes toAny.ResourceOrganizationis a no-op for an inherently org-scoped subject or a root target; forsystem:authenticatedon an org resource it tests the actual caller, and a Controller does not match. - Absolute org subject remains useful. A PlatformRoleBinding targeting
org:acmemay grant acme subjects use of a root resource. Inside an acme RoleBinding,system:authenticatedclamps to the same org population. - Recipient boundary makes foreign subjects inert. An org RoleBinding targeting a foreign Group/ServiceAccount or org-agnostic Controller is accepted but contributes no grant and is reported by policy auditing.
- Wildcard collision uses authored forms. Literal and templated subjects never collide; selector keys differ independently; a value-specific replacement affects only resources matching that value.
Org-admin assignment (§5)
Section titled “Org-admin assignment (§5)”- Atomic first admin. Organization creation and its exact scope-only
PlatformRole/org-adminRoleBinding to an existing User commit together or not at all. The direct binding itself establishes that first User’s org membership; no pre-existing Group in the new org is required. - No magic Group. A direct User binding and a binding to any ordinary Group both establish admin status; the Group’s name has no special meaning.
- Multi-org admin. One User may match qualifying bindings in acme and beta, ignore each org’s own Denies only there, and respect each org’s distinct platform ceiling.
- Structural predicate. A label-selected, descendant-scoped, foreign-org, or differently referenced binding never establishes org-admin status.
- Global baseline versus per-org ceiling. Editing
PlatformRole/org-adminchanges every org’s baseline; a scoped platform Deny changes only its matching org. The Role accepts the ordinary statement schema, but a Deny in it arrives through the qualifying org RoleBinding and is ignored by the admin; it never substitutes for a platform ceiling. - Promotion includes removed org Denies. Adding a User to an admin-bound Group or creating an admin RoleBinding computes the net post-promotion EffectivePolicy. A writer lacking any newly exposed tuple is rejected.
- Admin removal is live. Removing the last matching direct/Group relationship revokes admin Allows and makes org Denies apply on the next request. If the User also has no Group tie, it ends their org membership entirely; an operator can recover by creating a new binding.
- SSO sync is governed. A sync principal may add ordinary directory Group membership only within its delegated delta. Adding membership to an admin-bound Group requires admin-equivalent authority.
Grant gate and consistency (§5, §6)
Section titled “Grant gate and consistency (§5, §6)”- Net delta respects platform Deny. A capped admin may add another admin covered by the same platform Deny; the new admin remains denied that tuple.
- Role edit spans bindings. Widening a Role computes effective deltas for every bound recipient and domain; an unbound Role body creates no authority until binding.
- Deleting Deny is a grant. Removing or narrowing a Deny passes the same effective-delta subset check as adding an Allow.
- Scope and selector containment are exact. Narrow authority cannot justify a broader Scope; same-key selectors order by specificity and different keys fail closed.
- Serializable with revocation. Concurrent grant and writer revocation cannot both commit against stale assumptions; one retries and re-evaluates.
- Identity mapping is a grant. Adding/retargeting UserIdentity or workload trust policy requires the parent’s effective authority; tightening/deleting it requires ordinary write authority.
- Leaving org ends org governance. Removing the final Group tie when no
direct qualifying admin binding remains drops org grants and org Denies.
Contextual platform Allows constrained by
ResourceOrganization, including direct User ownership, also stop matching. Any surviving unconstrained platform Allow remains limited by platform Denies, not by the departed org. - Request snapshot is local. Repeated checks in one request may reuse one AuthorizationSnapshot; the next request re-reads changed memberships, bindings, Denies, and identity state. No cross-request cache is accepted without the transactional authorization epoch.
- List-only projection is allowlisted. An item for which the caller holds
listbut notgetcontains onlyapiVersion,kind, and the documentedmetadatafields. Arbitrary top-level fields are absent even when they are neither namedspecnorstatus. - Per-item get expands lists. A listed item for which the caller also
holds
getis returned as the full stored object. Items lackinglistare omitted, and no applicable list grant yields a masked-empty collection.
Ownership and labels (§6)
Section titled “Ownership and labels (§6)”- Nearest label wins. A child owner label shadows its ancestor; removal re-exposes the inherited value.
- Relabel delta is subtree-wide. Adding, changing, or removing an access-driving label computes effective before/after authority across every descendant inheriting that value.
- Unauthorized redirect fails before lookup. An editor lacking the resulting grant cannot relabel ownership to themselves and receives no existence oracle for the attempted Group/User.
- Creation exception is narrow, immediate, and org-clamped. A genuine new
resource may set an access-driving
${ref.subject}value only touser:<caller's-stable-name>or a same-orggroup:<name>the caller belongs to. The pre-existing dynamic binding grants ownership in the same transaction without creating a RoleBinding. Another User or unrelated Group uses the general gate; a foreign/group-less existing User may be stored when the membership clamp makes the effective delta empty, receives no grant, and is reported by policy auditing. A later membership write that would activate that ownership passes the ordinary effective-delta grant gate. Removing the owner’s final affiliation removes the grant on the next request. Restore/upsert also uses the general gate. - Admin recovery is label-independent. Org-admin status derives only from exact scope-only RoleBindings and survives every ownership-label change.
Tokens and authorization details (§7)
Section titled “Tokens and authorization details (§7)”- Workload token exchange is target-bound. An external JWT presented to
serviceaccount:acme/ciconsiders only that target’s live trust policies and, on one match, issues a token for exactly its name and UID without an RBAC token-create check. - Workload failures are masked only on valid token routes. On a registered
ServiceAccount/Controller
/tokenroute, missing/deleted/disabled targets, a UID resolving to the wrong kind, invalid assertions, and zero/multiple policy matches return the same coarse authentication error./tokenon a kind that does not register it, such as Deployment, returns route-not-found before authentication. - Delegated issuance uses RBAC only. A Rise principal may mint an exact
target only with current capped
(create, target ResourceKind, token); target trust policies do not participate. - Modes cannot mix. A request containing both external assertion and Rise caller credential fails; Rise’s issuer is invalid in workload trust policy.
- Delegation chains explicitly. A minted identity may mint the next only
through its own live token-create grant; bounded nested
actrecords all delegators and never grants authority. - Main-resource detail omits subresources. A detail allowing
get/list on
rise.dev/Deploymentwith omittedsubresourcespermits the main resource only and denies status/finalizers. - Subresource detail is separate. A statement with
subresources:["status"]permits only status;"*"covers registered subresources but not the main resource. - Entries union by singular Scope. Two
rise.dev/rbacentries may give different permissions at different qualified Scopes; no Cartesian product is inferred. - Malformed details fail closed. Empty axes, unqualified kinds/Scopes, unknown types, malformed entries, and a present empty detail set fail token validation and never fall back to full policy.
- Cap applies everywhere. Live RBAC outside the detail union is denied for main/subresource checks, references, list items/projection, grant writes, and explain simulation.
- Revocation asymmetry and TTL. Target Role narrowing or deletion affects outstanding tokens immediately; revoking the caller’s token-create stops new issuance only; every token respects the platform max TTL.
Subresources (§2, §7)
Section titled “Subresources (§2, §7)”- Main and status writes are separated. Main update/apply preserves status and acquires no status field ownership; status update preserves every other field and does not increment generation.
- Finalizers are separated. Main writes preserve finalizers; only
(update, ResourceKind, finalizers)may change them. - Token is create-only. Delegated
POST .../tokenrequires create on the target subresource; get on the parent or token subresource grants nothing.
Deferred platform references (§9 deferred)
Section titled “Deferred platform references (§9 deferred)”- Use is independent of get. Reading and selecting a root platform resource remain separately grantable.
- Consuming-org isolation. A multi-org User may select an org-private RuntimeClass only while writing a consuming resource in that org.
- Materialization is a snapshot. Revoking use affects the next deployment, not an already materialized running resource.
Constrained product operations (§1, §5; product-operation deferred)
Section titled “Constrained product operations (§1, §5; product-operation deferred)”- Project ServiceAccount creation is confined (deferred). An ordinary Project user cannot generically create or delete ServiceAccounts. The product operation atomically allocates a never-reused canonical name and creates only its fixed Project-scoped policy/trust bundle. The resulting effective policy must be a subset of the caller’s current capped Project policy even though the caller need not hold generic Role/RoleBinding creation permission; arbitrary policy input and a previously retired canonical name are rejected. Its paired deletion cleans up only flow-owned authorization data and retires the identity.
References
Section titled “References”ROADMAP.md§§1–4 — owns live delivery status for the unified RBAC, authentication, subresource, and typed-object migration work this model informs.- Generic Resource API — the shipped, operator-only surface this model will govern.
crates/rise-resource-api,crates/rise-resource-store-postgres— the envelope types and theResourceStoretrait/impl the Implementation structure builds on.