Add installer/script delivery metadata to the core software model

AppVersion gains sha256 and silentargs so the endpoint update_app push can pin exact installer bytes and carry per-version silent switches; Application gains installersha256 for the app-level install source. New ShopDB-hosted script library (Script model plus scripts API) backs run_script delivery, with the body fetched by digest on push rather than pre-staged or sent over the command wire. Two idempotent migrations with real downgrades. Script added to the shopdb.api contract surface for plugin use.
This commit is contained in:
benjamin.baker
2026-08-30 14:39:58 -04:00
parent dc2f4266bc
commit 0c2b816a46
12 changed files with 483 additions and 3 deletions

View File

@@ -0,0 +1,46 @@
"""Add appversions.sha256 and appversions.silentargs (installer delivery metadata)
The endpoint installer push resolves a specific AppVersion: sha256 pins the
exact bytes the agent must fetch, and silentargs carries this version's
unattended-install switches (homegrown EXEs vary by developer; MSI is uniform
via msiexec). Both nullable. Idempotent guards and real downgrade.
Revision ID: 7d35_appversion_delivery_metadata
Revises: 7d34_singular_relationship_types
Create Date: 2026-07-24
"""
from alembic import op
import sqlalchemy as sa
revision = '7d35_appversion_delivery_metadata'
down_revision = '7d34_singular_relationship_types'
branch_labels = None
depends_on = None
def _has_col(insp, name):
return any(c['name'] == name for c in insp.get_columns('appversions'))
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'appversions' not in insp.get_table_names():
return
if not _has_col(insp, 'sha256'):
op.add_column('appversions', sa.Column('sha256', sa.String(length=64), nullable=True))
if not _has_col(insp, 'silentargs'):
op.add_column('appversions', sa.Column('silentargs', sa.String(length=255), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'appversions' not in insp.get_table_names():
return
if _has_col(insp, 'silentargs'):
op.drop_column('appversions', 'silentargs')
if _has_col(insp, 'sha256'):
op.drop_column('appversions', 'sha256')

View File

@@ -0,0 +1,71 @@
"""Add applications.installersha256 and the scripts table (script-library delivery)
Two additive, idempotent changes for the endpoint app/script delivery path:
1. applications.installersha256 - digest of the app-level uploaded installer so
an update_app push can resolve an install source by app alone (no AppVersion
row required). Nullable; null when installpath is a site share path.
2. scripts - the ShopDB-hosted script library. The endpoint agent fetches a
script body by digest on push and runs it in the configured runcontext
(system or user). Mirrors the application installer delivery model.
Idempotent guards and a real downgrade throughout; strict MySQL 8 clean.
Revision ID: 7d36_scriptlibrary_and_app_installer_digest
Revises: 7d35_appversion_delivery_metadata
Create Date: 2026-08-29
"""
from alembic import op
import sqlalchemy as sa
revision = '7d36_scriptlibrary_and_app_installer_digest'
down_revision = '7d35_appversion_delivery_metadata'
branch_labels = None
depends_on = None
def _has_col(insp, table, name):
return any(c['name'] == name for c in insp.get_columns(table))
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'applications' in insp.get_table_names() \
and not _has_col(insp, 'applications', 'installersha256'):
op.add_column(
'applications',
sa.Column('installersha256', sa.String(length=64), nullable=True))
if 'scripts' not in insp.get_table_names():
op.create_table(
'scripts',
sa.Column('scriptid', sa.Integer(), primary_key=True),
sa.Column('scriptname', sa.String(length=100), nullable=False),
sa.Column('scriptdescription', sa.String(length=255), nullable=True),
sa.Column('filename', sa.String(length=255), nullable=True),
sa.Column('sha256', sa.String(length=64), nullable=True),
sa.Column('runcontext', sa.String(length=16),
nullable=False, server_default='system'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.text('1')),
sa.UniqueConstraint('scriptname', name='uq_scripts_scriptname'),
)
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'scripts' in insp.get_table_names():
op.drop_table('scripts')
if 'applications' in insp.get_table_names() \
and _has_col(insp, 'applications', 'installersha256'):
op.drop_column('applications', 'installersha256')

View File

@@ -256,6 +256,7 @@ CORE_BLUEPRINT_NAMES = (
'dashboard', 'dashboard',
'dashboarddefaults', 'dashboarddefaults',
'applications', 'applications',
'scripts',
'supportteams', 'supportteams',
'search', 'search',
'reports', 'reports',

View File

@@ -41,6 +41,7 @@ from shopdb.core.models import (
AuditLog, AuditLog,
Application, Application,
AppVersion, AppVersion,
Script,
OperatingSystem, OperatingSystem,
AssetRelationship, AssetRelationship,
RelationshipType, RelationshipType,
@@ -313,6 +314,7 @@ __all__ = [
'AuditLog', 'AuditLog',
'Application', 'Application',
'AppVersion', 'AppVersion',
'Script',
'OperatingSystem', 'OperatingSystem',
'AssetRelationship', 'AssetRelationship',
'RelationshipType', 'RelationshipType',

View File

@@ -14,6 +14,7 @@ from .operatingsystems import operatingsystems_bp
from .dashboard import dashboard_bp from .dashboard import dashboard_bp
from .dashboarddefaults import dashboarddefaults_bp from .dashboarddefaults import dashboarddefaults_bp
from .applications import applications_bp from .applications import applications_bp
from .scripts import scripts_bp
from .supportteams import supportteams_bp from .supportteams import supportteams_bp
from .search import search_bp from .search import search_bp
from .reports import reports_bp from .reports import reports_bp
@@ -42,6 +43,7 @@ __all__ = [
'dashboard_bp', 'dashboard_bp',
'dashboarddefaults_bp', 'dashboarddefaults_bp',
'applications_bp', 'applications_bp',
'scripts_bp',
'supportteams_bp', 'supportteams_bp',
'search_bp', 'search_bp',
'reports_bp', 'reports_bp',

View File

@@ -1,6 +1,7 @@
"""Applications API endpoints.""" """Applications API endpoints."""
import glob import glob
import hashlib
import os import os
from flask import Blueprint, request, current_app from flask import Blueprint, request, current_app
@@ -47,6 +48,20 @@ def _apppackage_dir():
return os.path.join(current_app.instance_path, 'applicationpackages') return os.path.join(current_app.instance_path, 'applicationpackages')
def _sha256_of(stream):
"""Hex SHA-256 of an upload stream, read in chunks, cursor left rewound.
The agent pins a version to exact bytes; computing the digest here means
the server is the one source of truth for what those bytes are.
"""
digest = hashlib.sha256()
stream.seek(0)
for chunk in iter(lambda: stream.read(1048576), b''):
digest.update(chunk)
stream.seek(0)
return digest.hexdigest()
def _replace_upload(directory, stem, ext, upload): def _replace_upload(directory, stem, ext, upload):
"""Save one file per application, replacing any prior extension. """Save one file per application, replacing any prior extension.
@@ -322,7 +337,9 @@ def create_version(app_id: int):
appid=app_id, appid=app_id,
version=data['version'], version=data['version'],
releasedate=data.get('releasedate'), releasedate=data.get('releasedate'),
notes=data.get('notes') notes=data.get('notes'),
sha256=data.get('sha256'),
silentargs=data.get('silentargs')
) )
db.session.add(version) db.session.add(version)
@@ -604,16 +621,37 @@ def upload_application_package(app_id: int):
filename = _replace_upload(_apppackage_dir(), f'application-{app_id}', ext, upload) filename = _replace_upload(_apppackage_dir(), f'application-{app_id}', ext, upload)
app.installpath = '{}{}'.format(APP_PACKAGE_URL_PREFIX, filename) app.installpath = '{}{}'.format(APP_PACKAGE_URL_PREFIX, filename)
# The endpoint installer push pins bytes by digest; compute it once here so
# the server, not the agent, decides what the correct payload is. Stamp it
# on the app itself so an update_app push can resolve an install source by
# app alone (no AppVersion row). If the caller also names a version, stamp
# it there too; otherwise return it so the version-edit flow can persist it.
sha256 = _sha256_of(upload.stream)
app.installersha256 = sha256
stamped_version = None
appversionid = request.form.get('appversionid', type=int)
if appversionid:
stamped_version = db.session.get(AppVersion, appversionid)
if not stamped_version or stamped_version.appid != app_id:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'appversionid {} does not belong to this application'.format(appversionid))
stamped_version.sha256 = sha256
db.session.commit() db.session.commit()
AuditLog.log('updated', 'Application', entityid=app_id, AuditLog.log('updated', 'Application', entityid=app_id,
entityname=app.appname, entityname=app.appname,
changes={'installpath': {'new': app.installpath}, changes={'installpath': {'new': app.installpath},
'uploadedfilename': {'new': upload.filename}, 'uploadedfilename': {'new': upload.filename},
'sha256': {'new': sha256},
'bytes': {'new': size}}) 'bytes': {'new': size}})
db.session.commit() db.session.commit()
return success_response( return success_response(
{**app.to_dict(), 'uploadedfilename': upload.filename, 'bytes': size}, {**app.to_dict(), 'uploadedfilename': upload.filename, 'bytes': size,
'sha256': sha256,
'appversionid': stamped_version.appversionid if stamped_version else None},
message='Installer uploaded') message='Installer uploaded')

206
shopdb/core/api/scripts.py Normal file
View File

@@ -0,0 +1,206 @@
"""Script library API endpoints.
The ShopDB-hosted script library backs the endpoint run_script delivery path: a
PowerShell script is authored/uploaded once here, and the endpoint agent fetches
its body by digest on push (the same model as application installer delivery).
The body never crosses the command wire and is never pre-staged on the endpoint;
the agent downloads it with its collector credential and verifies sha256.
Gated on the applications.* permissions - scripts are software delivery like
apps, so they ride the same operator grant rather than a separate permission.
"""
import os
from flask import Blueprint, request, current_app
from shopdb.extensions import db
from shopdb.core.models import Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM, AuditLog
from shopdb.utils.responses import (
success_response,
error_response,
ErrorCodes,
)
from shopdb.utils.authz import require_permission
from shopdb.utils.uploads import send_upload
from shopdb.core.api.applications import _sha256_of, _replace_upload
# Scripts are PowerShell for the endpoint agent. The guard is a sanity check on
# the upload, not a security boundary - the download route always sends as an
# attachment and the agent runs it explicitly.
SCRIPT_EXTENSIONS = {'.ps1'}
SCRIPT_URL_PREFIX = '/api/scripts/body/'
# A delivery script is small; cap well under the installer limit.
MAX_SCRIPT_BYTES = 1 * 1024 * 1024
def _script_dir():
return os.path.join(current_app.instance_path, 'scripts')
scripts_bp = Blueprint('scripts', __name__)
@scripts_bp.route('', methods=['GET'])
@require_permission('applications.view')
def list_scripts():
"""List scripts (active by default)."""
query = Script.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Script.isactive == True) # noqa: E712
if search := request.args.get('search'):
query = query.filter(Script.scriptname.ilike(f'%{search}%'))
rows = query.order_by(Script.scriptname).all()
return success_response([row.to_dict() for row in rows])
@scripts_bp.route('/<int:script_id>', methods=['GET'])
@require_permission('applications.view')
def get_script(script_id: int):
script = db.session.get(Script, script_id)
if not script:
return error_response(ErrorCodes.NOT_FOUND, 'Script not found', http_code=404)
return success_response(script.to_dict())
def _validate_runcontext(value):
"""Normalize/validate a runcontext, defaulting to system when absent."""
if value is None:
return RUNCONTEXT_SYSTEM, None
value = str(value).strip().lower()
if value not in RUNCONTEXTS:
return None, 'runcontext must be one of: {}'.format(', '.join(sorted(RUNCONTEXTS)))
return value, None
@scripts_bp.route('', methods=['POST'])
@require_permission('applications.create')
def create_script():
"""Create a script catalog entry (body uploaded separately)."""
data = request.get_json(silent=True) or {}
name = (data.get('scriptname') or '').strip()
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR, 'scriptname is required')
if Script.query.filter_by(scriptname=name).first():
return error_response(ErrorCodes.VALIDATION_ERROR,
f'A script named {name!r} already exists')
runcontext, err = _validate_runcontext(data.get('runcontext'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
script = Script(
scriptname=name,
scriptdescription=data.get('scriptdescription'),
runcontext=runcontext,
)
db.session.add(script)
db.session.commit()
AuditLog.log('created', 'Script', entityid=script.scriptid,
entityname=name,
changes={'runcontext': {'new': runcontext}})
db.session.commit()
return success_response(script.to_dict(), message='Script created', http_code=201)
@scripts_bp.route('/<int:script_id>', methods=['PUT'])
@require_permission('applications.edit')
def update_script(script_id: int):
script = db.session.get(Script, script_id)
if not script:
return error_response(ErrorCodes.NOT_FOUND, 'Script not found', http_code=404)
data = request.get_json(silent=True) or {}
if 'scriptname' in data:
name = (data.get('scriptname') or '').strip()
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR, 'scriptname cannot be empty')
clash = Script.query.filter(Script.scriptname == name,
Script.scriptid != script_id).first()
if clash:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'A script named {name!r} already exists')
script.scriptname = name
if 'scriptdescription' in data:
script.scriptdescription = data.get('scriptdescription')
if 'runcontext' in data:
runcontext, err = _validate_runcontext(data.get('runcontext'))
if err:
return error_response(ErrorCodes.VALIDATION_ERROR, err)
script.runcontext = runcontext
if 'isactive' in data:
script.isactive = bool(data.get('isactive'))
db.session.commit()
return success_response(script.to_dict(), message='Script updated')
@scripts_bp.route('/<int:script_id>', methods=['DELETE'])
@require_permission('applications.delete')
def delete_script(script_id: int):
script = db.session.get(Script, script_id)
if not script:
return error_response(ErrorCodes.NOT_FOUND, 'Script not found', http_code=404)
name = script.scriptname
db.session.delete(script)
db.session.commit()
AuditLog.log('deleted', 'Script', entityid=script_id, entityname=name)
db.session.commit()
return success_response(None, message='Script deleted')
@scripts_bp.route('/<int:script_id>/body', methods=['POST'])
@require_permission('applications.edit')
def upload_script_body(script_id: int):
"""Upload (or replace) the .ps1 body for a script.
multipart/form-data: file=<script.ps1>. Stored as script-<id>.ps1 and the
digest is stamped so the endpoint delivery path can pin exact bytes.
"""
script = db.session.get(Script, script_id)
if not script:
return error_response(ErrorCodes.NOT_FOUND, 'Script not found', http_code=404)
upload = request.files.get('file')
if not upload or not upload.filename:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
ext = os.path.splitext(upload.filename)[1].lower()
if ext not in SCRIPT_EXTENSIONS:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Unsupported script type {}. Allowed: {}'.format(
ext, ', '.join(sorted(SCRIPT_EXTENSIONS))))
upload.stream.seek(0, os.SEEK_END)
size = upload.stream.tell()
upload.stream.seek(0)
if size > MAX_SCRIPT_BYTES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Script is {:.0f}KB; the limit is {:.0f}KB.'.format(
size / 1024, MAX_SCRIPT_BYTES / 1024))
filename = _replace_upload(_script_dir(), f'script-{script_id}', ext, upload)
script.filename = filename
script.sha256 = _sha256_of(upload.stream)
db.session.commit()
AuditLog.log('updated', 'Script', entityid=script_id,
entityname=script.scriptname,
changes={'filename': {'new': filename},
'sha256': {'new': script.sha256},
'bytes': {'new': size}})
db.session.commit()
return success_response(
{**script.to_dict(), 'uploadedfilename': upload.filename, 'bytes': size},
message='Script body uploaded')
@scripts_bp.route('/body/<path:filename>', methods=['GET'])
@require_permission('applications.view')
def download_script_body(filename: str):
"""Operator-facing script body download (JWT/RBAC).
The endpoint agent uses the collector-authenticated plugin route instead;
this one is for humans reviewing what a script does.
"""
return send_upload(_script_dir(), filename, as_attachment=True)

View File

@@ -16,6 +16,8 @@ from .communication import Communication, CommunicationType
from .user import (User, Role, Permission, full_permission_catalog, from .user import (User, Role, Permission, full_permission_catalog,
guest_grantable_permissions) guest_grantable_permissions)
from .application import Application, AppVersion from .application import Application, AppVersion
from .script_library import (Script, RUNCONTEXTS, RUNCONTEXT_SYSTEM,
RUNCONTEXT_USER)
from .supportteam import SupportTeam, SupportTeamContact from .supportteam import SupportTeam, SupportTeamContact
from .setting import Setting from .setting import Setting
from .auditlog import AuditLog from .auditlog import AuditLog
@@ -62,6 +64,11 @@ __all__ = [
# Applications # Applications
'Application', 'Application',
'AppVersion', 'AppVersion',
# Script library (endpoint run_script delivery)
'Script',
'RUNCONTEXTS',
'RUNCONTEXT_SYSTEM',
'RUNCONTEXT_USER',
# Support teams # Support teams
'SupportTeam', 'SupportTeam',
'SupportTeamContact', 'SupportTeamContact',

View File

@@ -34,6 +34,11 @@ class Application(BaseModel):
comment='Required on all PCs (drives the software-compliance report)' comment='Required on all PCs (drives the software-compliance report)'
) )
image = 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
# AppVersion row: the agent fetches the pinned bytes and verifies this hash.
# Stamped on package upload; null when installpath is a site share path.
installersha256 = db.Column(db.String(64))
# Relationships # Relationships
supportteam = db.relationship('SupportTeam', back_populates='applications') supportteam = db.relationship('SupportTeam', back_populates='applications')
@@ -70,6 +75,12 @@ class AppVersion(db.Model):
notes = db.Column(db.String(255)) notes = db.Column(db.String(255))
dateadded = db.Column(db.DateTime, default=_utcnow) dateadded = db.Column(db.DateTime, default=_utcnow)
isactive = db.Column(db.Boolean, default=True) isactive = db.Column(db.Boolean, default=True)
# Delivery metadata for the endpoint installer push. sha256 pins the exact
# bytes the agent must fetch (warn-not-block for now; hardened later);
# silentargs carries this version's unattended-install switches because
# homegrown EXEs vary by developer (MSI is uniform via msiexec).
sha256 = db.Column(db.String(64))
silentargs = db.Column(db.String(255))
# Relationships # Relationships
application = db.relationship('Application', back_populates='versions') application = db.relationship('Application', back_populates='versions')
@@ -88,7 +99,9 @@ class AppVersion(db.Model):
'releasedate': self.releasedate.isoformat() if self.releasedate else None, 'releasedate': self.releasedate.isoformat() if self.releasedate else None,
'notes': self.notes, 'notes': self.notes,
'dateadded': self.dateadded.isoformat() + 'Z' if self.dateadded else None, 'dateadded': self.dateadded.isoformat() + 'Z' if self.dateadded else None,
'isactive': self.isactive 'isactive': self.isactive,
'sha256': self.sha256,
'silentargs': self.silentargs
} }
def __repr__(self): def __repr__(self):

View File

@@ -0,0 +1,43 @@
"""ShopDB-hosted script library for endpoint run_script delivery.
A script is authored/uploaded once here, and the endpoint agent fetches its
body by digest on push (mirroring the application installer delivery path). The
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.
"""
from shopdb.extensions import db
from .base import BaseModel
# Execution context for a delivered script. SYSTEM is the service account the
# agent already runs as (no user session, cannot see mapped drives); USER runs
# in the interactive logged-on user's session. Default SYSTEM stays fail-safe.
RUNCONTEXT_SYSTEM = 'system'
RUNCONTEXT_USER = 'user'
RUNCONTEXTS = (RUNCONTEXT_SYSTEM, RUNCONTEXT_USER)
class Script(BaseModel):
"""ShopDB-hosted PowerShell script the endpoint agent runs on push."""
__tablename__ = 'scripts'
scriptid = db.Column(db.Integer, primary_key=True)
scriptname = db.Column(db.String(100), unique=True, nullable=False)
scriptdescription = db.Column(db.String(255))
# Stored file name under the scripts upload directory (script-<id>.ps1). The
# body is served by the collector-authenticated download route.
filename = db.Column(db.String(255))
# Digest of the stored body; the agent verifies this before running.
sha256 = db.Column(db.String(64))
# '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)
def to_dict(self):
result = super().to_dict()
# hasbody lets the UI disable pushing a script with no uploaded file.
result['hasbody'] = bool(self.filename and self.sha256)
return result
def __repr__(self):
return f"<Script {self.scriptname}>"

View File

@@ -0,0 +1,42 @@
"""AppVersion installer-delivery metadata: sha256 + silentargs.
These two per-version fields feed the endpoint installer push: sha256 pins the
exact bytes the agent fetches, and silentargs carries the version's unattended-
install switches. The API must accept and echo both, and the model must
serialize them.
"""
def _create_app(client, auth_headers, appname='DeliveryApp'):
resp = client.post('/api/applications',
json={'appname': appname, 'isinstallable': True},
headers=auth_headers)
return resp.get_json()['data']['appid']
def test_create_version_accepts_and_echoes_delivery_metadata(client, db, auth_headers):
"""POST versions with sha256 + silentargs; the response carries both back."""
appid = _create_app(client, auth_headers)
digest = 'a' * 64
resp = client.post(f'/api/applications/{appid}/versions',
json={'version': '1.2.3',
'sha256': digest,
'silentargs': '/S /v/qn'},
headers=auth_headers)
assert resp.status_code == 201
data = resp.get_json()['data']
assert data['sha256'] == digest
assert data['silentargs'] == '/S /v/qn'
def test_version_delivery_metadata_defaults_to_null(client, db, auth_headers):
"""A version created without delivery metadata serializes both as null,
so a version that predates the push feature reads cleanly."""
appid = _create_app(client, auth_headers, appname='NoMetaApp')
resp = client.post(f'/api/applications/{appid}/versions',
json={'version': '0.9.0'},
headers=auth_headers)
assert resp.status_code == 201
data = resp.get_json()['data']
assert data['sha256'] is None
assert data['silentargs'] is None

View File

@@ -83,6 +83,15 @@ PUBLIC_ENDPOINTS = {
# requesting user. Same reasoning as endpoints.report_command_result in # requesting user. Same reasoning as endpoints.report_command_result in
# tests/test_core/test_authz.py. # tests/test_core/test_authz.py.
'endpoints.claim_next_command', 'endpoints.claim_next_command',
# The endpoint agent downloads an installer package for an update_app
# command. Authenticated inside the handler by the endpoints collector API
# key - the same shape as claim_next_command - because a bay has no user
# session. Queueing the update_app command is the actual authority and stays
# gated on endpoints.deploy, audited with the requesting user.
'endpoints.download_app_package',
'endpoints.download_app_package_by_app',
'endpoints.download_script_body',
} }
# Reads that need a LOGIN but no named permission, because the handler decides # Reads that need a LOGIN but no named permission, because the handler decides