Files
shopdb-flask/plugins/endpoints/models/credential.py
cproudlock 3f80425f30 Make an admin's refusal stick, and let a revoke actually deny
Three holes in the enrollment path, all of the same shape: a decision the admin
made did not survive contact with a PC that simply asked again.

REJECTION WAS UNDONE BY A NEW NONCE. A rejected request re-armed itself as a
fresh cycle when the PC came back with a different nonce, and with the shared
key and auto-enroll on it was then issued the credential the admin had just
refused. Rejection now binds to the endpoint identity - hostname plus BIOS
serial - not to the nonce, so the PC stays refused however often it reinstalls.
An admin clears it deliberately, through the approve path that already exists; a
genuinely different PC is untouched.

THE PENDING QUEUE COULD BE FILLED UNTIL IT REFUSED EVERY NEW PC. /enroll takes
no auth by design - a bay that has never spoken to the server has nothing to
authenticate with - so anything that can reach the server could sit at the cap
and lock out real enrollments fleet-wide. Rows nobody has touched for seven days
now age out before the cap refuses anyone, and the count of what went is
audited. Per-identity dedup and the cap itself were already there and are left
alone.

REVOKE DID NOT STOP A LIVE AGENT. It fell back to the shared key and the server
admitted it again, so revoking a compromised PC changed nothing while that key
still worked. plugin.endpoints.revokeddeniessharedkey makes a revoked host
refused on the shared key too.

That switch DEFAULTS OFF, and that is the important part. Turning it on would
silence any bay revoked for a key rotation, and would break the documented
revoke-then-rollback path to an older agent - on an air-gapped site, mid
migration, that is a fleet outage. A never-enrolled bay is never affected either
way: with no credential row at all the shared key still works, which is the
whole un-migrated fleet.

The checkbox sits next to the other two enrollment switches and says what
turning it on costs, because a setting that can stop the floor should not be
reachable only through the API.
2026-09-18 13:29:51 -04:00

166 lines
6.6 KiB
Python

"""Per-PC endpoint credentials, enrollment requests and migration waves.
EndpointCredential
One row per asset. Holds only the sha256 of the secret the
agent was issued, plus a short display prefix. Revoking sets
revokedat; re-issuing after a revoke UPDATES the same row, so
an asset never carries two credentials. The row therefore also
says that this PC has enrolled at least once, which is how
agentauth tells a revoked PC from one that never enrolled:
plugin.endpoints.revokeddeniessharedkey refuses the shared key
to the first and never to the second. reenrollblocked keeps
a revoked PC from enrolling again (rolling it back to an agent
that only knows the shared key, or a compromised PC) until an
admin allows it.
EndpointEnrollRequest
One row per (hostname, serial number) that asked to enroll -
the endpoint's identity, and the reason every contact from one
PC lands on one row. The agent's enrollment nonce is recorded
on first sight and is what an admin approval is bound to, so a
second machine claiming the same hostname and serial cannot
collect the approved credential. A REJECTED row binds to the
identity instead: any nonce from that hostname and serial stays
rejected, so a reinstall cannot argue its way back into the
queue. Only an admin approving the request clears it.
EndpointMigrationWave
An admin action that pushed the agent upgrade to a set of PCs.
Progress is computed live from credentials and commands; only
the membership is stored.
"""
from datetime import datetime
from shopdb.api import db
def _utciso(value):
return value.isoformat() + 'Z' if value else None
class EndpointCredential(db.Model):
"""The credential issued to one endpoint (hash only)."""
__tablename__ = 'endpointcredentials'
ENROLLED_AUTO = 'auto'
ENROLLED_APPROVED = 'approved'
credentialid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
unique=True,
)
credentialhash = db.Column(db.CHAR(64), nullable=False, unique=True)
credentialprefix = db.Column(db.String(12), nullable=False)
enrolledvia = db.Column(db.String(16), nullable=False)
issuedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
lastusedat = db.Column(db.DateTime, nullable=True)
revokedat = db.Column(db.DateTime, nullable=True)
revokedby = db.Column(db.String(64), nullable=True)
reenrollblocked = db.Column(db.Boolean, nullable=False, default=False,
server_default=db.false())
reenrollblockedreason = db.Column(db.String(64), nullable=True)
@property
def isactive(self):
return self.revokedat is None
def to_dict(self):
return {
'credentialid': self.credentialid,
'assetid': self.assetid,
'credentialprefix': self.credentialprefix,
'enrolledvia': self.enrolledvia,
'issuedat': _utciso(self.issuedat),
'lastusedat': _utciso(self.lastusedat),
'revokedat': _utciso(self.revokedat),
'revokedby': self.revokedby,
'reenrollblocked': bool(self.reenrollblocked),
'reenrollblockedreason': self.reenrollblockedreason,
}
def __repr__(self):
return f'<EndpointCredential asset={self.assetid} {self.credentialprefix}>'
class EndpointEnrollRequest(db.Model):
"""A PC asking for a credential."""
__tablename__ = 'endpointenrollrequests'
STATUS_PENDING = 'pending'
STATUS_APPROVED = 'approved'
STATUS_REJECTED = 'rejected'
STATUS_ISSUED = 'issued'
requestid = db.Column(db.Integer, primary_key=True)
hostname = db.Column(db.String(255), nullable=False)
serialnumber = db.Column(db.String(128), nullable=False)
deviceid = db.Column(db.String(64), nullable=True)
enrollnonce = db.Column(db.String(128), nullable=False)
agentversion = db.Column(db.String(32), nullable=True)
status = db.Column(db.String(16), nullable=False, default=STATUS_PENDING)
reason = db.Column(db.String(64), nullable=True)
matchedassetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='SET NULL'),
nullable=True,
)
sourceip = db.Column(db.String(45), nullable=True)
requestedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
lastseenat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
decidedat = db.Column(db.DateTime, nullable=True)
decidedby = db.Column(db.String(64), nullable=True)
# Prefix lengths keep the key under 767 bytes on utf8mb4 (4 bytes a char).
__table_args__ = (
db.Index('idx_endpointenrollrequests_hostserial', 'hostname',
'serialnumber',
mysql_length={'hostname': 128, 'serialnumber': 63}),
)
def to_dict(self):
return {
'requestid': self.requestid,
'hostname': self.hostname,
'serialnumber': self.serialnumber,
'deviceid': self.deviceid,
'agentversion': self.agentversion,
'reason': self.reason,
'status': self.status,
'matchedassetid': self.matchedassetid,
'requestedat': _utciso(self.requestedat),
'lastseenat': _utciso(self.lastseenat),
'decidedat': _utciso(self.decidedat),
'decidedby': self.decidedby,
'sourceip': self.sourceip,
}
def __repr__(self):
return f'<EndpointEnrollRequest {self.hostname!r} {self.status}>'
class EndpointMigrationWave(db.Model):
"""One batch of agent upgrades pushed to move PCs onto credentials."""
__tablename__ = 'endpointmigrationwaves'
waveid = db.Column(db.Integer, primary_key=True)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
createdby = db.Column(db.String(64), nullable=True)
# Comma list of asset ids. TEXT, not JSON: MySQL 5.6.
assetids = db.Column(db.Text, nullable=False, default='')
notes = db.Column(db.String(255), nullable=True)
@property
def assetidlist(self):
return [int(part) for part in (self.assetids or '').split(',')
if part.strip().isdigit()]
def __repr__(self):
return f'<EndpointMigrationWave {self.waveid}>'