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:
@@ -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')
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user