/api/v1/resources no longer refuses everyone but operators. Every request is
authorized by the ADR-0001 engine against the resource it names, and
authorization-changing writes additionally pass the write-time grant gate
inside a SERIALIZABLE transaction.
Operators are unaffected: an operator expands to system:operators, whose
seeded system-admin binding allows every verb on every kind and subresource,
and an operator’s request ignores every Deny. Nothing an operator could do
before is refused now.
What changed for everyone else is that they are evaluated rather than
refused outright. Shipped policy grants a non-operator nothing, so an install
that has authored no bindings sees no access change — but if you were relying
on “non-operator ⇒ 403” as the whole authorization story, that is no longer
what the code says. Who reaches the evaluation at all is unchanged: the
resource routes still sit behind the platform-access middleware, so
auth.platform_access.policy: restrictive keeps its allowlist in front of
everything below. Under the default allow_all that is every authenticated
user. Two visible differences even without any binding:
- A collection listing by a caller with no
list grant returns an empty
200, not a 403. This is deliberate existence masking: a 403 would
confirm the scope is populated. Naming one resource exactly is masked the
same way: a caller with no get on it receives a 404, on every verb.
A caller who can read the resource still gets a 403 naming the verb they
are short.
- Which collections exist is now visible to any authenticated caller, since
the path is classified before any per-resource decision. What a collection
contains is authorized per item.
Behaviour changes on the request/response surface:
metadata.effectiveLabels is present on every resource response, resolved
live from the ancestor chain (nearest value wins per key).
- A write returns the object at the granularity the caller may read. A
caller holding
(update, Kind, status) but not get gets the projected
metadata shape back, not the spec — a write verb is not a read grant.
- A main-resource
PUT may no longer change metadata.finalizers; carrying
the stored list back unchanged is fine, and changing them is the
finalizers subresource’s separate grant. A create carrying a reserved
system.rise.dev/* finalizer is rejected.
- Changing a
ResourceDefinition’s allowedStatusControllerIds now requires
operator standing: it grants controllers status and finalizer writes
outside the authorization model, so it is not something an ordinary
update on the definition should confer.
- A listing under an ancestor that does not exist returns an empty collection
rather than
404, so the ancestor path is not enumerable by name. Item
paths and creates under a missing ancestor still fail.
- Changing the
metadata.ownerReferences set is now authorized. Attaching or
removing a reference requires use on the owner; attaching additionally
requires delete on the dependent when it already exists, and setting
blockOwnerDeletion: true requires delete on the owner, since that flag
holds the owner’s own deletion open. Removing a reference whose owner is
already gone or draining is ungated, so a dependent is never frozen behind
an owner nobody can revive. Re-sending already-stored references is
unaffected. Note that the shipped resource-owner role does not include
use, so owning a resource does not by itself let you make it the owner of
another — author a binding that grants use where you need that.
deletion-blockers filters its blockers per item on get and reports a
hiddenBlockers count for those the caller cannot read. That count is a
deliberate disclosure: it moves as resources the caller cannot see are
created and deleted, so grant the subresource on that basis.
- A refused authorization-changing write says less than it did. The recipient
and the domain are named only when they came from the request; what the
recipient would have gained is never named, because the gate compares their
whole effective policy over the domain and a witness can come from any
binding delivering policy to them. The full comparison is in the
rise::audit resource.grant_gate record.
- Attaching a
metadata.ownerReferences entry to a Role, RoleBinding,
PlatformRole, or PlatformRoleBinding now passes the grant gate as
though the row were being deleted, and deleting an Organization passes it
for every Role and RoleBinding beneath it. Both were routes around the
gate: the cascade tombstones the row, and a tombstoned binding stops
applying immediately, so removing a Deny this way was ungated. An
operator is unaffected; a non-operator who could delete an Organization
containing policy they cannot lift is now refused.
- Creating a
rise.dev/User with spec.active true (the default) passes the
grant gate as an activation, matching the existing gate on flipping
active from false to true. Policy binds to the User name, so recreating
a name that stale bindings or GroupMembership markers still refer to makes
that policy reachable again. Both gates now measure the name’s Group ties as
well as the bindings naming it directly, so reactivating an offboarded
identity requires holding whatever its Groups grant.
- Security fix, outside the resource API. When the IdP takes over a team
that already existed in Rise —
sync_user_groups on login, and the Entra
sync — every pre-existing membership is now removed, not just the owners.
An IdP-managed team’s membership grants operator, admin, and platform access
by group (auth.operator_idp_groups and friends), and team names are
first-come, first-served while auth.allow_team_creation is on (the
default): a user who created a team named after a privileged IdP group and
listed themselves in it kept that membership through the takeover and
inherited the group’s authority. Operator impact: if you pre-created
teams that later became IdP-managed and relied on their Rise-side
memberships, those members lose their group-derived roles until their next
login, when the IdP re-asserts them. Members the IdP does not assert are
not restored — that is the point. The takeover and the team-membership API
now take a row lock on the team, so a membership write cannot slip into the
window between the purge and its commit.
- Deleting an
Organization with more than 64 Role/RoleBinding resources
beneath it is refused with 409 for a non-operator: each one has to be
weighed against the writer’s authority, and more than that cannot be done in
one transaction. Remove them first, or have an operator perform the delete.
- A write response for a caller holding a write verb and neither
get nor
list is now apiVersion, kind, and metadata.name only — previously it
carried labels and inherited effectiveLabels, which are org-wide and are
what a list grant pays for.
- Every
404 from these routes now carries the same body,
{"error": "resource not found"}. Clients that matched on the old wording
(“resource ‘x’ not found”, “parent path segment not found”) need updating.
Authorization also runs before the request body is inspected, so a PUT
with a malformed body against a resource the caller cannot read returns
404 rather than 400.
- An item a caller can
list but not get is returned projected onto
apiVersion, kind, and the metadata fields name, labels,
effectiveLabels, and deletionTimestamp — no spec, status, uid,
revision, or discriminator. Operators always hold get, so their list
responses are unchanged apart from effectiveLabels.
- A write that loses a serialization race after five attempts returns
503
with a retryable message instead of committing on stale facts.
A restriction must name a subject that resolves today. A principal’s own
Group ties are read through a live, active User resource of their name, and
no login path writes one yet, so every principal currently has an empty tie
set. A group-targeted binding therefore grants nothing — harmless — but a
cap expressed as a group-targeted Deny is equally never collected, so it
does not restrict anyone either. Until identity resolution lands, express a
restriction against system:authenticated, org:<name>, or the principal
itself.
A GroupMembership you write now is not inert in one respect: the grant gate
resolves a name’s ties without requiring the User row, so the marker is
weighed against whoever later tries to create or activate that name. That is
deliberate — it is what stops a deleted-and-recreated identity from silently
reclaiming its old groups.
Two audit record names changed: resource.operator_status_updated and
resource.operator_finalizers_updated are now resource.user_status_updated
and resource.user_finalizers_updated, because the caller need not be an
operator. Two records are new: resource.access_denied and
resource.grant_gate. Update any log-based alerting that matches the old
names.
No migration and no backfill runs, and no configuration changes.
auth.operator_users and auth.operator_idp_groups keep their meaning.