main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
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}>'
|