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

@@ -216,14 +216,65 @@ export const endpointsApi = {
get(assetId) { get(assetId) {
return api.get(`/endpoints/${assetId}`) return api.get(`/endpoints/${assetId}`)
}, },
enqueueCommand(assetId, action, target = null) { // opts.policyoverride + opts.overridereason drive the admin break-glass path.
// Both are required together server-side: an override without a stated reason
// is rejected, so the caller must collect the reason before sending.
enqueueCommand(assetId, action, target = null, opts = {}) {
const body = { action } const body = { action }
if (target) body.target = target if (target) body.target = target
if (opts.policyoverride) {
body.policyoverride = true
body.overridereason = opts.overridereason
}
return api.post(`/endpoints/${assetId}/commands`, body) return api.post(`/endpoints/${assetId}/commands`, body)
}, },
listCommands(assetId) { listCommands(assetId) {
return api.get(`/endpoints/${assetId}/commands`) return api.get(`/endpoints/${assetId}/commands`)
}, },
// Active break-glass holds. Readable by any endpoints.view holder, since a
// suppressed action is drift an operator must be able to explain.
listPolicyHolds(assetId) {
return api.get(`/endpoints/${assetId}/policyholds`)
},
// Desired-mode actions that a hold would actually affect, with hold state.
listHoldableActions(assetId) {
return api.get(`/endpoints/${assetId}/holdableactions`)
},
// Admin-only. Takes one action name or an array of them; the server applies
// them atomically, so a bad name rejects the whole request rather than
// leaving a partial set of holds behind.
// minutes: null uses the server default window, 0 means indefinite.
placePolicyHold(assetId, actions, reason, minutes = null) {
const list = Array.isArray(actions) ? actions : [actions]
const body = { actions: list, reason }
// Explicit null check, NOT truthiness: 0 is a meaningful value here
// (indefinite) and a falsy test would silently downgrade it to the
// default window.
if (minutes !== null && minutes !== undefined) body.minutes = minutes
return api.post(`/endpoints/${assetId}/policyholds`, body)
},
releasePolicyHold(assetId, action) {
return api.delete(`/endpoints/${assetId}/policyholds/${action}`)
},
// Endpoint lock: an admin quarantine refusing ACTIONS from non-admins on this
// endpoint. Distinct from a policy hold - a hold suppresses reconciliation of
// one action and expires on its own; a lock gates callers and stands until an
// admin lifts it. Returns null when the endpoint is not locked.
//
// Readable by any endpoints.view holder on purpose: an operator whose action
// was refused needs to see who locked it and why, or they file a ticket about
// a machine that is working fine.
getEndpointLock(assetId) {
return api.get(`/endpoints/${assetId}/lock`)
},
// Admin-only. Reason is required and is quoted verbatim in the refusal shown
// to blocked users, so it is the message, not paperwork.
placeEndpointLock(assetId, reason) {
return api.post(`/endpoints/${assetId}/lock`, { reason })
},
releaseEndpointLock(assetId) {
return api.delete(`/endpoints/${assetId}/lock`)
},
telemetryMeta(assetId) { telemetryMeta(assetId) {
return api.get(`/endpoints/asset/${assetId}/telemetrymeta`) return api.get(`/endpoints/asset/${assetId}/telemetrymeta`)
}, },

View File

@@ -125,6 +125,32 @@ def caller_holds(permission_name: str) -> bool:
return True 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): def require_permission(permission_name: str):
"""Gate a route behind a single permission. Admin role bypasses. """Gate a route behind a single permission. Admin role bypasses.