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.
86 lines
3.0 KiB
Python
86 lines
3.0 KiB
Python
"""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)
|