Whether somebody with no account may see something was being answered one feature at a time: a securesend_allow_guests setting, an enable flag per kiosk, and very nearly a third for guides - each with its own settings page, its own vocabulary and its own hand-written branch inside a route. Every one of them answered a permissions question, and this app already administers permissions per role. So a caller with no token is not nobody: they hold the seeded `guest` role. require_permission consults it when there is no identity, which is the entire mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a site opens one by ticking a box in the same grid it uses for every other role, and closes it the same way. Revocation is read on every request rather than cached, because taking guest access away is what somebody does the moment they realise what they opened, and it has to bite immediately. The redundant @jwt_required() above require_permission is gone from 184 routes. It was belt-and-braces before; now it is actively wrong, because it refuses a request with no token before the permission check can have an opinion, so a grant would appear to do nothing. What a guest may hold is bounded. Any .view or .read permission is grantable. A WRITE has to be blessed by whoever owns it, through the new get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the only case that exists: sending a password to a vendor is the one write this product means to offer somebody who never signs in. Everything else is refused by the API and greyed out in the grid rather than warned about - a checkbox that quietly makes deleting an asset anonymous is not a warning anybody reads twice. A guest write records no creator: the trail says when and from where, never who, and that is the reason for the bar. GE-Enforce and Slides gained read permissions so they can be opened to guests without handing out the manage right. Their read routes accept either through require_any_permission, so nothing an existing manager could reach has moved. The navigation endpoint now returns only what the caller can actually reach. Without that a signed-out visitor gets a sidebar of the whole app where every link bounces off the login page, which reads as a broken site rather than as a closed door. A nav item may name several permissions; holding any one is enough, which is what GE-Enforce and Slides need now that a viewer and a manager hold different strings. Two things this found on the way: current_user is a LocalProxy, so `current_user is None` is False for a request with nobody behind it - harmless under a required JWT, an AttributeError the moment verification became optional. And an image pasted into a guide before its first save was never attached to it, so it was visible to editors and invisible to every reader; saving now attaches it, and `flask guides fix-images` repairs what is stored. The guides guest setting written earlier today is gone. Granting guides.view to the guest role replaces it, which is the point.
299 lines
13 KiB
Python
299 lines
13 KiB
Python
"""GE-Enforce manifest-store plugin.
|
|
|
|
Owns the imaging-PC-type scopes and their install manifests as shopdb data
|
|
(see docs/proposals/ge-enforce-plugin.md). This is the P0/P1 foundation: models,
|
|
the alias-graph seed, and the client-facing manifest endpoint. Authoring UI and
|
|
client cutover come in later phases.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Dict, Optional, Type
|
|
|
|
import click
|
|
from flask import Flask, Blueprint
|
|
|
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
|
from shopdb.api import db
|
|
|
|
from .api import geenforce_bp
|
|
from .models import (
|
|
ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
|
|
ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess,
|
|
ManifestPublishedVersion, ManifestPayload, ManifestBlob,
|
|
ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias,
|
|
)
|
|
from .filters import ALIAS_GROUPS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GeEnforcePlugin(BasePlugin):
|
|
"""Desired-state manifest store for the GE-Enforce client."""
|
|
|
|
def __init__(self):
|
|
self._manifest = self._load_manifest()
|
|
|
|
def _load_manifest(self) -> Dict:
|
|
manifest_path = Path(__file__).parent / 'manifest.json'
|
|
if manifest_path.exists():
|
|
with open(manifest_path, 'r') as handle:
|
|
return json.load(handle)
|
|
return {}
|
|
|
|
@property
|
|
def meta(self) -> PluginMeta:
|
|
return PluginMeta(
|
|
name=self._manifest.get('name', 'geenforce'),
|
|
version=self._manifest.get('version', '0.1.0'),
|
|
description=self._manifest.get('description', 'GE-Enforce manifest store'),
|
|
author=self._manifest.get('author', 'ShopDB Team'),
|
|
dependencies=self._manifest.get('dependencies', []),
|
|
core_version=self._manifest.get('core_version', '>=0.7.0,<1.0.0'),
|
|
api_prefix=self._manifest.get('api_prefix', '/api/geenforce'),
|
|
)
|
|
|
|
def get_blueprint(self) -> Optional[Blueprint]:
|
|
return geenforce_bp
|
|
|
|
def get_models(self) -> List[Type]:
|
|
return [
|
|
ManifestScope, ManifestEntry, ManifestEntryPcType,
|
|
ManifestEntryHostname, ManifestEntryMachineNumber,
|
|
ManifestInUseCheck, ManifestInUseCheckProcess,
|
|
ManifestPublishedVersion, ManifestPayload, ManifestBlob,
|
|
ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias,
|
|
]
|
|
|
|
def get_dashboard_widgets(self) -> List[Dict]:
|
|
"""Dashboard cards this plugin contributes.
|
|
|
|
DATA AND SHAPE, not a component name. The older widget contract named a
|
|
Vue component per widget, which cannot survive a lean build - a
|
|
plugin's component may never be staged into the frontend bundle - and
|
|
is why five plugins declared widgets pointing at components nobody ever
|
|
wrote. Core owns a small set of generic renderers; a plugin says what
|
|
to show and how to link it. Same lesson ADR-010 already applied to
|
|
asset panels.
|
|
|
|
`empty: hide` matters as much as the data. A card that reports "nothing
|
|
wrong" every day teaches people to stop reading the page, which is
|
|
exactly how a fleet log reached 3,234 lines with 17 that mattered.
|
|
"""
|
|
return [
|
|
{
|
|
'id': 'geenforce-failures',
|
|
'viewall': '/geenforce',
|
|
'title': 'Enforcement failures',
|
|
'endpoint': '/api/geenforce/dashboard/failures',
|
|
'render': 'exceptions',
|
|
'severity': 'critical',
|
|
'permission': 'geenforce.manage',
|
|
'empty': 'hide',
|
|
'position': 10,
|
|
'map': {
|
|
'title': 'hostname',
|
|
'detail': 'entryname',
|
|
'meta': [{'key': 'message'}, {'key': 'exitcode',
|
|
'label': 'exit'}],
|
|
'link': '/pcs/{computerid}',
|
|
'timestamp': 'receivedat',
|
|
},
|
|
},
|
|
]
|
|
|
|
def get_permissions(self) -> List:
|
|
"""RBAC permissions this plugin owns (edit vs ship are split)."""
|
|
return [
|
|
('geenforce.view', 'View imaging PC types and manifests',
|
|
'geenforce'),
|
|
('geenforce.manage', 'Edit imaging PC types and manifest drafts',
|
|
'geenforce'),
|
|
('geenforce.publish', 'Publish, roll back, and export manifests',
|
|
'geenforce'),
|
|
('geenforce.fetch', 'Fetch published manifests (client service token)',
|
|
'geenforce'),
|
|
('geenforce.report', 'Report enforcement results (client service token)',
|
|
'geenforce'),
|
|
]
|
|
|
|
def get_navigation_items(self) -> List[Dict]:
|
|
"""Top-level sidebar section (GE-Enforce is a large operational surface,
|
|
not a mere setting). The tabbed shell hosts Manifests + Reports."""
|
|
return [
|
|
{
|
|
'name': 'GE-Enforce',
|
|
'icon': 'shield',
|
|
'route': '/geenforce/manifests',
|
|
'position': 46,
|
|
# The READ permission: managing implies it, and it is the one a
|
|
# site can hand to guests.
|
|
'permission': ['geenforce.view', 'geenforce.manage'],
|
|
'section': 'fleet',
|
|
},
|
|
]
|
|
|
|
def get_settings_cards(self) -> List[Dict]:
|
|
"""Admin config for GE-Enforce lives in the Settings rail."""
|
|
return [
|
|
{
|
|
'group': 'GE-Enforce',
|
|
'to': '/settings/geenforce',
|
|
'icon': 'shield',
|
|
'title': 'GE-Enforce Settings',
|
|
'description': 'Client IP allowlist for token-less fleet access',
|
|
'position': 46,
|
|
},
|
|
]
|
|
|
|
def get_settings_defaults(self) -> List[Dict]:
|
|
"""How long a PC may go quiet before the fleet table calls it stale."""
|
|
return [
|
|
{
|
|
'key': 'geenforce_reportstaleminutes',
|
|
'value': '30',
|
|
'valuetype': 'integer',
|
|
'category': 'geenforce',
|
|
'description': 'Minutes without an enforcement report before a '
|
|
'PC is shown as stale. A report records how the '
|
|
'LAST cycle went, so a PC that stops reporting '
|
|
'keeps whatever status it last sent - it reads '
|
|
'as healthy while it is switched off. Set to 0 '
|
|
'to disable the check.',
|
|
# Server-side only: nothing on a PC reads this, so it stays off
|
|
# the public settings surface.
|
|
'public': False,
|
|
},
|
|
]
|
|
|
|
def init_app(self, app: Flask, db_instance) -> None:
|
|
logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})")
|
|
|
|
def on_install(self, app: Flask) -> None:
|
|
with app.app_context():
|
|
self._seed_aliases()
|
|
db.session.commit()
|
|
logger.info("GE-Enforce plugin installed")
|
|
|
|
def _seed_aliases(self) -> None:
|
|
"""Seed pctypealiases from the engine lib's alias graph (idempotent)."""
|
|
for group_index, group in enumerate(ALIAS_GROUPS):
|
|
for aliasname in group:
|
|
exists = PcTypeAlias.query.filter_by(
|
|
aliasgroup=group_index, aliasname=aliasname).first()
|
|
if not exists:
|
|
db.session.add(PcTypeAlias(
|
|
aliasgroup=group_index, aliasname=aliasname))
|
|
|
|
def get_cli_commands(self) -> List:
|
|
"""CLI: parity (Gate A), import-share, export-share, publish."""
|
|
|
|
@click.group('geenforce')
|
|
def geenforce_cli():
|
|
"""GE-Enforce manifest-store commands."""
|
|
|
|
@geenforce_cli.command('parity')
|
|
@click.option('--shareroot', required=True,
|
|
help='GE-Enforce share root (contains common/, '
|
|
'gea-shopfloor-*/).')
|
|
@click.option('--preinstall', default=None,
|
|
help='Optional path to a preinstall.json to include.')
|
|
def parity_cmd(shareroot, preinstall):
|
|
"""Prove import+export is behaviorally lossless (Gate A). DB-free."""
|
|
from .importer import discover_share, load_manifest_file
|
|
from .parity import run_parity, format_result
|
|
|
|
manifests = list(discover_share(shareroot))
|
|
if preinstall:
|
|
manifests.append(
|
|
('preinstall', 'preinstall', load_manifest_file(preinstall)))
|
|
results, ok = run_parity(manifests)
|
|
for result in results:
|
|
click.echo(format_result(result))
|
|
click.echo(f"RESULT: {'PASS' if ok else 'FAIL'} "
|
|
f"({len(results)} scopes)")
|
|
raise SystemExit(0 if ok else 1)
|
|
|
|
@geenforce_cli.command('import-share')
|
|
@click.option('--shareroot', required=True)
|
|
@click.option('--preinstall', default=None)
|
|
@click.option('--scope', default=None,
|
|
help='Import only this scope name (else all).')
|
|
def import_share_cmd(shareroot, preinstall, scope):
|
|
"""Import on-share manifests into draft rows (idempotent rebuild)."""
|
|
from flask import current_app
|
|
from .importer import discover_share, load_manifest_file
|
|
from .service import replace_scope_draft
|
|
|
|
with current_app.app_context():
|
|
sources = list(discover_share(shareroot))
|
|
if preinstall:
|
|
sources.append(('preinstall', 'preinstall',
|
|
load_manifest_file(preinstall)))
|
|
count = 0
|
|
for name, phase, manifest in sources:
|
|
if scope and name != scope:
|
|
continue
|
|
replace_scope_draft(name, phase, manifest)
|
|
count += 1
|
|
db.session.commit()
|
|
click.echo(f"Imported {count} scope(s).")
|
|
|
|
@geenforce_cli.command('publish')
|
|
@click.argument('scopename')
|
|
@click.option('--phase', default='runtime')
|
|
@click.option('--notes', default=None)
|
|
@click.option('--force', is_flag=True,
|
|
help='Publish even when reporting PCs run an older '
|
|
'enforcer lib than the manifest requires.')
|
|
def publish_cmd(scopename, phase, notes, force):
|
|
"""Freeze the current draft of a scope into a published snapshot."""
|
|
from flask import current_app
|
|
from .service import publish_scope, LibVersionTooOldError
|
|
|
|
with current_app.app_context():
|
|
try:
|
|
version = publish_scope(scopename, phase, notes=notes,
|
|
force=force)
|
|
except LibVersionTooOldError as error:
|
|
click.echo(click.style(f'REFUSED: {error}', fg='red'))
|
|
raise SystemExit(1)
|
|
db.session.commit()
|
|
click.echo(f"Published {scopename}/{phase} as v{version}.")
|
|
|
|
@geenforce_cli.command('export-share')
|
|
@click.argument('scopename')
|
|
@click.option('--shareroot', required=True)
|
|
@click.option('--phase', default='runtime')
|
|
def export_share_cmd(scopename, shareroot, phase):
|
|
"""Write a scope's published JSON to the share (with history backup)."""
|
|
from flask import current_app
|
|
from .service import export_scope_to_share
|
|
|
|
with current_app.app_context():
|
|
path = export_scope_to_share(scopename, phase, shareroot)
|
|
click.echo(f"Exported to {path}.")
|
|
|
|
@geenforce_cli.command('add-payload')
|
|
@click.argument('filepath')
|
|
@click.option('--contenttype', default=None)
|
|
def add_payload_cmd(filepath, contenttype):
|
|
"""Store a file in the content-addressed payload store for HTTPS
|
|
delivery, and print its sha256. Set an entry's PayloadSource=http +
|
|
PayloadSha256 to serve it from GET /api/geenforce/payload/<sha>."""
|
|
import os as _os
|
|
from flask import current_app
|
|
from .service import store_blob
|
|
|
|
with current_app.app_context():
|
|
with open(filepath, 'rb') as handle:
|
|
raw = handle.read()
|
|
sha = store_blob(raw, _os.path.basename(filepath), contenttype)
|
|
db.session.commit()
|
|
click.echo(f"stored {len(raw)} bytes")
|
|
click.echo(f"PayloadSha256: {sha}")
|
|
click.echo(f"URL: /api/geenforce/payload/{sha}")
|
|
|
|
return [geenforce_cli]
|