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.
166 lines
6.6 KiB
Python
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}>'
|