diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 6c7a0da0..6100fe0f 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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`) }, diff --git a/shopdb/utils/authz.py b/shopdb/utils/authz.py index b1cc404a..9d68eed8 100644 --- a/shopdb/utils/authz.py +++ b/shopdb/utils/authz.py @@ -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.