Add break-glass override plumbing for policy-held actions

Adds caller_is_role(), the body-answering counterpart to require_role, for routes that serve everyone but treat one class of caller differently. A scoped personal token is always refused here, matching require_role: scopes gate individual permissions, not roles. That invariant stops a leaked scoped agent token from inheriting override authority just because its owner is an admin - a scoped PAT is a narrower caller than its owner, admin included.

Frontend enqueueCommand now carries policyoverride with overridereason. Both are required together server-side and an override without a stated reason is rejected, so the caller must collect the reason before sending rather than discovering the refusal after the fact.
This commit is contained in:
benjamin.baker
2026-09-07 12:39:31 -04:00
parent 0e43ac6b8d
commit 63ce75f211
2 changed files with 78 additions and 1 deletions

View File

@@ -125,6 +125,32 @@ def caller_holds(permission_name: str) -> bool:
return True
def caller_is_role(rolename: str) -> bool:
"""Whether THIS caller holds a role, without refusing the request.
The body-answering counterpart to require_role, for a route that serves
everyone but treats one class of caller differently - break-glass override
being the case that prompted it.
A scoped personal token is ALWAYS refused here, matching require_role: scopes
gate individual permissions, not roles. This is the invariant that keeps a
leaked scoped agent token from inheriting override authority just because the
token's owner happens to be an admin. A scoped PAT is a narrower caller than
its owner, including when that owner is an admin.
Guests hold no roles, so an unauthenticated caller is always False.
"""
user = currentuser()
if user is None:
return False
try:
if get_jwt().get('patscopes') is not None:
return False
return user.hasrole(rolename)
except Exception:
return False
def require_permission(permission_name: str):
"""Gate a route behind a single permission. Admin role bypasses.