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:
committed by
cproudlock
parent
5f8061c55d
commit
7ac1772881
46
migrations/versions/7d35_appversion_delivery_metadata.py
Normal file
46
migrations/versions/7d35_appversion_delivery_metadata.py
Normal 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')
|
||||||
@@ -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')
|
||||||
@@ -261,6 +261,7 @@ CORE_BLUEPRINT_NAMES = (
|
|||||||
'dashboard',
|
'dashboard',
|
||||||
'dashboarddefaults',
|
'dashboarddefaults',
|
||||||
'applications',
|
'applications',
|
||||||
|
'scripts',
|
||||||
'supportteams',
|
'supportteams',
|
||||||
'search',
|
'search',
|
||||||
'reports',
|
'reports',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
206
shopdb/core/api/scripts.py
Normal 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)
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
43
shopdb/core/models/script_library.py
Normal file
43
shopdb/core/models/script_library.py
Normal 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}>"
|
||||||
42
tests/test_core/test_appversion_delivery.py
Normal file
42
tests/test_core/test_appversion_delivery.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user