Endpoint locks -------------- Adds EndpointLock so an admin can hold an endpoint against automated and operator action, with migration 0018 and enforcement in the enqueue path rather than only in the UI, so a lock cannot be bypassed by calling the API directly. Split endpoints.deploy ---------------------- endpoints.deploy bundled three actions of very different risk behind a single name: scheduledtask_run -> starts a task already registered and allowlisted update_app -> runs a pinned installer run_script -> arbitrary code as SYSTEM unless the descriptor pins a user context Because they shared one grant there was no way to let an operator push an application update without also handing them arbitrary SYSTEM code execution on every endpoint they could reach. The permission name described the lightest of the three while silently carrying the heaviest. commands.ELEVATED_ACTION_PERMISSIONS now maps each action to its own permission (endpoints.corptools, endpoints.updateapp, endpoints.runscript). The enqueue route reads that table, so no route logic changed. Migration 7d42 preserves existing access exactly: anyone holding endpoints.deploy receives all three replacements.
88 lines
3.9 KiB
Python
88 lines
3.9 KiB
Python
"""Add endpointlocks (admin-only endpoint quarantine).
|
|
|
|
One table behind the endpoint lock: an admin-set refusal of operator commands
|
|
on a single endpoint, independent of policy and permission.
|
|
|
|
Deliberately separate from the computers plugin's policyactionholds. That table
|
|
suppresses ONE desired-state action on the policy-resolution axis and expires on
|
|
a timer; this one refuses NON-ADMIN CALLERS on the whole endpoint and does not
|
|
expire at all. Two concepts, two tables, two enforcement points - collapsing
|
|
them would recreate the axis-mixing the surrounding refactor exists to undo.
|
|
|
|
No expiresat column, and that is the design rather than an omission. A lock
|
|
stands for a physical-world condition (machine mid-repair, validated part
|
|
running, box that must not reboot during an audit) and those do not end on a
|
|
schedule. A TTL would silently unlock a machine someone still has their hands
|
|
inside.
|
|
|
|
Ships EMPTY. No endpoint has ever been locked before this migration, so there is
|
|
nothing to backfill and every endpoint keeps behaving exactly as it did.
|
|
|
|
Idempotent: the create checks the live schema first, so a re-run and a
|
|
db.create_all() site both stay clean on strict MySQL 8.
|
|
"""
|
|
from alembic import op
|
|
|
|
import sqlalchemy as sa
|
|
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision = 'endpoints0018endpointlocks'
|
|
down_revision = 'endpoints0017healthrollup'
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
LOCKS = 'endpointlocks'
|
|
|
|
|
|
def _has_table(table_name):
|
|
from sqlalchemy import inspect
|
|
|
|
return inspect(op.get_bind()).has_table(table_name)
|
|
|
|
|
|
def upgrade():
|
|
if not _has_table(LOCKS):
|
|
op.create_table(
|
|
LOCKS,
|
|
sa.Column('endpointlockid', sa.Integer(), nullable=False),
|
|
sa.Column('assetid', sa.Integer(), nullable=False),
|
|
sa.Column('reason', sa.Text(), nullable=False),
|
|
sa.Column('lockedbyuserid', sa.Integer(), nullable=True),
|
|
sa.Column('lockedbyusername', sa.String(length=100), nullable=True),
|
|
sa.Column('lockedat', sa.DateTime(), nullable=False),
|
|
# Null while the lock stands. Setting it is what releases the lock,
|
|
# so there is no separate active flag able to drift out of step.
|
|
sa.Column('releasedat', sa.DateTime(), nullable=True),
|
|
sa.Column('releasedbyuserid', sa.Integer(), nullable=True),
|
|
sa.Column('releasedbyusername', sa.String(length=100),
|
|
nullable=True),
|
|
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
|
ondelete='CASCADE'),
|
|
# SET NULL, not CASCADE: deleting the admin who locked a machine
|
|
# must not delete the record that it was locked. The username
|
|
# snapshot columns survive to say who it was.
|
|
sa.ForeignKeyConstraint(['lockedbyuserid'], ['users.userid'],
|
|
ondelete='SET NULL'),
|
|
sa.ForeignKeyConstraint(['releasedbyuserid'], ['users.userid'],
|
|
ondelete='SET NULL'),
|
|
sa.PrimaryKeyConstraint('endpointlockid'),
|
|
)
|
|
# Serves the hot path: "is there an active lock on this asset", asked on
|
|
# every enqueue and every endpoint detail load. NOT unique on assetid -
|
|
# released rows accumulate per endpoint as history, and a unique
|
|
# constraint would make the second lock in a machine's life fail. The
|
|
# one-active-lock invariant is enforced on write instead.
|
|
op.create_index('idx_endpointlocks_assetactive', LOCKS,
|
|
['assetid', 'releasedat'])
|
|
|
|
|
|
def downgrade():
|
|
# Safe to drop: the table is a system of record for quarantine state, but a
|
|
# downgrade past this revision removes the enforcement that reads it, so
|
|
# leaving orphaned rows behind would be worse - they would silently reapply
|
|
# if the revision were ever re-applied.
|
|
if _has_table(LOCKS):
|
|
op.drop_index('idx_endpointlocks_assetactive', table_name=LOCKS)
|
|
op.drop_table(LOCKS)
|