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.
This commit is contained in:
benjamin.baker
2026-09-07 12:36:45 -04:00
parent 879451b689
commit 627d3f5ff5
8 changed files with 1067 additions and 89 deletions

View File

@@ -2,6 +2,7 @@
from .endpoint import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
EndpointLock,
)
from .monitor import EndpointMonitor
from .operatorreport import OperatorReport
@@ -11,6 +12,7 @@ from .health import (
__all__ = [
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
'EndpointLock',
'EndpointMonitor', 'OperatorReport',
'EndpointHealthState', 'EndpointHealthTransition', 'EndpointMonitorImpact',
]

View File

@@ -445,3 +445,89 @@ class EndpointApplication(db.Model):
'appid': self.appid,
'recordedat': _utciso(self.recordedat),
}
class EndpointLock(db.Model):
"""An admin-only quarantine refusing operator commands on one endpoint.
Deliberately NOT the same thing as a PolicyActionHold, despite the similar
name. The two sit on different axes and collapsing them would recreate the
axis-mixing this refactor exists to undo:
PolicyActionHold policy-resolution axis. Suppresses ONE desired-state
action so reconciliation stops fighting an admin.
Per-action, several may stand at once, and it EXPIRES.
EndpointLock caller-authority axis. Refuses commands from non-admins
on this endpoint regardless of policy or permission.
Per-endpoint, one at a time, and INDEFINITE.
Indefinite is the point. A lock represents a physical-world condition - a
machine mid-repair, a validated part running, a box that must not reboot
during a customer audit - and those do not end on a timer. A TTL here would
quietly unlock a machine someone is still working inside, which is the exact
accident the lock exists to prevent. Stale locks are surfaced in the UI
instead of being auto-cleared.
Released rows are kept and marked rather than deleted: the record of who
quarantined a machine, why, and who lifted it is the audit trail, and it is
most wanted precisely when the lock is no longer there to explain itself.
"""
__tablename__ = 'endpointlocks'
endpointlockid = db.Column(db.Integer, primary_key=True)
# Not unique at the schema level: released rows accumulate per asset and a
# unique constraint would make the second lock in an endpoint's life fail.
# The one-active-lock invariant is enforced on write in PLACE_ENDPOINT_LOCK.
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
index=True,
)
reason = db.Column(db.Text, nullable=False)
# Who set it. The id goes null if the account is deleted, but the username
# snapshot survives: a quarantine that forgets who imposed it is unusable at
# review time, which is the only time anyone reads it.
lockedbyuserid = db.Column(
db.Integer,
db.ForeignKey('users.userid', ondelete='SET NULL'),
nullable=True,
)
lockedbyusername = db.Column(db.String(100), nullable=True)
lockedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Null while the lock stands. Set on release, which is what makes this row
# inactive - there is no isactive flag to drift out of step with them.
releasedat = db.Column(db.DateTime, nullable=True)
releasedbyuserid = db.Column(
db.Integer,
db.ForeignKey('users.userid', ondelete='SET NULL'),
nullable=True,
)
releasedbyusername = db.Column(db.String(100), nullable=True)
__table_args__ = (
# Serves the hot path: "is there an active lock on this asset", asked on
# every enqueue and every detail page load.
db.Index('idx_endpointlocks_assetactive', 'assetid', 'releasedat'),
)
def __repr__(self):
state = 'released' if self.releasedat else 'active'
return f"<EndpointLock asset={self.assetid} {state}>"
def to_dict(self):
return {
'endpointlockid': self.endpointlockid,
'assetid': self.assetid,
'reason': self.reason,
'lockedbyuserid': self.lockedbyuserid,
'lockedbyusername': self.lockedbyusername,
'lockedat': _utciso(self.lockedat),
'releasedat': _utciso(self.releasedat),
'releasedbyusername': self.releasedbyusername,
}