Make main the lean product a site receives

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.
This commit is contained in:
cproudlock
2026-09-19 11:24:40 -04:00
parent cb3975049c
commit 534f179ee6
675 changed files with 115759 additions and 60239 deletions

View File

@@ -14,10 +14,13 @@ from .operatingsystem import OperatingSystem
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
from .communication import Communication, CommunicationType
from .user import (User, Role, Permission, full_permission_catalog,
guest_grantable_permissions)
guest_grantable_permissions, scope_permission_names,
PREBUILT_SCOPES, SCOPE_ENDPOINT_ACTIONS,
SCOPE_NEVER_GRANT, SCOPES_DENIED_ENDPOINT_ACTIONS)
from .application import Application, AppVersion
from .script_library import (Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM,
RUNCONTEXT_USER)
RUNCONTEXT_USER, ERRORMODES, ERRORMODE_DEFAULT,
ERRORMODE_STRICT)
from .supportteam import SupportTeam, SupportTeamContact
from .setting import Setting
from .auditlog import AuditLog
@@ -63,6 +66,11 @@ __all__ = [
'Permission',
'full_permission_catalog',
'guest_grantable_permissions',
'scope_permission_names',
'PREBUILT_SCOPES',
'SCOPE_ENDPOINT_ACTIONS',
'SCOPE_NEVER_GRANT',
'SCOPES_DENIED_ENDPOINT_ACTIONS',
# Applications
'Application',
'AppVersion',
@@ -71,6 +79,9 @@ __all__ = [
'RUNCONTEXTS',
'RUNCONTEXT_SYSTEM',
'RUNCONTEXT_USER',
'ERRORMODES',
'ERRORMODE_DEFAULT',
'ERRORMODE_STRICT',
# Support teams
'SupportTeam',
'SupportTeamContact',

View File

@@ -54,10 +54,10 @@ class ApiToken(BaseModel):
# owner holds, and suspends the admin bypass. See scopelist below.
scopes = db.Column(db.Text, nullable=True)
# JSON array of RESOURCE-scope names (a different axis from `scopes`, which
# is what the token may DO). Today these are geenforce manifest scope names
# (e.g. gea-shopfloor-display): a resource-bound geenforce.fetch token may
# only pull those scopes' manifests and only blobs those manifests
# reference. NULL = unrestricted (any resource), for back-compat. See
# is what the token may DO). The names are a plugin's own, not core's: a
# plugin that serves per-fleet bundles, say, pins a token to the bundle
# names it may pull, so the permission says "may fetch" and this says "may
# fetch THESE". NULL = unrestricted (any resource), for back-compat. See
# resourcescopelist below.
resourcescopes = db.Column(db.Text, nullable=True)

View File

@@ -33,6 +33,20 @@ class Application(BaseModel):
db.Boolean, default=False,
comment='Required on all PCs (drives the software-compliance report)'
)
# Monitorable: this app can be watched by the endpoint agent as a process
# monitor. A monitor is useless without something to launch and something to
# look for, so both fields below are REQUIRED whenever this is set - enforced
# in the applications API rather than as a DB constraint, because the columns
# must stay nullable for the rows that existed before this shipped.
ismonitorable = db.Column(
db.Boolean, default=False,
comment='Can be watched by the endpoint agent (needs launch path + process name)'
)
# Full path used to START the app when a monitor finds it not running.
monitorlaunchpath = db.Column(db.String(512))
# Windows image name to watch for, e.g. "notepad.exe". This is what the agent
# matches against the running process list.
monitorprocessname = db.Column(db.String(255))
image = db.Column(db.String(255))
# Digest of the app-level uploaded installer (installpath). Lets the
# endpoint update_app push resolve an install source by app alone, with no

View File

@@ -253,8 +253,6 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
result['pluginid'] = self.network_device.networkdeviceid
elif hasattr(self, 'printer') and self.printer:
result['pluginid'] = self.printer.printerid
elif hasattr(self, 'measuringtool') and self.measuringtool:
result['pluginid'] = self.measuringtool.measuringtoolid
# What this asset OWNS, before any inheritance is merged in below.
#
@@ -321,7 +319,4 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
# Check for printer extension
if hasattr(self, 'printer') and self.printer:
return self.printer.to_dict()
# Check for measuring-tool extension
if hasattr(self, 'measuringtool') and self.measuringtool:
return self.measuringtool.to_dict()
return None

View File

@@ -11,11 +11,14 @@ from .base import BaseModel
# Display role -> the frontend kiosk path it maps to. THE one place a role is
# named, and the reason the names look like this: these are the literal values a
# person types into C:\Enrollment\display-type.txt on the kiosk itself, which the
# GE-Enforce dispatcher reads to pick a target. Two vocabularies used to exist -
# core said 'partskiosk' where the kiosk said '3DPrintRoom' - so a display could
# report a role core could not store and core could store a role no kiosk would
# ever match. The machine's own file wins, because that is what a person edits.
# person types into C:\Enrollment\display-type.txt on the display PC itself,
# which the launcher on that PC reads to pick a target. Two vocabularies used to
# exist - core said 'partskiosk' where the display said '3DPrintRoom' - so a
# display could report a role core could not store and core could store a role
# no display would ever match. The machine's own file wins, because that is what
# a person edits. The vocabulary is deliberately not trimmed to the pages this
# build happens to serve: a display reports what its file says, and core must be
# able to store that answer whether or not a plugin renders the page.
DISPLAY_ROLE_PATHS = {
'Dashboard': '/shopfloor',
'Lobby': '/tv',

View File

@@ -6,6 +6,8 @@ body never lives on the endpoint ahead of time and never crosses the command
wire; the agent downloads it with its collector credential and verifies sha256.
"""
import sqlalchemy as sa
from shopdb.extensions import db
from .base import BaseModel
@@ -16,6 +18,26 @@ RUNCONTEXT_SYSTEM = 'system'
RUNCONTEXT_USER = 'user'
RUNCONTEXTS = (RUNCONTEXT_SYSTEM, RUNCONTEXT_USER)
# How the agent treats a PowerShell NON-TERMINATING error.
#
# DEFAULT is what PowerShell does on its own: the error is written, the script
# keeps going, and powershell.exe still exits 0. That is why a script could
# report succeeded while doing none of its work - the exit code answers "did
# the interpreter reach the end", not "did the script do its job".
#
# STRICT prepends $ErrorActionPreference = 'Stop' so the first error terminates
# and the exit code becomes non-zero, which makes the reported verdict mean
# what an operator assumes it means.
#
# Default is DEFAULT, deliberately. Strict changes execution semantics: a body
# that tolerates a failing Remove-Item, probes with Get-LocalUser, or loops past
# a bad item would start aborting partway. Existing scripts were authored under
# the permissive rule, so this is opt-in per script AFTER the body has been read
# rather than a library-wide switch flipped on code nobody re-checked.
ERRORMODE_DEFAULT = 'default'
ERRORMODE_STRICT = 'strict'
ERRORMODES = (ERRORMODE_DEFAULT, ERRORMODE_STRICT)
class Script(BaseModel):
"""ShopDB-hosted PowerShell script the endpoint agent runs on push."""
@@ -32,6 +54,34 @@ class Script(BaseModel):
# 'system' or 'user' (see RUNCONTEXTS). Per-script so a drive-mapping script
# can target the user session while a machine-wide script runs as SYSTEM.
runcontext = db.Column(db.String(16), default=RUNCONTEXT_SYSTEM, nullable=False)
# Optional folder to run the script IN. Empty means the agent's staging
# directory, which is the right default for a self-contained script and the
# wrong one for a script that shells out to a corporate tool delivered by
# Intune (Sysinternals, vendor CLIs) and referenced by relative path.
#
# This grants NOTHING. The body is already arbitrary PowerShell the agent
# runs as SYSTEM, and it could Set-Location anywhere itself; the security
# boundary is endpoints.runscript plus the digest-pinned library, not the
# working directory. Recorded here so this is not later mistaken for a
# privilege surface and gated harder than it needs to be.
workingdirectory = db.Column(db.String(255))
# 'default' or 'strict' (see ERRORMODES). Opt-in per script because strict
# turns non-terminating errors fatal and would change how existing bodies
# run. Not nullable with a default so a row written before this column
# existed reads as the permissive behavior it was authored under.
errormode = db.Column(db.String(16), default=ERRORMODE_DEFAULT, nullable=False,
server_default=ERRORMODE_DEFAULT)
# Whether a SUCCESSFUL run sends its output back to the server. Failures
# always do: a failed run has to be diagnosable. Success is opt-in because
# script output carries whatever the body printed - hostnames, paths, a
# token an author echoed while debugging - and a run nobody needs to
# diagnose is not worth that exposure by default. The endpoint log keeps the
# full copy either way, so this only controls what crosses to the server.
#
# Off by default so existing scripts keep the behavior they were authored
# under, same rule as errormode above.
captureoutput = db.Column(db.Boolean, default=False, nullable=False,
server_default=sa.false())
def to_dict(self):
result = super().to_dict()

View File

@@ -19,6 +19,25 @@ rolepermissions = db.Table(
db.Column('permissionid', db.Integer, db.ForeignKey('permissions.permissionid'), primary_key=True)
)
# Association table for DIRECT per-user permissions (many-to-many).
#
# A grant here is an ordinary permission, identical in power to one held through
# a role - it counts toward the API token scope ceiling and everything else. It
# exists because a site of 5-7 operators should be able to give one person one
# capability without minting a role for them, which would otherwise turn the
# role list into a mixture of job titles and single-capability flags.
#
# Anything that answers "what may this user do" MUST fold this in. There are two
# such surfaces and they are computed separately: haspermission() (the predicate
# authorization uses) and getpermissions() (the list baked into JWT claims and
# read by the frontend authStore). Teaching only one of them makes the server and
# the UI disagree silently.
userpermissions = db.Table(
'userpermissions',
db.Column('userid', db.Integer, db.ForeignKey('users.userid'), primary_key=True),
db.Column('permissionid', db.Integer, db.ForeignKey('permissions.permissionid'), primary_key=True)
)
class Permission(db.Model):
"""
@@ -35,7 +54,7 @@ class Permission(db.Model):
# Core permission catalog. This is CORE ONLY - permissions for a plugin
# domain (machines, computers, printers, network, knowledgebase,
# notifications, usb, warranty, measuringtools) live in that plugin's
# notifications, warranty) live in that plugin's
# BasePlugin.get_permissions() hook, not here (plugin-is-the-product). The
# merged core+plugins view is full_permission_catalog() below.
CORE_PERMISSIONS = [
@@ -79,6 +98,13 @@ class Permission(db.Model):
# perms, and this perm gates no other route). See collector.py.
('collector.ingest', 'Submit collector payloads (fleet reporting)',
'collector'),
# Employee directory. This one is CORE even though nothing here owns an
# employee table: global search serves an employee domain straight out
# of the external HR database (Config.EMPLOYEE_DB_*, see
# shopdb/utils/employee_db.py), and search.py gates that domain on this
# permission. Leave it out and the gate can never be satisfied, so a
# working feature is unreachable.
('employees.view', 'View the employee directory', 'employees'),
]
def __repr__(self):
@@ -179,6 +205,157 @@ CORE_GUEST_GRANTABLE = {
}
# ---------------------------------------------------------------------------
# Prebuilt user scopes
# ---------------------------------------------------------------------------
#
# Why these exist: before them the only roles were `admin` and `guest`, so a new
# user was given nothing, held no permissions, and the sidebar correctly hid
# every link. There was no rung between "runs the site" and "sees nothing".
#
# The four scopes, as specified:
# admin - unchanged. Bypasses every check in haspermission(); NOT seeded
# here and NOT described by a grant list, because it holds
# everything by rule rather than by grant.
# operator - full record access except deletion, which stays admin-only.
# technician - read-only on records. Cannot create or edit.
# reporter - reads everything and exports reports. No action capability.
#
# DERIVED, not hardcoded, and this is the opposite choice to
# CORE_GUEST_GRANTABLE above. That set is declared name-by-name because it
# governs what an ANONYMOUS visitor may reach, where the bar is "could this be
# public forever" and a wrong answer leaks to the internet. These scopes govern
# authenticated internal staff, and the catalog is plugin-driven: roughly sixty
# permissions across fifteen plugins, growing whenever a plugin is added. A
# hardcoded list would silently omit every future plugin, so a Technician would
# lose access to new modules by default and the failure would look like a
# permissions bug rather than a stale list.
#
# So scopes are computed from the catalog by verb, minus an explicit exclusion
# set. Adding a plugin extends the scopes automatically; keeping something OUT
# of them is the thing that requires a deliberate code change.
# Verbs that mutate records. Split by which scope may hold them.
_EDIT_VERBS = ('create', 'edit', 'restock', 'manage', 'publish')
_DESTRUCTIVE_VERBS = ('delete', 'purge')
# Never granted by ANY prebuilt scope, regardless of verb. Each is either an
# administrative capability, a machine-to-machine credential, or a payload that
# a scope-holder should not get merely for being staff.
SCOPE_NEVER_GRANT = {
# Administrative surface. A scope is not a way to become an admin.
'settings.view', 'settings.edit',
# users.view is deliberately ABSENT: every scope may see who exists, so an
# operator can tell whether an account is already there before asking an
# admin to make one. Creating, editing, and deleting accounts stay
# admin-only, so seeing the directory grants no power over it.
'users.create', 'users.edit', 'users.delete',
'audit.view', 'audit.purge',
'apitokens.create',
# Service-token permissions: these authorize machines, not people. Granting
# one to a human scope would let a person impersonate the collector.
'collector.ingest',
# Bytes, not metadata: SYSTEM-context PowerShell bodies and vendor
# installers. Deliberately separate from applications.view for exactly this
# reason (see the CORE_PERMISSIONS comment), so scopes must not undo that.
'applications.download',
# The big four endpoint actions. These are per-user direct grants chosen by
# checkbox in the user editor, NOT role grants - that is the whole point of
# the Phase A2 split. A scope that granted them would take the choice away
# from the person creating the account.
'endpoints.restart',
'endpoints.runscript',
'endpoints.updateapp',
'endpoints.corptools',
# Operator report screenshots are an uncontrolled capture of the operator's
# screen and may carry shopfloor content, so who sees them is a per-account
# decision rather than something a scope confers.
'endpoints.operatorreports',
# Fleet-wide and agent-distribution capabilities, admin-only by nature.
'endpoints.fleet',
'endpoints.agentpackage',
'endpoints.agentupdate',
}
# Endpoint actions offered as per-user checkboxes in the user editor. Held here
# rather than in the endpoints plugin because the USER EDITOR is what needs the
# list, and core cannot import a plugin. Kept in sync by
# test_scope_endpoint_actions_match_the_plugin.
SCOPE_ENDPOINT_ACTIONS = (
'endpoints.restart',
'endpoints.runscript',
'endpoints.updateapp',
'endpoints.corptools',
'endpoints.operatorreports',
)
def _permission_verb(name):
"""The verb half of a dotted permission name ('assets.edit' -> 'edit')."""
return name.rsplit('.', 1)[-1] if '.' in name else ''
def scope_permission_names(scope, catalog=None):
"""Permission names a prebuilt scope grants, derived from the catalog.
Returns a sorted list so seeding and tests are deterministic.
reporter - every non-excluded permission whose verb is neither a mutation
nor a destruction, plus reports.export. The export is a read
that produces a file, not an action against a machine, and the
user asked for it by name.
technician - the same reads. Identical to reporter at ROLE level: the two
differ by what the user editor offers alongside the role, where
a technician may be given endpoint action checkboxes and a
reporter may not.
operator - the reads plus the editing verbs, still never the destructive
ones. Deletion stays admin-only, per the user's instruction.
"""
if catalog is None:
catalog = full_permission_catalog()
names = {entry[0] for entry in normalize_permission_entries(catalog)}
names -= SCOPE_NEVER_GRANT
readable = {
name for name in names
if _permission_verb(name) not in _EDIT_VERBS
and _permission_verb(name) not in _DESTRUCTIVE_VERBS
}
if scope in ('reporter', 'technician'):
return sorted(readable)
if scope == 'operator':
return sorted(
readable | {
name for name in names
if _permission_verb(name) in _EDIT_VERBS
})
raise ValueError(f'Unknown scope: {scope!r}')
# The seeded scopes, in the order the user editor should offer them. `admin` is
# absent on purpose: it already exists, is created by `seed admin`, and holds
# everything by bypass rather than by grant.
PREBUILT_SCOPES = (
('operator',
'Full access to records except deletion, which stays with admins. '
'Endpoint actions are granted per user by checkbox.',
'#0D6EFD'),
('technician',
'Read-only on records: cannot create, edit, or delete, and cannot author '
'policy. Endpoint actions are granted per user by checkbox.',
'#0DCAF0'),
('reporter',
'Views everything and exports reports. Holds no action capability and '
'cannot be granted one.',
'#6F42C1'),
)
# Scopes that may NOT hold the per-user endpoint action grants, enforced
# server-side in the user API. Reporter's defining property is that it holds no
# action capability EVEN when someone tries to grant one directly.
SCOPES_DENIED_ENDPOINT_ACTIONS = frozenset({'reporter'})
def guest_grantable_permissions():
"""Which permissions the guest role may be granted.
@@ -297,6 +474,60 @@ class Role(BaseModel):
))
return True
@classmethod
def seedscopes(cls):
"""Create the prebuilt scope roles and sync their grants.
Returns {'created': [rolename...], 'granted': {rolename: n}}.
Idempotent, and ADDITIVE on re-run. A second run creates nothing and
adds only grants the role is missing; it never REMOVES a permission from
an existing role.
That asymmetry is deliberate. Re-running has to add, because the grant
lists are derived from a plugin-driven catalog: install a plugin, re-run
the seed, and Technicians should gain read access to the new module
without anyone editing a list. But it must not remove, because the role
grid is an administrative surface and a site may have deliberately given
Operator something extra. A revoking seed would silently undo that
decision on the next deploy, which is a worse failure than a role
holding one grant more than the default - the first is invisible, the
second is on screen in the role editor.
Consequence worth stating plainly: this cannot be used to TIGHTEN a
scope after the fact. If a permission is ever added to
SCOPE_NEVER_GRANT, roles already holding it keep it, and revoking is a
separate deliberate migration.
"""
catalog = full_permission_catalog()
result = {'created': [], 'granted': {}}
for rolename, description, color in PREBUILT_SCOPES:
role = cls.query.filter_by(rolename=rolename).first()
if role is None:
role = cls(rolename=rolename, description=description,
color=color)
db.session.add(role)
db.session.flush()
result['created'].append(rolename)
wanted = set(scope_permission_names(rolename, catalog=catalog))
held = {p.name for p in role.permissions}
missing = wanted - held
if not missing:
continue
# Only link permissions that actually exist as rows. A name in the
# catalog with no Permission row means `seed permissions` has not
# run for that plugin yet; the next run picks it up.
rows = Permission.query.filter(Permission.name.in_(missing)).all()
for permission in rows:
role.permissions.append(permission)
if rows:
result['granted'][rolename] = len(rows)
return result
def getpermissionnames(self) -> list:
"""Get list of permission names."""
if self.rolename == 'admin':
@@ -332,6 +563,14 @@ class User(BaseModel):
backref=db.backref('users', lazy='dynamic')
)
# Permissions granted to this user directly, outside any role. See the
# userpermissions table comment for why these exist and what must fold them.
directpermissions = db.relationship(
'Permission',
secondary=userpermissions,
lazy='joined'
)
def __repr__(self):
return f"<User {self.username}>"
@@ -347,18 +586,43 @@ class User(BaseModel):
return any(r.rolename == rolename for r in self.roles)
def haspermission(self, permission_name: str) -> bool:
"""Check if user has a specific permission through any role."""
"""Check if user has a permission, through any role or directly.
A direct grant is not a lesser kind of permission: it is honoured here
exactly like a role grant, which is what keeps the API token scope
ceiling correct without any change to the ceiling itself. Both ceiling
checks (mint time in core/api/apitokens.py and per-request in
utils/authz.py) call this method.
"""
# Admin role has all permissions
if self.hasrole('admin'):
return True
if any(p.name == permission_name for p in self.directpermissions):
return True
return any(r.haspermission(permission_name) for r in self.roles)
def getdirectpermissionnames(self) -> list:
"""Permission names granted to this user directly, ignoring roles.
The user editor needs the DIRECT grants alone so a checkbox reflects
what is stored on the user rather than what a role happens to supply;
getpermissions() deliberately cannot answer that because it folds both.
"""
return sorted(p.name for p in self.directpermissions)
def getpermissions(self) -> list:
"""Get list of all permission names from all roles."""
"""Get list of all permission names, from roles and direct grants.
This is the LIST surface: it feeds JWT claims and the frontend
authStore. It must agree with haspermission(), the predicate surface, or
the UI hides controls the server would allow (or worse, shows ones it
would refuse).
"""
if self.hasrole('admin'):
return [p[0] for p in full_permission_catalog()]
perms = set()
for role in self.roles:
perms.update(role.getpermissionnames())
perms.update(p.name for p in self.directpermissions)
return list(perms)