Files
shopdb-flask/plugins/endpoints/migrations/versions/0018_endpoint_locks.py
benjamin.baker 627d3f5ff5 Add endpoint locks and split endpoints.deploy into three grants
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.
2026-09-07 12:36:45 -04:00

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)