Skip to content
rise.Operator docs
GitHub

HTTP API

All generic resource operations are dispatched from a single wildcard route:

GET|POST|PUT|DELETE /api/v1/resources/{*path}

The path describes a collection, an item, a subresource, or the operational pending-deletion listing. The handler classifies the path, resolves the leaf kind’s parent chain via the resource store, and routes the request to the matching store operation.

A path always names the leaf collection first as {group}/{version}/{plural}, then carries ancestor names (root-most first), then optionally a leaf name and a subresource keyword. Let D be the leaf kind’s parent-chain depth (0 for a root-scoped collection like Organization, 1 for a kind whose declared parent is Organization, etc.).

PathMethodsOperation
{group}/{version}/{plural}/{ancestor}… (D names)GET, POSTList / create in the collection
{group}/{version}/{plural}/{ancestor}…/{name} (D+1)GET, PUT, DELETEGet / update / delete an item
{group}/{version}/{plural}/{ancestor}…/{name}/status (D+2)PUTStatus subresource update
{group}/{version}/{plural}/{ancestor}…/{name}/finalizers (D+2)PUTFinalizer subresource update
{group}/{version}/{plural}/{ancestor}…/{name}/deletion-blockers (D+2)GETDeletion-blocker diagnostics
{group}/{version}/{plural}/{ancestor}…/{name}/explain (D+2)GETThe caller’s own access explanation
{group}/{version}/{plural}/{ancestor}…/{name}/token (D+2)POSTToken issuance — ServiceAccount and Controller only
{group}/{version}/{plural}/uid:{uuid}GET, PUT, DELETEItem by UID
{group}/{version}/{plural}/uid:{uuid}/{sub}PUTstatus or finalizers by UID
{group}/{version}/{plural}/uid:{uuid}/deletion-blockersGETDeletion-blocker diagnostics by UID
{group}/{version}/{plural}/uid:{uuid}/explainGETThe caller’s own access explanation by UID
{group}/{version}/{plural}/uid:{uuid}/tokenPOSTToken issuance by UID
pending-deletionGETList tombstoned resources awaiting GC
policy-auditGETInstall-wide policy diagnostics

Ancestor segments are bare resource names; the ancestor kinds are derived from the leaf’s ResourceDefinition parent chain and never appear in the URL. pending-deletion and policy-audit are only valid as the sole path segment, so a resource may be named either without ambiguity.

Unversioned paths are not supported — {group}/{version} always names the leaf collection.

The leaf identifier may be given as uid:{uuid} instead of a name. A UID is globally unique, so the ancestor names are redundant: the uid: form is valid only as the sole identifier segment, immediately after {group}/{version}/{plural}, with no ancestor names. A uid: token anywhere else in the path is a 400.

UID addressing skips the ancestor-chain resolution entirely — it works even if an ancestor’s ResourceDefinition has been removed. This is the intended disaster-recovery path: if a RD is deleted while instances still exist, named addressing fails, but operators and controllers can still reach the orphaned rows by UID.

When a UID-prefixed identifier is used in a PUT URL, the body’s metadata.name is not validated against the URL — but it still must match the stored resource name. Resource names are immutable.

Every authenticated request — User or Controller alike — is authorized by the ADR-0001 engine: one (verb, ResourceKind, subresource?) decision per resource, evaluated against that resource’s own ancestry and effective labels. There is no operator gate in front of the API any more. An operator reaches everything because the seeded PlatformRoleBinding/system-admin says so and because an operator’s request ignores every Deny; anyone else reaches exactly what stored policy grants them.

CallerHow it is authorized
Operator (auth.operator_users, auth.operator_idp_groups)Expands to system:operators, whose seeded binding allows every verb on every kind and subresource
Any other authenticated userLive RBAC: bindings that name them, one of their Groups, org:<name>, or system:authenticated, plus dynamic ownership bindings resolved from rise.dev/owner
A ServiceAccount or Controller resource, holding an identity token from its token subresourceLive RBAC on its own subject (serviceaccount:<org>/<name>, controller:<name>), intersected with the token’s authorization_details ceiling. It has no Group ties and is never an operator
Controller (a live Controller resource)Live RBAC like any other principal: bindings naming its controller:<name> subject. See Controller authorization

The verbs map onto the HTTP surface directly: list and get for reads, create for POST (and for the token subresource), update for item PUT and for the status/finalizers subresources, delete for DELETE. Subresource permissions are separate from the main resource — a statement with no subresources field permits the main resource only.

Two read granularities exist, and they are independent grants:

  • list without get returns each item projected onto an allowlist — apiVersion, kind, and the metadata fields name, labels, effectiveLabels, and deletionTimestamp. spec, status, and any other top-level field are absent, and so are uid, revision, and discriminator.
  • list and get returns the full stored object for that item.

Every masked answer is byte-identical to the genuine one: status 404 and the body {"error": "resource not found"}, whether the resource is absent, an ancestor is absent, the row belongs to another collection, or the caller simply may not read it. Authorization runs before the request body is inspected, so a malformed body against an invisible resource is masked too rather than answered with a 400. On the create path the body checks run first instead — they read only the request and the collection registry, so the same 400 comes back whether or not the parent exists, and the parent is reached only once the answer can no longer depend on the body.

The masking is on status and body, not on timing: an existing-but-invisible resource costs an ancestry walk and a policy evaluation that an absent one does not. Treat the difference as observable by anyone who can measure it.

Items the caller cannot list are omitted, and their existence is masked: a caller with no applicable grant receives an empty collection, never a 403 confirming the scope is populated. Addressing one resource by name is masked the same way — a caller who holds no get on it receives the 404 a name that does not exist receives, on every verb, so the item path cannot hand back one name at a time what the listing withholds. A caller who can read the resource is told which verb they are short instead, with a 403: they already know it exists, and masking would only make the refusal harder to act on. Which collections exist stays visible to every caller who reaches the API at all, matching discovery being a property of the registry rather than of any one resource. Who reaches it is the platform-access policy’s question rather than authorization’s: these routes sit behind auth.platform_access, which under its default allow_all admits every authenticated user.

metadata.effectiveLabels is on every response, resolved live by walking the ancestor chain nearest-wins: a child with no value of its own reports the one it inherits, and a child that sets the key shadows its ancestor rather than unioning with it. It is the same walk a binding’s labelSelector matches against, so the value a client sees and the value authorization used can never disagree. Note the corollary: granting broad list beneath an ancestor exposes that ancestor’s inherited label values on every listed child.

auth.admin_users are admins within the default Organization for typed APIs only; they do not receive Operator and are not implicitly granted anything here. Access for anyone other than an operator comes from a binding.

Changing the metadata.ownerReferences set is authorized separately, because the edge is not inert: deleting the owner starts deletion of the dependent. Attaching or removing a reference both require use on the owner — §2’s verb for referencing a resource from another resource’s fields. Attaching additionally requires delete on the dependent when it already exists, since the edge is what makes it deletable through someone else’s resource; and blockOwnerDeletion: true requires delete on the owner, because that flag is not a reference but a hold on the owner’s own deletion. Removing a reference whose owner is already gone or draining is ungated — the store will not accept such a reference back, so gating its removal would leave the dependent with no legal write at all. Re-sending references that are already stored is an ordinary read-modify-write.

A write that changes who can do what — a Role or PlatformRole body, a binding, a GroupMembership, an identity or trust mapping, a User create or activation, or a label some binding selects on — additionally passes ADR-0001 §5’s grant gate: the authority the change would confer must already be held by the writer, over the same domain. Holding create on PlatformRoleBinding therefore does not let a caller bind a Role granting more than they have; the refusal is a 403.

How much that refusal says depends on where its parts came from. A recipient or a domain the caller supplied in this request is named; one read out of stored policy is not, because a refusal that echoes it is a read of policy the caller may hold nothing on. So a refused binding create names the subject and scope it asked for, while a refused Role edit or label write names neither. What the recipient would have gained is never named: the gate compares their whole effective policy over the domain, so a witness tuple can come from any binding delivering policy to them — including ones the caller has never seen. The full comparison is in the rise::audit resource.grant_gate record.

The check and the mutation are one SERIALIZABLE transaction with bounded retry, so a concurrent revocation either precedes the check or forces the write to be replayed against fresh facts.

Two writes reach the gate because of what they schedule rather than what they say. Attaching an owner reference to a policy resource is a scheduled delete of it — the cascade tombstones the dependent, and a tombstoned binding stops applying at once — so it is diffed as that delete. Deleting an Organization is diffed as the deletion of every Role and RoleBinding beneath it, for the same reason. In both cases removing a Deny is a grant, and routing it through a cascade must not be cheaper than asking for it directly. A write that keeps losing that race returns 503 with a retryable message rather than committing on stale assumptions.

Three consequences worth knowing:

  • An unbound Role body confers nothing, so authoring one is ungated. Binding it is what the gate weighs.
  • Deleting a binding or narrowing a Role is also a grant when it removes a Deny, and passes the same check.
  • Removing a membership is not gated — ADR-0001 §4 puts it outside — and a platform-tier Deny subjected to org:<name> or group:<org>/<name> stops matching a caller who drops that affiliation. So granting delete on GroupMembership lets those users shed such a cap along with the membership. Shipped policy grants it to nobody: the seeded owner binding is label-selected, so leaving is an explicit per-organization grant. Grant it where self-service exit is wanted, not where a subject-scoped platform Deny is load-bearing. Re-entry stays gated, so a caller who leaves cannot readmit themselves to recover what the affiliation carried.

Startup seeds five root policy resources described by ADR-0001. These are the whole of the shipped policy: an install that adds no bindings of its own grants nothing to anyone but operators and the subjects a rise.dev/owner label names.

ResourceGrantsMutability
PlatformRole/system-adminevery verb on every kind and subresourceimmutable
PlatformRoleBinding/system-adminsystem-admin to system:operatorsimmutable
PlatformRole/org-adminthe global org-admin baselineoperator-editable
PlatformRole/resource-ownerget, list, update, delete — no create, no subresourcesoperator-editable
PlatformRoleBinding/resource-ownerresource-owner to whoever rise.dev/owner namesoperator-editable

The two immutable rows are not the source of operator authority — the evaluator hardcodes that, so the guarantee survives a bad restore or a direct database write. They are its only inspectable record, which is why the store rejects PUT and DELETE on both, and why a startup finding either diverged from its shipped definition fails with the row named and remediation instructions rather than silently rewriting it. Seeding never overwrites the three editable rows, so an operator edit survives every restart and a deleted one is re-created on the next.

Editing org-admin changes what administrators may do in every organization, never who they are: admin standing comes from an exact org-root, scope-only RoleBinding, so no label or Group name can confer it. Editing resource-owner changes what ownership means platform-wide; an organization can instead override the default for itself with its own binding on the same (subject, label key) pair.

system:operators is reserved: no binding of either kind may name it except the seeded root PlatformRoleBinding/system-admin, and only carrying its shipped body.

An org RoleBinding’s subject must belong to the Organization the binding sits in. A subject that names its own organization — group:, serviceaccount:, org: — is checked at write time and refused on a mismatch: both organizations are fixed once the row exists, so such a binding would read as a cross-org grant while granting nothing, forever. user: and system:authenticated subjects are accepted, because their affiliation is a live membership question rather than a property of the identifier.

A new Organization gets its first administrator through bootstrap.admin on the Organization create (Organization bootstrap) — the atomic alternative to authoring an org-root RoleBinding by hand afterward.

Because the Organization is implied by placement, that subject also accepts the relative form group:<name>, expanded against the parent before the row is stored:

{ "subject": "group:platform", "roleRef": { "kind": "Role", "name": "viewer" } }

under acme stores group:acme/platform. PlatformRoleBinding has no parent Organization and so takes absolute subjects only.

Resource lifecycle operations are audit-logged on the rise::audit target. Records include resource.created, resource.updated, resource.deleted, resource.deletion_cascaded, resource.controller_status_updated, resource.controller_finalizers_updated, resource.user_status_updated, resource.user_finalizers_updated, resource.pending_deletion_listed, resource.deletion_blockers_listed, resource.policy_audited, resource.explained, resource.access_denied (a refused authorization decision), and resource.grant_gate (what the grant gate compared, including the operator short-circuit that produces no claims). Cascade records are best-effort after commit; durable delivery would require a transactional outbox or Event resource.

A controller JWT is matched against live ControllerTrustPolicy resources beneath a live root Controller and resolves to the ordinary principal controller:<name>, where <name> is the Controller resource’s name. From there a Controller is authorized exactly like a User: the choke point evaluates (verb, ResourceKind, subresource?) against stored policy, with no separate controller-specific gate. An org RoleBinding never reaches a Controller — it belongs to no organization (ADR-0001 §3) — so a Controller’s grants are always PlatformRoleBindings.

To let a controller reconcile a kind, create its identity and grant it access:

// 1. The Controller identity — root-scoped, one per controller process.
POST /api/v1/resources/rise.dev/v1alpha1/controllers
{ "apiVersion": "rise.dev/v1alpha1", "kind": "Controller",
"metadata": { "name": "widget-controller" }, "spec": {} }
// 2. What issuer/claims a token must present to authenticate as it.
POST /api/v1/resources/rise.dev/v1alpha1/controllertrustpolicies/widget-controller
{ "apiVersion": "rise.dev/v1alpha1", "kind": "ControllerTrustPolicy",
"metadata": { "name": "github-actions" },
"spec": { "issuer": "https://token.actions.githubusercontent.com",
"claims": { "aud": "rise-controller", "sub": "repo:acme/widget-controller:*" } } }
// 3. The permission to grant.
POST /api/v1/resources/rise.dev/v1alpha1/platformroles
{ "apiVersion": "rise.dev/v1alpha1", "kind": "PlatformRole",
"metadata": { "name": "widget-controller-role" },
"spec": { "statements": [{ "effect": "Allow", "kinds": ["example.dev/Widget"],
"verbs": ["get", "list", "update"],
"subresources": ["status", "finalizers"] }] } }
// 4. Bind it to the controller subject.
POST /api/v1/resources/rise.dev/v1alpha1/platformrolebindings
{ "apiVersion": "rise.dev/v1alpha1", "kind": "PlatformRoleBinding",
"metadata": { "name": "widget-controller-binding" },
"spec": { "subject": "controller:widget-controller",
"roleRef": { "kind": "PlatformRole", "name": "widget-controller-role" } } }

Several trust policies matching the same Controller are an ordinary match — a Controller may accept more than one issuer or claim shape. Policies matching different Controllers make a token ambiguous and the request is refused with 409. Deleting or tombstoning a Controller resource invalidates every token minted for it on the next request; a controller that still holds finalizers when its PlatformRoleBinding is removed can no longer clear them itself — a user with the same subresource grant can, through the same finalizers endpoint (see below).

All bodies follow the Kubernetes-style envelope: apiVersion, kind, and metadata are top-level fields alongside spec and status. The /api/v1/ URL prefix is the Rise HTTP API namespace and is unrelated to apiVersion in the body.

POST /api/v1/resources/example.dev/v1/widgets/acme
Content-Type: application/json
Authorization: Bearer <operator-jwt>
{
"apiVersion": "example.dev/v1",
"kind": "Widget",
"metadata": {
"name": "my-widget",
"annotations": {"team": "platform"},
"finalizers": [],
"ownerReferences": [{
"apiVersion": "rise.dev/v1alpha1",
"kind": "Organization",
"name": "acme",
"uid": "6e999cac-9c0b-4a94-a844-546ce8d508fb",
"blockOwnerDeletion": false
}]
},
"spec": {"color": "blue"}
}
  • The body’s apiVersion must be a served version of the collection.
  • The body’s kind must match the collection’s kind.
  • metadata.name is the resource’s name within its scope.
  • metadata.ownerReferences is optional lifecycle metadata. Every entry must identify the same live resource by API group/kind, name, and UID. References do not change the resource URL or grant authorization. Deleting an owner always starts dependent deletion. Optional blockOwnerDeletion defaults to false; when true, that dependent also keeps the owner visible until the dependent is collected.
  • A built-in Organization or ResourceDefinition cannot currently be the dependent side of an owner reference: requests that put metadata.ownerReferences on either kind are rejected. Owner-driven deletion tombstones dependents through the generic garbage collector, which would bypass the additional deletion safety checks for legacy Organization-owned records and resources that still use a ResourceDefinition. Both kinds may still be referenced as owners. A custom Organization kind in another API group is unaffected.
  • Server-controlled fields (uid, revision, discriminator, deletionTimestamp) are rejected on create.
  • status is rejected on create.
  • Response: 201 Created with the created resource (envelope projected to the URL’s served version).

An Organization create may carry a top-level bootstrap field to create its first administrator atomically, alongside the Organization itself:

POST /api/v1/resources/rise.dev/v1alpha1/organizations
Content-Type: application/json
Authorization: Bearer <operator-jwt>
{
"apiVersion": "rise.dev/v1alpha1",
"kind": "Organization",
"metadata": { "name": "acme" },
"spec": { "displayName": "Acme" },
"bootstrap": { "admin": "user:u-01j9z..." }
}

bootstrap is rejected with 400 on any kind other than a rise.dev/v1alpha1 Organization, and bootstrap.admin must be a user:<name> subject or the request is 400. When present, the handler builds a RoleBinding named org-admin-<user name> under the new Organization — exact org-root scope, no selector, roleRef: { kind: PlatformRole, name: org-admin }, the structural shape ADR-0001 §5 requires for org-admin standing — in the same transaction as the Organization create: require_create, the grant gate, and admission all run against it exactly as they would for an ordinary RoleBinding create. A gate refusal, or admission finding no live User named by admin (422, the store’s message), rolls the whole write back — the Organization is never persisted without the admin the caller asked to bootstrap alongside it. The response is the ordinary 201 Organization envelope; the binding is discoverable afterward by listing rolebindings under the org. bootstrap is a request parameter, never stored state — nothing about it appears in the Organization’s spec.

PUT /api/v1/resources/example.dev/v1/widgets/acme/my-widget
Content-Type: application/json
Authorization: Bearer <operator-jwt>
{
"apiVersion": "example.dev/v1",
"kind": "Widget",
"metadata": {
"name": "my-widget",
"revision": 7,
"annotations": {"team": "platform"},
"finalizers": ["widget-controller/cleanup"],
"ownerReferences": []
},
"spec": {"color": "red"}
}
  • metadata.revision is required; omitting it is 400.
  • A revision mismatch is 409 Conflict.
  • metadata.name must equal the URL name (or the stored row’s name when addressed by UID); resource names are immutable.
  • metadata.ownerReferences replaces the complete owner-reference set. Omitting it is equivalent to an empty set.
  • status is rejected on update (use the status subresource).
  • Reads (GET/LIST) work for any served version. Writes (POST/PUT) must use the storage version — a write targeting a served non-storage version is rejected with 422 Unprocessable Entity (version conversion is not yet implemented).

A successful create or update of a RoleBinding or PlatformRoleBinding re-runs the row-level policy-audit detectors against the row just written, scoped to that row alone, on the same transaction the write used. Every resulting finding is attached as an HTTP Warning response header, one per finding, in the form Kubernetes uses for admission warnings:

Warning: 299 - "roleRef names Role 'viewer' in Organization 'acme', which no longer exists; the binding grants nothing."

This never blocks or fails the write — diagnostics never reject a write (ADR-0001 §5) — and the response body is unchanged; a client that ignores Warning headers sees nothing different. See Policy audit for what the detectors catch.

Status subresource (controllers and users)

Section titled “Status subresource (controllers and users)”
PUT /api/v1/resources/example.dev/v1/widgets/acme/my-widget/status
Content-Type: application/json
Authorization: Bearer <controller-jwt>
{"status": {"phase": "Ready", "message": "all good"}}

The body’s status is stored under status.controllers[<key>] where <key> is the calling Controller’s resource name (for a controller token) or operator:<subject> — e.g. operator:user:<uid> — for a user write, authorized by (update, Kind, status). Other slots in status.controllers are unaffected. A controller can only write its own slot; a user write lands in its own writer-keyed slot and never overwrites a controller’s.

The status update applies unconditionally to the latest row (no revision needed) and increments revision.

Finalizer subresource (controllers and users)

Section titled “Finalizer subresource (controllers and users)”
PUT /api/v1/resources/example.dev/v1/widgets/acme/my-widget/finalizers
Content-Type: application/json
Authorization: Bearer <controller-jwt>
{"add": ["widget-controller/cleanup"], "remove": []}

add and remove are applied in a single transaction. Both lists may be empty. Adding or removing a system.rise.dev/* finalizer is rejected with 400. A controller can only add or remove finalizers named <its own resource name> or <its own resource name>/<reason>; a user write authorized by (update, Kind, finalizers) bypasses that ownership check — that path is the deadlock-break for stuck cascade deletions — but the reserved-prefix guard still applies.

DELETE /api/v1/resources/rise.dev/v1alpha1/organizations/acme
Authorization: Bearer <operator-jwt>

DELETE always cascades through structural children and owner-reference dependents (see Storage Model). Two response shapes:

  • 200 OK with {"deleted": true, "uid": "..."} — row had no finalizers or blocking dependents, hard-deleted in place. Non-blocking owner-reference dependents have already been tombstoned and continue draining asynchronously.
  • 202 Accepted with {"deleted": false, "markedForDeletion": true, "resource": ...} — row tombstoned, cascade in progress. The returned envelope carries the row at its post-stamp state (metadata.deletionTimestamp set, metadata.finalizers may include system.rise.dev/cascade-deletion).
GET /api/v1/resources/pending-deletion?limit=100
Authorization: Bearer <operator-jwt>

Returns tombstoned rows oldest-first, up to limit (1–1000, default 100). Useful for spotting deletions stuck on a finalizer.

GET /api/v1/resources/rise.dev/v1alpha1/organizations/acme/deletion-blockers
Authorization: Bearer <operator-jwt>

Returns the concrete resources currently preventing the addressed resource from being collected. Structural children are always blockers; owner-reference dependents appear only when their matching reference carries blockOwnerDeletion: true. Each item identifies the relationship and resource, including its deletion timestamp and finalizers. The response also reports whether system.rise.dev/cascade-deletion is currently present. This subresource is authorized by (get, Kind, deletion-blockers) on the resource being blocked, and computed from the canonical resource rows; it does not maintain a separate blocker table. The blockers are filtered per item like any other collection, on get rather than list because each item carries more than list granularity projects (its UID and its finalizers): one the caller cannot get is counted in hiddenBlockers rather than named, so the report never reads as “nothing is blocking this” while something is.

hiddenBlockers is itself a deliberate disclosure: it is a live count of resources the caller may not read, so a principal holding only (get, Kind, deletion-blockers) can watch it move as others create and delete children. That is accepted rather than overlooked — a blocker report that silently omits blockers is worse than one that says how many it withheld — but grant the subresource on that basis, not on the assumption that it reveals nothing about a subtree the caller cannot list.

GET /api/v1/resources/policy-audit?organization=acme&limit=200
Authorization: Bearer <operator-jwt>

Diagnoses shapes admission accepts but that grant nothing, or nothing durable — a dangling roleRef, a stale subject, a subjectMembership constraint that can never bite, an Organization with no live admin, and the rest of the detector table below. It never rejects a write; admission stays exactly as it is (ADR-0001 §5, “diagnostics never reject writes”).

Query parameters:

  • organization (optional): scope the audit to one Organization by name. Omit to audit every live Organization.
  • limit (optional, default 200, clamped to 1–1000): the maximum number of findings returned. truncated: true means more existed than limit allowed through.

A run is authorized by (get, Organization, policy-audit) on every Organization it covers: the one named by organization — a caller who may not read it gets the same 404 as for a nonexistent name — or, without it, every live Organization (403 if any one is refused, or if there is none). Operators hold it through system-admin; an org admin holds it on their own Organization. Granting it discloses that Organization’s name and the run’s scanned binding counts, including the install-wide platform binding count.

{ "effect": "Allow", "kinds": ["rise.dev/Organization"], "verbs": ["get"], "subresources": ["policy-audit"] }

Each run reads every binding in scope, so it is rate limited per replica: six requests per caller per minute, and thirty authorized runs per minute altogether. Over either budget the answer is 429.

Every finding names a resource — a binding, a Role, a GroupMembership, a labelled resource, or an Organization — and some name further resources in related and in their detail. A finding is included only when the caller holds list on every resource it names, exactly like the pending-deletion listing. Findings the caller may not see in full are counted in hiddenFindings rather than named, so a partial view never reads as “nothing to see here”. A finding naming a resource whose ancestry cannot be resolved is skipped and logged rather than failing the whole listing.

{
"findings": [
{
"severity": "warning",
"category": "danglingRoleRef",
"subject": { "uid": "...", "kind": "RoleBinding", "name": "viewers", "organization": "acme" },
"related": [],
"detail": "roleRef names Role 'viewer' in Organization 'acme', which no longer exists; the binding grants nothing."
}
],
"hiddenFindings": 3,
"truncated": false,
"scanned": {
"organizations": ["acme"],
"platformBindings": 6,
"organizationBindings": 4
}
}

shadowedBy ("deny" or "replacement") appears only on a shadowedAllow finding. scanned reports what the run actually looked at — every Organization it loaded bindings for, and how many platform and organization bindings it read — independent of what it found, so a report with zero findings reads as “scanned and healthy” rather than “scanned nothing”.

OrganizationWithoutAdmin on the configured default Organization (default_organization.name in settings) comes back as info rather than warning: that Organization is administered by operators through the seeded system-admin binding, not a per-organization admin, and giving it one is the multi-org direction rather than a gap to close today.

CategorySeverityWhat it means
danglingRoleRefwarningroleRef names no live Role/PlatformRole; the binding grants nothing.
staleSubjectwarningA literal subject names no live, active identity.
staleScopewarningA non-wildcard scope names a resource that no longer exists.
staleMembershipinfoA GroupMembership names a User with no live, active row. Markers deliberately outlive Users (ADR-0001 §1), so recreating the name makes the tie live again.
noOpMembershipConstraintwarningsubjectMembership: ResourceOrganization can never bite — the subject already carries its own organization, or belongs to none.
recipientBoundaryNoOpwarning or infoAn org RoleBinding’s subject can never (warning) or does not currently (info) belong to that Organization.
inertOwnerLabelwarningrise.dev/owner resolves to nobody the seeded ownership binding reaches.
selectorMatchesNothingwarningA binding’s labelSelector matches no resource in its scope.
shadowedAllowwarningA more specific binding drops this Allow, through a Deny (shadowedBy: "deny") or wildcard replacement (shadowedBy: "replacement").
organizationWithoutAdminwarning (info for the default Organization)No qualifying binding gives this Organization a live, active admin.
nonQualifyingOrgAdminReferencewarningA PlatformRole/org-admin reference that is not an exact org-root, scope-only binding — it grants the baseline statements without conferring admin standing or the Deny exemption.
GET /api/v1/resources/rise.dev/v1alpha1/organizations/acme/explain?verb=delete
Authorization: Bearer <user-jwt>

Explains the caller’s own access to the addressed resource for one (verb, subresource?) tuple: every collected statement bearing on it (with its binding, placement tier, and whether it was retained, superseded by wildcard replacement, or an exempted Deny), plus every binding that matched but contributed nothing and why. Explaining another principal’s access is not supported.

verb is required; subresource is optional and explains access to that subresource instead of the main resource. The subresource is its own grant, authorized as (get, Kind, explain) — the same shape as deletion-blockers — so it is refused, not masked, for a caller who can see the resource but was not granted explain on it. The shipped resource-owner Role excludes subresources entirely (ADR-0001 §6.2), so an ordinary owner cannot explain their own access until granted explain separately.

{
"decision": "deny",
"operatorOverride": false,
"ceilingAdmits": true,
"contributions": [
{
"binding": { "uid": "...", "kind": "RoleBinding", "name": "org-cap", "role": { "kind": "Role", "name": "org-cap" } },
"tier": { "organization": "acme" },
"statement": { "effect": "Deny", "kinds": ["rise.dev/Project"], "verbs": ["delete"] },
"retention": "exemptDeny:organizationAdmin"
}
],
"inert": [
{ "binding": { "uid": "...", "kind": "PlatformRoleBinding", "name": "...", "role": { "kind": "PlatformRole", "name": "..." } },
"tier": "platform", "reason": "resourceOrganization" }
]
}

tier is "platform" or {"organization": "<name>"}. retention is "retained", "supersededAllow", "exemptDeny:operator", or "exemptDeny:organizationAdmin". inert[].reason is one of "unresolvedSubject", "recipientBoundary", "resourceOrganization", or "danglingRoleRef".

Token subresource (ServiceAccount and Controller)

Section titled “Token subresource (ServiceAccount and Controller)”
POST /api/v1/resources/rise.dev/v1alpha1/serviceaccounts/acme/ci/token
Content-Type: application/json
{
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": "<external OIDC JWT>",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt"
}

Issues a Rise identity token for exactly the resource the URL names, and persists nothing — the subresource is create-only, so GET, PUT, and DELETE on it are 405. Only rise.dev/ServiceAccount and rise.dev/Controller register it; /token on any other kind is the ordinary route-not-found 404, before authentication. The response is the RFC 8693 shape: access_token, token_type: Bearer, issued_token_type, and expires_in.

Two request modes exist, and a request must be exactly one of them:

  • Workload token exchange — the request carries subject_token and no Rise credential. The assertion is validated only against the ServiceAccountTrustPolicy / ControllerTrustPolicy children of the addressed target (issuer, JWKS signature and expiry, then each policy’s claim matcher, with aud accepting a string or an array) and must match exactly one. There is no RBAC check and no search across identities. Every failure after the route is entered — a target that does not exist, is of another kind, or is being deleted; an issuer no policy names; a signature that does not verify; zero or several matching policies — returns the same 401 body, so the route never confirms whether a given identity or policy exists. A trust policy may not name Rise’s own issuer; creating one is a 400.
  • Delegated issuance — the request carries a Rise credential (a user session, or an identity token) and no subject_token. The caller needs (create, rise.dev/ServiceAccount, token) or (create, rise.dev/Controller, token) on that exact target under its own capped effective policy; get on the target, create on the main resource, and get on the subresource each grant nothing here. A refused caller who cannot get the target is told 404, like any masked item. The minted token records the caller in a bounded act chain (the platform limit is four delegators; a longer chain is 403), and a minted identity may mint the next only through its own live token-create grant — actor data never grants anything.

Both modes accept three optional fields. expires_in is clamped to server.auth_token_max_ttl_seconds, the platform-global maximum. audience (RFC 8693) names the verifier the token is for: omitted means Rise’s own API; any other value mints the same token with that aud, which Rise’s API refuses and the named verifier checks against Rise’s JWKS at GET {public_url}/api/v1/auth/jwks (identity tokens are always RS256-signed with that key, whatever their audience). authorization_details is an RFC 9396 list of rise.dev/rbac entries that only ever narrows the issued token; it is a statement about Rise’s own RBAC, so it is rejected together with an external audience:

{
"authorization_details": [
{
"type": "rise.dev/rbac",
"scope": "rise.dev/Organization/acme",
"permissions": [
{"verbs": ["get", "list"], "kinds": ["rise.dev/ServiceAccount"]},
{"verbs": ["update"], "kinds": ["rise.dev/Deployment"], "subresources": ["status"]}
]
}
]
}

Each entry has one qualified scope and one or more permission statements in Role grammar; entries union, statements union within their entry’s scope, and omitted subresources means the main resource only. An empty list, an unknown type, an empty axis, or an unqualified kind or scope is a 400 — never a fallback to full policy — and a token whose stored claim is malformed fails authentication.

The minted token is a bearer for this API. On every request Rise re-resolves its (sub, rise_uid) pair to one live resource and evaluates the identity’s current policy intersected with the ceiling: narrowing or deleting the target affects outstanding tokens immediately, and recreating the same name under a new UID does not revive them. Revoking the caller’s token-create grant stops new issuance only. Identity tokens are not accepted by the typed APIs.

CodeMeaning
200 / 201 / 202Operation succeeded (200 read/update, 201 create, 202 cascade tombstoned)
400Malformed path, body validation, reserved finalizer prefix, wrong version
401No credentials or JWT verification failed; an assertion the token subresource did not accept
403Authorized to read the resource but not to perform this verb, refused by the grant gate, or a delegation chain past the platform limit
404Unknown collection, unknown resource, kind/version mismatch on uid:, a resource the caller may not read, or /token on a kind that does not register it
405Method not valid for the addressed path (e.g. GET on /status, PUT on /token)
409Name conflict on create, revision conflict on update
422Write targeting a served non-storage version (no conversion yet)
503Discriminator generator exhausted (10 retries)
  • Only versions with served: true are addressable in the URL; at least one version must be served.
  • Exactly one version must have storage: true.
  • Reads and lists return rows stored at any declared version of the same group/kind — including a non-served storage version. The response apiVersion is projected to the requested version.
  • Writes (POST/PUT) must use the storage version. Conversion webhooks are not yet implemented.

Example: Organization-scoped custom resource

Section titled “Example: Organization-scoped custom resource”

Given a ResourceDefinition for Widget (group: example.dev, kind: Widget, plural: widgets, parent: {apiVersion: rise.dev/v1alpha1, kind: Organization}), the URL D = 1 and paths look like:

Terminal window
# List widgets under the 'acme' organization.
curl -H "Authorization: Bearer $TOKEN" \
https://rise.example.com/api/v1/resources/example.dev/v1/widgets/acme
# Get one widget.
curl -H "Authorization: Bearer $TOKEN" \
https://rise.example.com/api/v1/resources/example.dev/v1/widgets/acme/my-widget
# Get the same widget by UID (skips the org-chain resolution).
curl -H "Authorization: Bearer $TOKEN" \
https://rise.example.com/api/v1/resources/example.dev/v1/widgets/uid:0c0ffee0-dead-beef-cafe-000000000000
# Update its controller status.
curl -X PUT \
-H "Authorization: Bearer $CONTROLLER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": {"phase": "Ready"}}' \
https://rise.example.com/api/v1/resources/example.dev/v1/widgets/acme/my-widget/status