Add multi-action policy holds and exempt collect_now from policy

Adds PolicyActionHold so an admin can suspend specific actions on a computer without editing the policy itself, covering the break-glass case where a machine must be quiesced immediately. Holds cover multiple actions in one atomic submission rather than one row per action, so a partial failure cannot leave a half-applied hold. Duration is free-form with 0 meaning indefinite (migration 0006 makes expiresat nullable); the prior fixed ceiling was removed because an admin quiescing a misbehaving machine does not know in advance how long it needs, and a hold silently expiring is worse than one that must be cleared deliberately. The What This Does explainer reflects indefinite holds.

collect_now exemption: refreshing status was denied on any endpoint with a non-empty effective policy even when the caller held the matching grant, because policy intersection silently removed the action. collect_now is a read-only status refresh with no side effects, so it now sits in POLICY_EXEMPT_ACTIONS alongside the other routine actions. Verified against a synthetic non-empty policy: operators can refresh status again while every write action stays gated.
This commit is contained in:
benjamin.baker
2026-09-07 12:38:55 -04:00
parent 627d3f5ff5
commit 52c9d2b009
10 changed files with 2174 additions and 19 deletions

View File

@@ -14,7 +14,7 @@ allowlist, so a bad tree or an unknown action never reaches the database.
from flask import request
from shopdb.api import (db, AuditLog, success_response, error_response,
ErrorCodes, require_permission)
ErrorCodes, require_permission, caller_holds)
from .routes import computers_bp
from ..models import (ConfigurationPolicy, ConfigurationPolicyAction,
@@ -26,6 +26,77 @@ from ..services.policyscope import validate_scope, ScopeError
_MODES = {ACTION_MODE_DESIRED, ACTION_MODE_GATE}
def _elevated_denial(actionnames):
"""Refuse unless the caller may author every elevated action in actionnames.
A policy is a deferred instruction to run something. Gating only the enqueue
route left authoring wide open: computers.edit alone could author a policy
carrying run_script and let the agent execute it, which is the whole point of
splitting the big four undone. The permission to CAUSE an action must be the
same permission required to RUN it.
caller_holds, not current_user.haspermission: a scoped personal token is a
narrower caller than its owner, and asking the owner is the exact bypass
require_permission exists to prevent.
"""
from plugins.endpoints.commands import elevated_permission_for
missing = []
for name in sorted(set(actionnames)):
elevated = elevated_permission_for(name)
if elevated and not caller_holds(elevated) and elevated not in missing:
missing.append(elevated)
if not missing:
return None
return error_response(
ErrorCodes.FORBIDDEN,
'This policy carries actions you may not author. Missing: '
+ ', '.join(missing),
http_code=403)
def _requested_actionnames(actions):
return [(row.get('action') or '').strip() for row in actions or []]
def _policy_actionnames(policy):
return [a.action for a in policy.actions]
def _composed_actionnames(configpolicyids):
"""Every action reachable through a set of configuration policies.
Composition is an authoring act: adding a configuration policy to a business
policy causes its actions to run on everything the business policy targets,
without the composer ever naming those actions.
"""
names = []
for cpid in configpolicyids:
policy = db.session.get(ConfigurationPolicy, cpid)
if policy:
names.extend(_policy_actionnames(policy))
return names
def _changes_effect(data, policy=None):
"""Whether this payload changes WHAT a policy does, rather than how it reads.
Scope widening, reactivation, priority and composition all change the effect
of an already-authored action without touching the action list, so gating the
action payload alone would leave every one of them open. Renaming a policy or
editing its description changes nothing that runs, so it stays ungated.
"""
for field in ('actions', 'items', 'scopejson', 'priority'):
if field in data:
return True
if 'isactive' in data:
# Only reactivation matters. Deactivating a policy cannot widen anything,
# and refusing it would strand a live policy nobody present can switch off.
was = getattr(policy, 'isactive', False) if policy is not None else False
return bool(data['isactive']) and not was
return False
def _feature_gate():
"""Return a 404 response when the policy feature is off, else None."""
if not policy_flag_enabled():
@@ -155,6 +226,10 @@ def create_configuration_policy():
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Invalid scope: {exc}')
denial = _elevated_denial(_requested_actionnames(data.get('actions')))
if denial:
return denial
policy = ConfigurationPolicy(
name=name,
description=data.get('description'),
@@ -189,6 +264,17 @@ def update_configuration_policy(policy_id: int):
http_code=409)
data = request.get_json() or {}
# Both sides. The incoming actions are what the caller is asking to cause;
# the existing ones are what a scope widening or reactivation would newly
# unleash on machines that were never in range before.
if _changes_effect(data, policy):
names = list(_policy_actionnames(policy))
if 'actions' in data:
names.extend(_requested_actionnames(data['actions']))
denial = _elevated_denial(names)
if denial:
return denial
if 'name' in data:
newname = (data['name'] or '').strip()
if newname and newname != policy.name:
@@ -242,6 +328,11 @@ def delete_configuration_policy(policy_id: int):
ErrorCodes.CONFLICT,
f'Cannot delete: used by {inuse} business policy item(s)',
http_code=409)
# Deleting a gate policy REMOVES a restriction, so it changes what endpoints
# will run just as surely as authoring one does.
denial = _elevated_denial(_policy_actionnames(policy))
if denial:
return denial
db.session.delete(policy)
AuditLog.log('deleted', 'ConfigurationPolicy', entityname=policy.name)
db.session.commit()
@@ -324,6 +415,12 @@ def create_business_policy():
priority=data.get('priority', 100),
source=POLICY_SOURCE_LOCAL,
)
requested = [row.get('configurationpolicyid')
for row in data.get('items') or []]
denial = _elevated_denial(_composed_actionnames(
[cpid for cpid in requested if cpid is not None]))
if denial:
return denial
err = _apply_items(policy, data.get('items'))
if err:
return err
@@ -349,6 +446,20 @@ def update_business_policy(policy_id: int):
'Policy is managed externally and is read-only',
http_code=409)
data = request.get_json() or {}
# Composition is the quiet escalation: attaching a configuration policy runs
# its actions everywhere this business policy reaches, without the composer
# naming a single one of them.
if _changes_effect(data, policy):
cpids = [it.configurationpolicyid for it in policy.items]
for row in data.get('items') or []:
cpid = row.get('configurationpolicyid')
if cpid is not None:
cpids.append(cpid)
denial = _elevated_denial(_composed_actionnames(cpids))
if denial:
return denial
if 'name' in data:
newname = (data['name'] or '').strip()
if newname and newname != policy.name:
@@ -387,6 +498,10 @@ def delete_business_policy(policy_id: int):
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
denial = _elevated_denial(_composed_actionnames(
[it.configurationpolicyid for it in policy.items]))
if denial:
return denial
db.session.delete(policy)
AuditLog.log('deleted', 'BusinessPolicy', entityname=policy.name)
db.session.commit()
@@ -405,16 +520,29 @@ def list_policy_actions():
Returns command actions tagged kind='command'. Monitor attachments are not
listed here: the UI builds that branch from the endpoints monitor catalog
(endpointsApi.monitors) so newly created monitors appear without a reload.
Actions the caller may not author are marked rather than hidden. Hiding them
would make the editor look like the capability does not exist; marking them
says it exists and is not yours, which is the difference between a confusing
UI and an honest one. The authoring gate is enforced server-side regardless.
"""
gate = _feature_gate()
if gate:
return gate
from plugins.endpoints.commands import COMMAND_ALLOWLIST
return success_response([
{'action': name, 'label': meta.get('label'),
'description': meta.get('description'), 'kind': 'command'}
for name, meta in COMMAND_ALLOWLIST.items()
])
from plugins.endpoints.commands import (COMMAND_ALLOWLIST,
elevated_permission_for)
catalog = []
for name, meta in COMMAND_ALLOWLIST.items():
elevated = elevated_permission_for(name)
catalog.append({
'action': name,
'label': meta.get('label'),
'description': meta.get('description'),
'kind': 'command',
'requiredpermission': elevated,
'mayauthor': not elevated or caller_holds(elevated),
})
return success_response(catalog)
@computers_bp.route('/policies/effective/<int:asset_id>', methods=['GET'])

View File

@@ -17,9 +17,13 @@
v-for="opt in filteredCommands"
:key="opt.action"
class="action-option"
:class="{ 'action-locked': opt.mayauthor === false }"
@mousedown.prevent="selectCommand(opt)"
>
{{ opt.label || opt.action }}
<span v-if="opt.mayauthor === false" class="action-lock-note">
needs {{ opt.requiredpermission }}
</span>
</div>
<div
class="action-option action-branch"
@@ -171,6 +175,9 @@ function enterMonitorLevel() {
}
function selectCommand(opt) {
// The server refuses this action anyway; declining here turns a confusing 403
// on save into an obvious "you cannot pick that" at the point of choosing.
if (opt.mayauthor === false) return
emit('update:modelValue', opt.action)
open.value = false
level.value = 'command'
@@ -254,6 +261,8 @@ function cancelCreate() {
}
.action-option { padding: 8px 12px; cursor: pointer; color: var(--text); }
.action-option:hover { background: var(--bg); }
.action-locked { opacity: 0.55; cursor: not-allowed; }
.action-lock-note { font-size: 0.75rem; color: var(--text-light); margin-left: 6px; }
.action-branch { font-style: italic; }
.action-create { font-weight: 600; color: var(--primary); }
.action-divider { height: 1px; background: var(--border); margin: 2px 0; }

View File

@@ -90,6 +90,46 @@
</button>
</div>
</div>
<!-- Endpoint lock. Rendered ABOVE the holds and in a heavier
treatment because it outranks them: a hold pauses one automatic
action, a lock stops every user but an admin from doing
anything here at all. Same-looking banners would train people
to read the two as synonyms. -->
<div v-if="endpointlock" class="endpoint-lock">
<span class="endpoint-lock-tag">LOCKED</span>
<span class="endpoint-lock-text">
by {{ endpointlock.lockedbyusername || 'a deleted user' }}
- {{ endpointlock.reason }}
</span>
<button v-if="canHoldPolicy" class="endpoint-lock-release"
:disabled="lockbusy"
@click="confirmReleaseLock">
{{ lockbusy ? 'Working...' : 'Unlock' }}
</button>
</div>
<!-- Active break-glass holds. Shown to every viewer, not just
admins: while a hold stands this PC is deliberately NOT being
reconciled to policy, and an operator seeing drift needs to
know it was authorized rather than chase a phantom fault. -->
<div v-if="policyholds.length" class="policy-holds">
<div v-for="hold in policyholds" :key="hold.action"
class="policy-hold">
<span class="policy-hold-action">{{ hold.action }}</span>
<span class="policy-hold-text">
policy enforcement paused by
{{ hold.createdbyusername || 'a deleted user' }}
until {{ holdExpiryLabel(hold) }} - {{ hold.reason }}
</span>
<!-- Release sits on the row, not in the header: holds are
per-action and several can stand at once, so a single
header button could only guess which one to end. -->
<button v-if="canHoldPolicy" class="policy-hold-release"
:disabled="holdbusy"
@click="confirmReleaseHold(hold)">Release</button>
</div>
</div>
</div>
<!-- Cloud identity. Laid out as a horizontal row in the same
@@ -178,8 +218,20 @@
:assetid="computer.assetid"
:accessmethods="computer.accessmethods || []"
>
<template #actions v-if="canRestart">
<button @click="confirmRestart" class="btn btn-warning btn-sm"
<template #actions v-if="canRestart || canHoldPolicy">
<!-- Admin only, and FIRST in the band. Placing a hold switches OFF
automatic policy enforcement, so it sits with the destructive
controls rather than with the read-only refresh. Red rather
than grey: grey undersold that this disables enforcement.
Leading the band because it is the control an admin reaches
for BEFORE restarting a machine they are about to work on. -->
<button v-if="canHoldPolicy" @click="openHoldDialog"
class="btn btn-danger btn-sm"
:disabled="holdbusy">
{{ holdbusy ? 'Working...' : 'Admin Hold' }}
</button>
<button v-if="canRestart" @click="confirmRestart"
class="btn btn-warning btn-sm"
:disabled="restarting">Restart Computer</button>
</template>
</AssetTriageBand>
@@ -470,6 +522,163 @@
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Computer not found</p>
</div>
<Modal v-model="showholddialog" :title="holdDialogTitle" size="medium">
<!-- Collapsed by default: an admin who already knows what a hold does
should not have to scroll past an explanation to reach the form, but
the explanation has to be one click away because this switches off
automatic enforcement and that is not self-evident from the title. -->
<details class="hold-explain">
<summary>What this does</summary>
<div class="hold-explain-body">
<!-- Split into two labelled halves because the dialog now performs two
unrelated jobs. Running them together as prose invited the reader
to treat the lock as part of the hold, which is the confusion this
whole feature has to avoid: one expires by itself, one does not. -->
<template v-if="holdableactions.length">
<p class="hold-explain-head">Hold</p>
<p>
ShopDB normally keeps this computer matching its policy. If policy
says a service should be running and someone stops it, ShopDB
starts it again automatically.
</p>
<p>
A hold pauses that automatic correction for the actions you tick,
so you can work on the machine without ShopDB undoing your
changes. It does not change anyone's permissions, and on its own
it does not stop you or anyone else running those actions by hand.
Each one can be released separately afterwards.
</p>
<p>
Give it a time and automatic correction resumes on its own when
that runs out - you do not have to remember to turn it back on.
Set the time to <strong>0</strong> and it never resumes by itself:
the hold stands until someone releases it here, and nothing will
prompt anyone to do that.
</p>
</template>
<p class="hold-explain-head">Lock</p>
<p>
Locking is separate, and it is about PEOPLE rather than policy. A
hold stops ShopDB correcting the machine; a lock stops other users
touching it. While a lock stands, nobody but an admin can run any
action against this computer - they are refused and shown your
reason instead.
</p>
<p>
A lock never expires, so use it when the machine must be left alone
for reasons ShopDB cannot see - mid-repair, a validated part
running, an audit in progress. It stays until an admin unlocks it
from this page. Agent check-in and monitoring keep working
throughout; you do not go blind on the machine.
</p>
</div>
</details>
<!-- Only the HOLD half depends on policy enforcement existing. Locking is
a caller-authority action with no relationship to policy, so it must
stay reachable on an endpoint with nothing to reconcile - which is
most of them. -->
<div v-if="holdableactions.length === 0" class="hold-empty">
<p>
Nothing on this computer is under automatic policy enforcement right
now, so there is nothing for a hold to pause. You can still lock it
below.
</p>
</div>
<template v-if="holdableactions.length">
<div class="form-group">
<label>Actions to pause</label>
<div class="hold-action-list">
<label v-for="entry in holdableactions" :key="entry.action"
class="hold-action-item"
:class="{ 'is-held': entry.held }">
<input type="checkbox" :value="entry.action"
v-model="holdactions" :disabled="entry.held" />
<span class="hold-action-name">{{ entry.action }}</span>
<span v-if="entry.held" class="hold-action-note">
already held
</span>
</label>
</div>
<small class="form-hint">
Pick as many as you need. They all share the reason and duration
below, and each can be released on its own afterwards.
</small>
</div>
</template>
<!-- Outside the branch above: the reason is required by BOTH the hold and
the lock, and it is the text a blocked user reads. -->
<div class="form-group">
<label for="holdreason">Reason</label>
<input id="holdreason" v-model="holdreason" class="form-control"
placeholder="Why this needs to be paused or locked" />
<small class="form-hint">
Recorded against your account and shown to anyone viewing this
computer.
</small>
</div>
<template v-if="holdableactions.length">
<div class="form-group">
<label for="holdminutes">Pause for (minutes)</label>
<input id="holdminutes" v-model.number="holdminutes" type="number"
min="0" step="1" class="form-control" />
<small class="form-hint">
Any length you need. Policy resumes on its own when the time runs
out, and you can release it sooner from this page.
</small>
<!-- The only warning an indefinite hold ever gets: nothing else in
the UI reports a stale one, so it has to be said at the moment
the choice is made. -->
<p v-if="holdisindefinite" class="hold-indefinite-warn">
<strong>0 means indefinite.</strong>
Policy enforcement for the actions above stays switched off until
someone releases it by hand. Nothing will remind you: the computer
will keep reporting healthy while it drifts out of policy.
</p>
</div>
</template>
<!-- Separate concept, offered here because it is the same moment: an
admin about to work on a machine usually wants BOTH the reconciler
off their back and other people's hands off the box. Worded as
"lock" rather than "hold" on purpose - the hold above expires on
its own, this does not, and calling both a hold would teach people
to expect an expiry that never comes. -->
<div class="form-group hold-lock-option">
<label class="hold-lock-check">
<input type="checkbox" v-model="holdlockendpoint" />
<span>Also lock this endpoint</span>
</label>
<small class="form-hint">
Blocks other users from running any action on this computer until an
admin unlocks it. They are shown your reason above. This does not
expire on its own.
</small>
</div>
<template #footer>
<!-- A disabled submit button is indistinguishable from a broken one:
the click is swallowed with no error and no change, so the user
concludes the feature is broken rather than that they missed a
field. Say which field is missing, at the point of the click. -->
<span v-if="holdBlockedReason" class="hold-blocked-reason">
{{ holdBlockedReason }}
</span>
<button class="btn btn-secondary" @click="showholddialog = false">
Cancel
</button>
<button class="btn btn-primary"
:disabled="!canSubmitHold"
@click="submitHold">
{{ holdSubmitLabel }}
</button>
</template>
</Modal>
</div>
</template>
@@ -486,6 +695,7 @@ import AssetTriageBand from '@/components/AssetTriageBand.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import Modal from '@/components/Modal.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { Copy } from 'lucide-vue-next'
import { getEntraDeviceUrls } from '@/utils/siteSettings'
@@ -611,12 +821,13 @@ const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
// populatedGroups comes from PluginAssetPanels, which knows which panels
// actually returned data; Software is additionally true when core has an
// installed-applications list of its own.
// Gates the Corporate Tools tab. endpoints.deploy currently bundles
// scheduledtask_run with run_script/update_app - see the note beside the
// permission declaration in plugins/endpoints/plugin.py if that gets split.
// Gates the Corporate Tools tab, which runs scheduledtask_run. That action now
// has its own grant (endpoints.corptools) instead of riding the old
// endpoints.deploy bundle, so seeing this tab no longer implies the ability to
// push installers or run arbitrary scripts.
// Declared ahead of detailtabs because the watch below evaluates that computed
// during setup, which reads canDeploy before this line would otherwise run.
const canDeploy = computed(() => authStore.hasPermission('endpoints.deploy'))
const canDeploy = computed(() => authStore.hasPermission('endpoints.corptools'))
const detailtabs = computed(() => {
const filled = assetPanels.value?.populatedGroups ?? new Set()
@@ -640,7 +851,7 @@ const detailtabs = computed(() => {
if (has('activity')) tabs.push({ key: 'activity', label: 'Activity' })
// Corporate Tools is permission-gated rather than data-gated: it is a set of
// fixed actions, not a rendering of collected telemetry, so there is no
// populatedGroup to key off. Hidden without endpoints.deploy because every
// populatedGroup to key off. Hidden without endpoints.corptools because every
// button here is an elevated command; the server re-checks regardless.
if (canDeploy.value && liveness.value) {
tabs.push({ key: 'corptools', label: 'Corporate Tools' })
@@ -662,6 +873,214 @@ const refreshing = ref(false)
const canCommand = computed(() => authStore.hasPermission('endpoints.command'))
const canRestart = computed(() => authStore.hasPermission('endpoints.restart'))
// Break-glass is admin-only and re-checked server-side against the CALLER, so a
// scoped token cannot inherit it from the admin who minted it. This flag only
// decides whether we offer the prompt.
const canOverridePolicy = computed(() => authStore.isAdmin)
// Active holds are shown to everyone with endpoints.view: a held action means
// this PC is deliberately not being reconciled, and an operator who cannot see
// that is left debugging drift that someone already authorized.
const policyholds = ref([])
// Placing or releasing a hold is the same authority as break-glass, so it is
// admin-gated here and re-checked server-side against the CALLER.
const canHoldPolicy = computed(() => authStore.isAdmin)
const showholddialog = ref(false)
const holdableactions = ref([])
const holdactions = ref([])
const holdreason = ref('')
const holdminutes = ref(240)
const holdbusy = ref(false)
const holdlockendpoint = ref(false)
// Drives the warning panel. Guards against the empty input, which v-model.number
// yields as '' rather than 0 - treating that as indefinite would warn while the
// field looks blank, and worse, submit as indefinite.
const holdisindefinite = computed(() => holdminutes.value === 0)
// Null when unlocked. Loaded for EVERY viewer, not just admins, so a blocked
// operator can read who locked the box instead of guessing at a refusal.
const endpointlock = ref(null)
const lockbusy = ref(false)
// The dialog does two independent jobs, and on an endpoint with no policy
// enforcement only the lock half is available. Submitting needs a reason plus
// at least one job selected - otherwise the button would post nothing.
const canSubmitHold = computed(() => {
if (holdbusy.value || !holdreason.value.trim()) return false
// An empty duration is not a valid hold: it is neither a window nor an
// explicit indefinite choice, so block rather than guess which was meant.
if (holdactions.value.length && !Number.isInteger(holdminutes.value)) {
return false
}
return holdactions.value.length > 0 || holdlockendpoint.value
})
// Mirrors canSubmitHold in the same order, so the message always names the
// first thing actually standing in the way. Empty when the form is submittable.
const holdBlockedReason = computed(() => {
if (holdbusy.value) return ''
if (!holdreason.value.trim()) return 'Enter a reason to continue.'
if (!holdactions.value.length && !holdlockendpoint.value) {
return holdableactions.value.length
? 'Select an action to pause, or tick "Also lock this endpoint".'
: 'Tick "Also lock this endpoint" to continue.'
}
if (holdactions.value.length && !Number.isInteger(holdminutes.value)) {
return 'Enter a duration in minutes, or 0 for indefinite.'
}
return ''
})
const holdSubmitLabel = computed(() => {
if (holdbusy.value) return 'Working...'
const count = holdactions.value.length
if (count && holdlockendpoint.value) {
return count > 1 ? `Place ${count} holds and lock` : 'Place hold and lock'
}
if (holdlockendpoint.value) return 'Lock endpoint'
return count > 1 ? `Place ${count} holds` : 'Place hold'
})
// Named for what the dialog can actually do here. Calling it "Place admin hold"
// on an endpoint with nothing to hold reads as a dead end.
const holdDialogTitle = computed(() =>
holdableactions.value.length ? 'Place admin hold' : 'Lock endpoint')
async function openHoldDialog() {
holdactions.value = []
holdreason.value = ''
holdminutes.value = 240
holdableactions.value = []
// Default off: locking everyone else out is the bigger side effect of the two
// and should be a deliberate tick, not something inherited from last time.
holdlockendpoint.value = false
showholddialog.value = true
try {
const response = await endpointsApi.listHoldableActions(computer.value.assetid)
holdableactions.value = response.data?.data || []
} catch (error) {
// Leave the list empty: the dialog then explains there is nothing under
// enforcement, which is the same thing the user needs to know either way.
holdableactions.value = []
}
}
async function submitHold() {
if (!canSubmitHold.value) return
const reason = holdreason.value.trim()
const actions = [...holdactions.value]
const wantshold = actions.length > 0
const wantslock = holdlockendpoint.value
// Phrased once here so every branch below agrees on how the holds are named.
const summary = actions.length === 1
? actions[0]
: `${actions.length} actions`
holdbusy.value = true
try {
if (wantshold) {
// One call for every action: the server applies them atomically, so a
// rejected name leaves no partial set of holds behind.
await endpointsApi.placePolicyHold(
computer.value.assetid, actions, reason, holdminutes.value)
}
// Sequenced after the hold, not in parallel: if the lock call fails the
// hold still stands and the banner says so. Firing both at once could
// leave the page reporting a lock whose hold never landed.
if (wantslock) {
await endpointsApi.placeEndpointLock(computer.value.assetid, reason)
}
if (wantshold && wantslock) {
toast.success(
`Policy enforcement paused for ${summary} and endpoint locked`)
} else if (wantslock) {
toast.success('Endpoint locked')
} else {
toast.success(`Policy enforcement paused for ${summary}`)
}
showholddialog.value = false
await loadPolicyHolds()
await loadEndpointLock()
} catch (error) {
toast.error(apiError(error, wantshold
? 'Failed to place hold'
: 'Failed to lock endpoint'))
} finally {
holdbusy.value = false
}
}
async function confirmReleaseHold(hold) {
if (!hold) return
if (!confirm(`Resume automatic policy enforcement for ${hold.action}?\n\n`
+ 'ShopDB will go back to correcting this action automatically, '
+ 'which may undo changes made while the hold was in place.')) return
holdbusy.value = true
try {
await endpointsApi.releasePolicyHold(computer.value.assetid, hold.action)
toast.success(`Policy enforcement resumed for ${hold.action}`)
await loadPolicyHolds()
} catch (error) {
toast.error(apiError(error, 'Failed to release hold'))
} finally {
holdbusy.value = false
}
}
async function loadPolicyHolds() {
if (!computer.value?.assetid) return
try {
const response = await endpointsApi.listPolicyHolds(computer.value.assetid)
policyholds.value = response.data?.data || []
} catch {
// Non-fatal: the holds banner is context, not a gate. A failure here must
// not blank the page or block the actions below it.
policyholds.value = []
}
}
async function loadEndpointLock() {
if (!computer.value?.assetid) return
try {
const response = await endpointsApi.getEndpointLock(computer.value.assetid)
endpointlock.value = response.data?.data || null
} catch {
// Same reasoning as the holds banner: context, not a gate. The server is
// the real enforcement point, so a failed read here cannot let anyone past
// a lock - it only means this page does not explain one.
endpointlock.value = null
}
}
async function confirmReleaseLock() {
if (!endpointlock.value) return
if (!confirm('Unlock this endpoint?\n\n'
+ 'Other users will be able to run actions against it again. '
+ 'Any policy holds you placed stay in force until they expire '
+ 'or you release them separately.')) return
lockbusy.value = true
try {
await endpointsApi.releaseEndpointLock(computer.value.assetid)
toast.success('Endpoint unlocked')
await loadEndpointLock()
} catch (error) {
toast.error(apiError(error, 'Failed to unlock endpoint'))
} finally {
lockbusy.value = false
}
}
function holdExpiryLabel(hold) {
if (!hold.expiresat) return 'unknown'
// Server sends naive UTC isoformat; mark it as UTC so it renders in local time
// rather than being read as local and shifting by the offset.
const raw = hold.expiresat.endsWith('Z') ? hold.expiresat : `${hold.expiresat}Z`
const when = new Date(raw)
return Number.isNaN(when.getTime()) ? 'unknown' : when.toLocaleString()
}
// Warranty fetch + hero badge (shared across asset detail pages).
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
@@ -725,6 +1144,8 @@ onMounted(async () => {
} catch (appError) {
}
loadLiveness(computer.value?.assetid)
loadPolicyHolds()
loadEndpointLock()
// Warranties load via useWarrantyBadge (watches computer.assetid).
} catch (error) {
console.error('Error loading computer:', error)
@@ -836,6 +1257,33 @@ async function waitForCommand(commandId, { attempts = 15, delayMs = 1000 } = {})
return 'timeout'
}
// A policy denial is the server telling us this action is not exposed on this
// endpoint. For an admin that is recoverable, but only deliberately: we surface
// what was refused, demand a written reason, and send it as an explicit
// override. Returns true when the retry succeeded.
async function retryWithOverride(assetid, action, target, error, label) {
if (!canOverridePolicy.value) return false
const message = apiError(error, '')
if (!message.includes('not permitted by policy')) return false
const reason = prompt(
`Policy does not permit ${action} on ${label}.\n\n`
+ 'As an admin you may override this. The override is recorded against '
+ 'your account, and desired-state enforcement for this action is paused '
+ 'until the hold expires.\n\n'
+ 'Reason for the override:')
// Cancel returns null; an empty or whitespace reason is refused server-side
// anyway, so do not spend a round trip on it.
if (!reason || !reason.trim()) return false
await endpointsApi.enqueueCommand(assetid, action, target, {
policyoverride: true,
overridereason: reason.trim(),
})
await loadPolicyHolds()
return true
}
// Destructive: queues a system_restart that reboots the PC immediately
// (shutdown /r /t 0 on the agent). The confirm gate is the last stop before an
// unrecoverable action; the server re-checks endpoints.restart regardless.
@@ -849,7 +1297,17 @@ async function confirmRestart() {
await endpointsApi.enqueueCommand(computer.value.assetid, 'system_restart')
toast.success(`Restart queued for ${label}`)
} catch (error) {
toast.error(apiError(error, 'Failed to queue restart'))
try {
const overridden = await retryWithOverride(
computer.value.assetid, 'system_restart', null, error, label)
if (overridden) {
toast.success(`Restart queued for ${label} (policy overridden)`)
return
}
toast.error(apiError(error, 'Failed to queue restart'))
} catch (retryError) {
toast.error(apiError(retryError, 'Failed to queue restart'))
}
} finally {
restarting.value = false
}
@@ -909,7 +1367,7 @@ const firingTask = ref('')
// Elevated: queues a scheduledtask_run. The confirm gate mirrors
// confirmRestart - these start real work on a live machine and some of them
// change system state - and the server re-checks endpoints.deploy regardless.
// change system state - and the server re-checks endpoints.corptools regardless.
//
// Tools marked destructive get a TYPED confirmation rather than an OK button,
// for the same reason Restart has its own gate: applying firmware updates can
@@ -1110,6 +1568,214 @@ async function confirmRetire() {
justify-content: flex-end;
gap: 0.5rem;
}
/* Warning-toned, not error-toned: a hold is a deliberate, audited state, not a
fault. It should read as "someone did this on purpose" at a glance. */
.policy-holds {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.6rem;
}
/* Heavier than .policy-hold on purpose. A lock outranks a hold - it stops every
non-admin from acting at all - so it must not read as one more amber note in
a stack of them. */
.endpoint-lock {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
padding: 0.45rem 0.7rem;
font-size: 0.85rem;
border-left: 3px solid var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
border-radius: 3px;
}
.endpoint-lock-tag {
font-family: monospace;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--danger);
}
.endpoint-lock-text {
color: var(--text);
}
.endpoint-lock-release {
margin-left: auto;
padding: 0.1rem 0.5rem;
font-size: 0.78rem;
color: var(--danger);
background: transparent;
border: 1px solid var(--danger);
border-radius: 3px;
cursor: pointer;
}
.endpoint-lock-release:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 18%, transparent);
}
.endpoint-lock-release:disabled {
opacity: 0.55;
cursor: default;
}
/* Scrolls rather than growing the dialog: endpoints under broad policy can
carry a long action list, and the reason/duration fields below must stay
reachable without hunting for them. */
.hold-action-list {
display: flex;
flex-direction: column;
gap: 0.15rem;
max-height: 13rem;
overflow-y: auto;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
}
.hold-action-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-weight: 400;
cursor: pointer;
}
.hold-action-item input {
width: auto;
margin: 0;
}
.hold-action-item.is-held {
opacity: 0.6;
cursor: default;
}
.hold-action-name {
font-family: var(--font-mono, monospace);
font-size: 0.9rem;
}
.hold-action-note {
font-size: 0.78rem;
color: var(--text-light);
font-style: italic;
}
/* Deliberately loud. An indefinite hold is the one setting here with no
self-correcting behaviour behind it.
Tinted from --warning via colour-mix rather than a fixed pale hex: a light
cream panel keeps its dark body text in dark mode and turns unreadable. */
.hold-indefinite-warn {
margin: 0.5rem 0 0;
padding: 0.5rem 0.65rem;
border-left: 3px solid var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
font-size: 0.82rem;
line-height: 1.45;
color: var(--text);
}
/* Separated from the fields above it: this is a second, heavier decision than
the hold being configured, not another attribute of it. */
.hold-lock-option {
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.hold-lock-check {
display: flex;
align-items: center;
gap: 0.45rem;
font-weight: 600;
cursor: pointer;
}
.hold-lock-check input {
width: auto;
margin: 0;
}
.policy-hold {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem;
font-size: 0.85rem;
padding: 0.35rem 0.6rem;
border-left: 3px solid var(--warning);
background: color-mix(in srgb, var(--warning) 10%, transparent);
border-radius: 3px;
}
.policy-hold-action {
font-family: monospace;
font-weight: 600;
}
.policy-hold-text {
color: var(--text);
}
/* Pushed to the row end so the release affordance reads as belonging to that
specific hold rather than to the banner as a whole. */
.policy-hold-release {
margin-left: auto;
padding: 0.1rem 0.5rem;
font-size: 0.78rem;
color: var(--warning);
background: transparent;
border: 1px solid var(--warning);
border-radius: 3px;
cursor: pointer;
}
.policy-hold-release:hover:not(:disabled) {
background: color-mix(in srgb, var(--warning) 18%, transparent);
}
.policy-hold-release:disabled {
opacity: 0.55;
cursor: default;
}
.hold-explain {
margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
}
.hold-explain summary {
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
}
.hold-explain-body {
margin-top: 0.5rem;
font-size: 0.875rem;
line-height: 1.5;
}
.hold-explain-body p {
margin: 0 0 0.6rem;
}
.hold-explain-body p:last-child {
margin-bottom: 0;
}
/* Labels the two halves apart. Without them the lock paragraphs read as more
detail about the hold, which is the exact conflation to avoid. */
.hold-explain-head {
margin-bottom: 0.25rem !important;
font-weight: 700;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
color: var(--text-light);
}
.hold-explain-head:not(:first-child) {
margin-top: 0.9rem !important;
}
.hold-empty {
font-size: 0.9rem;
color: var(--text-light);
}
/* Sits to the left of the footer buttons, next to the control it explains. */
.hold-blocked-reason {
margin-right: auto;
align-self: center;
text-align: left;
font-size: 0.8rem;
color: var(--text-light);
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
/* The chip styles live in AssetTriageBand's scoped block, so they do not reach
this copy of the markup. Kept in step with that component deliberately. */
.triage-chip {

View File

@@ -0,0 +1,68 @@
"""Add policyactionholds (break-glass desired-state suppression).
Backs the admin override: when an admin break-glasses past a policy denial on a
desired-mode action, a hold row suspends reconciliation of that action on that
endpoint until expiresat, so the override is not immediately undone by the next
drift-correcting enqueue.
Rows are never swept: expiry is evaluated at resolve time against expiresat, and
an expired row stays as the audit record of what was suppressed and why.
Idempotent: the create_table is skipped when the table already exists (e.g. a
test DB built by db.create_all()).
Revision ID: computers0005policyactionholds
Revises: computers0004policies
"""
from alembic import op
import sqlalchemy as sa
revision = 'computers0005policyactionholds'
down_revision = 'computers0004policies'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing = set(insp.get_table_names())
if 'policyactionholds' not in existing:
op.create_table(
'policyactionholds',
sa.Column('policyactionholdid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('expiresat', sa.DateTime(), nullable=False),
sa.Column('createdbyuserid', sa.Integer(), nullable=True),
sa.Column('createdbyusername', sa.String(length=100), nullable=True),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.PrimaryKeyConstraint('policyactionholdid'),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.ForeignKeyConstraint(['createdbyuserid'], ['users.userid'],
ondelete='SET NULL'),
)
op.create_index('idx_policyactionhold_asset', 'policyactionholds',
['assetid'])
op.create_index('idx_policyactionhold_expiry', 'policyactionholds',
['expiresat'])
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing = set(insp.get_table_names())
if 'policyactionholds' in existing:
op.drop_index('idx_policyactionhold_expiry',
table_name='policyactionholds')
op.drop_index('idx_policyactionhold_asset',
table_name='policyactionholds')
op.drop_table('policyactionholds')

View File

@@ -0,0 +1,85 @@
"""Allow indefinite policy action holds (policyactionholds.expiresat nullable).
0005 created expiresat NOT NULL because every hold was time-boxed: a hold that
nobody remembered to clear would expire on its own and policy would resume. That
self-healing property was the point of the column.
Admins now need to hold an action for work that cannot be bounded up front, so a
null expiresat is permitted and means indefinite - the hold stands until it is
released by hand. Everything reading this table must test
`expiresat IS NULL OR expiresat > now`; testing only the comparison silently
drops indefinite holds and lets policy resume against an admin who asked it not
to.
No backfill: existing rows all carry a real expiry and keep it. Widening
NOT NULL to NULL rewrites no data.
Idempotent: the alter is skipped when the column is already nullable, so a test
DB built by db.create_all() from the current model is left alone.
Revision ID: computers0006holdindefinite
Revises: computers0005policyactionholds
The id is kept short deliberately: alembic_version.version_num is VARCHAR(32),
and a longer id applies the DDL and then fails on the stamp, leaving the DB
migrated but recorded as un-migrated.
"""
from alembic import op
import sqlalchemy as sa
revision = 'computers0006holdindefinite'
down_revision = 'computers0005policyactionholds'
branch_labels = None
depends_on = None
def _expiresat_is_nullable(bind):
"""True when policyactionholds.expiresat already permits null.
Returns None when the table is absent entirely, which lets both directions
no-op rather than raising on a DB where the computers chain has not built
the table yet.
"""
insp = sa.inspect(bind)
if 'policyactionholds' not in set(insp.get_table_names()):
return None
for column in insp.get_columns('policyactionholds'):
if column['name'] == 'expiresat':
return bool(column.get('nullable'))
return None
def upgrade():
bind = op.get_bind()
nullable = _expiresat_is_nullable(bind)
if nullable is None or nullable:
return
op.alter_column('policyactionholds', 'expiresat',
existing_type=sa.DateTime(),
nullable=True)
def downgrade():
"""Re-impose NOT NULL, giving any indefinite hold a bounded expiry.
A straight alter would fail on the rows this feature exists to create, so
indefinite holds are first stamped with the current time - expiring them
rather than destroying them. That is the conservative direction: on
downgrade, policy resuming is safer than reconciliation staying suppressed
by a row the older code cannot represent. The audit row survives either way.
"""
bind = op.get_bind()
nullable = _expiresat_is_nullable(bind)
if nullable is None or not nullable:
return
op.execute(sa.text(
'UPDATE policyactionholds '
'SET expiresat = CURRENT_TIMESTAMP '
'WHERE expiresat IS NULL'))
op.alter_column('policyactionholds', 'expiresat',
existing_type=sa.DateTime(),
nullable=False)

View File

@@ -16,6 +16,8 @@ from .policy import (
POLICY_SOURCE_SHIPPED,
ACTION_MODE_DESIRED,
ACTION_MODE_GATE,
PolicyActionHold,
DEFAULT_HOLD_MINUTES,
)
__all__ = [
@@ -32,4 +34,6 @@ __all__ = [
'POLICY_SOURCE_SHIPPED',
'ACTION_MODE_DESIRED',
'ACTION_MODE_GATE',
'PolicyActionHold',
'DEFAULT_HOLD_MINUTES',
]

View File

@@ -202,3 +202,82 @@ class BusinessPolicyItem(BaseModel):
def __repr__(self):
return (f"<BusinessPolicyItem bp={self.businesspolicyid} "
f"cp={self.configurationpolicyid}>")
# Default life of a break-glass suppression hold, used when the caller names no
# window. Long enough to comfortably outlast one maintenance window, short
# enough that nobody has to remember to clear it: policy resumes on its own.
#
# A caller MAY name any window instead, including none at all (expiresat NULL,
# an indefinite hold). That is a deliberate escape hatch for work that cannot be
# bounded up front, and it forfeits the self-healing this default exists to
# provide - see the class docstring.
DEFAULT_HOLD_MINUTES = 240
class PolicyActionHold(BaseModel):
"""A time-boxed suspension of desired-state reconciliation for one action.
An admin break-glass override on a `gate`-mode action is a one-shot - policy
only said "not exposed", and nothing reverses it. An override on a
`desired`-mode action is a fight: the admin stops a service and the
reconciliation loop restarts it on the next check-in, so the override looks
broken in production. A hold records that fight's outcome server-side, and
the resolver drops the held action out of the desired set until it expires.
Expiry is evaluated at RESOLVE time against expiresat, never by a sweeper
job. A sweeper that stalls would silently extend every hold it failed to
clear, which is policy disabled by accident - the failure mode this design
exists to avoid. Rows are left behind after expiry deliberately: an expired
hold is the audit record of what was suppressed, by whom, and why.
expiresat is NULLABLE, and null means indefinite: the hold applies until an
admin releases it by hand. This is the one case where the self-healing
property above does not hold, so treat it as the sharp edge it is - an
indefinite hold silently disables reconciliation of that action forever, and
the endpoint will keep reporting healthy while it drifts. Nothing in the
system surfaces a stale hold; the only guard is the admin who placed it.
Every read of this table must therefore test `expiresat IS NULL OR
expiresat > now`. Testing only the comparison drops indefinite holds, which
would let policy quietly resume against an admin who asked it not to.
"""
__tablename__ = 'policyactionholds'
policyactionholdid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
)
# Allowlisted action name, same vocabulary as ConfigurationPolicyAction.
action = db.Column(db.String(100), nullable=False)
# Naive UTC, matching BaseModel timestamps. Null means indefinite - the
# hold stands until released by hand.
expiresat = db.Column(db.DateTime, nullable=True)
# Who placed it. The id goes null if the user is deleted, but the username
# snapshot survives: a break-glass record that forgets who used it when the
# account is removed is worthless at review time.
createdbyuserid = db.Column(
db.Integer,
db.ForeignKey('users.userid', ondelete='SET NULL'),
nullable=True,
)
createdbyusername = db.Column(db.String(100), nullable=True)
reason = db.Column(db.Text, nullable=False)
__table_args__ = (
db.Index('idx_policyactionhold_asset', 'assetid'),
db.Index('idx_policyactionhold_expiry', 'expiresat'),
)
def __repr__(self):
until = self.expiresat or 'indefinite'
return (f"<PolicyActionHold asset={self.assetid} {self.action} "
f"until={until}>")

View File

@@ -57,17 +57,39 @@ class EffectivePolicy:
actions maps action name -> EffectiveAction (first-match-wins already
applied). Convenience views expose the gate allowlist and desired-state set
that enforcement and the endpoints seam consume.
heldactions are action names under an unexpired break-glass hold. They are
withheld from desired_actions (reconciliation stops fighting the admin) but
deliberately LEFT IN allowed_actions: a hold suspends drift correction, it
does not revoke permission to run the action by hand.
"""
def __init__(self, actions):
def __init__(self, actions, heldactions=None):
self.actions = actions
self.heldactions = set(heldactions or ())
def allowed_actions(self):
"""Set of action names permitted on this endpoint (gate + desired)."""
return set(self.actions.keys())
def desired_actions(self):
"""action name -> EffectiveAction for mode == 'desired' only."""
"""action name -> EffectiveAction for mode == 'desired' only.
Held actions are excluded, so the reconciler does not re-enqueue the
very change an admin just broke glass to make.
"""
return {name: eff for name, eff in self.actions.items()
if eff.mode == 'desired' and name not in self.heldactions}
def holdable_actions(self):
"""Desired-mode action names, INCLUDING ones already held.
Deliberately not desired_actions(): that view hides held actions, so
building a "what can I hold" picker from it would drop an action the
moment it was held and make the current state unreadable. Only
desired-mode actions appear, because a hold suspends reconciliation and
gate-mode actions have none to suspend.
"""
return {name: eff for name, eff in self.actions.items()
if eff.mode == 'desired'}
@@ -165,7 +187,35 @@ def effective_policy_for(assetid):
return EffectivePolicy({})
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
return build_effective_policy(computer, business_policies)
effective = build_effective_policy(computer, business_policies)
effective.heldactions = _held_actions_for(assetid)
return effective
def _held_actions_for(assetid):
"""Action names under an unexpired break-glass hold for this endpoint.
Expiry is compared here, at resolve time, rather than swept by a background
job. A sweeper that stalled would silently keep every hold it failed to
clear alive, which is reconciliation disabled by accident - so the row's own
expiresat is the single authority on whether it still applies.
A null expiresat is an INDEFINITE hold and is always active. It must be
matched explicitly: a bare `expiresat > now` drops null rows, which would
resume reconciliation of exactly the actions an admin pinned open-endedly -
the reconciler restarting a service someone deliberately stopped.
"""
from datetime import datetime
from sqlalchemy import or_
from ..models import PolicyActionHold
rows = (PolicyActionHold.query
.filter(PolicyActionHold.assetid == assetid,
PolicyActionHold.isactive.is_(True),
or_(PolicyActionHold.expiresat.is_(None),
PolicyActionHold.expiresat > datetime.utcnow()))
.all())
return {row.action for row in rows}
def _monitor_action_ids(effective):