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) {
|
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`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user