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

@@ -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)