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.
69 lines
2.7 KiB
Python
69 lines
2.7 KiB
Python
"""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')
|