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