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

@@ -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; }