"""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'' 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'' 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''