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:
@@ -216,14 +216,65 @@ export const endpointsApi = {
|
||||
get(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 }
|
||||
if (target) body.target = target
|
||||
if (opts.policyoverride) {
|
||||
body.policyoverride = true
|
||||
body.overridereason = opts.overridereason
|
||||
}
|
||||
return api.post(`/endpoints/${assetId}/commands`, body)
|
||||
},
|
||||
listCommands(assetId) {
|
||||
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) {
|
||||
return api.get(`/endpoints/asset/${assetId}/telemetrymeta`)
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user