ADR-013 Phase 2: enforcement + signed shelf + adopt
Completes the marketplace security model. Verification stops being advisory: a plugin only loads or migrates when its tree matches a trusted signature, and plugins are pulled from a signed shelf with anti-rollback and revocation. Enforcement (default OFF - existing deploys unchanged): - verification.py PluginVerifier, shared by the loader (verify-at-load, before plugin.py is imported) and the migration manager (verify-at-migrate, before any DDL). Fail-closed: an unsigned/tampered/wrong-key plugin does not run. - Gated by PLUGIN_REQUIRE_SIGNED. PLUGIN_DEV_TRUST_DIRS exempts named dirs but only under DEBUG/TESTING; production ignores it. - flask plugin stamp-bundled writes provenance into in-tree plugins so verify-at-load applies to bundled plugins too (image build step). - tier:core manifest guard: uninstall/disable refuse a core-tier plugin. Shelf (shelf.py): - Signed shelf-index.json (+ .sig): monotonic serial (a site refuses an older index - anti-rollback), revoked list carried across builds, per-entry version/tier/core_version for browse. Index is a browse layer only; adopt reads security-bearing fields from the verified artifact. - flask plugin shelf-build / shelf-list / adopt / audit. adopt verifies index + artifact (signature + every file hash), unpacks to staging, re-verifies, then atomically moves into place and installs+enables the closure. Refuses a downgrade without --force-downgrade. Anti-rollback serial stored in instance/shelf-state.json. - config PLUGIN_SHELF_DIR; the app only reads the folder, never speaks a network. .env.example + docs/PLUGIN-SIGNING.md document the flow. 22 tests: verifier policy (off / no-keys / signed / tampered / wrong-key / dev-exempt), verify-at-load + verify-at-migrate integration, tier guard, index sign/verify + tamper/wrong-key, serial state, revocation, version resolution, verified atomic unpack + tamper refusal. Live-smoked keygen->pack->shelf-build ->list->adopt->audit + serial guard. 1050 pass, naming green.
This commit is contained in:
@@ -39,6 +39,7 @@ class PluginManager:
|
||||
self.registry: Optional[PluginRegistry] = None
|
||||
self.loader: Optional[PluginLoader] = None
|
||||
self.migration_manager: Optional[PluginMigrationManager] = None
|
||||
self.verifier = None
|
||||
self._app: Optional[Flask] = None
|
||||
self._db = None
|
||||
# API prefixes already claimed by a registered plugin blueprint, to
|
||||
@@ -66,6 +67,19 @@ class PluginManager:
|
||||
app.config.get('SQLALCHEMY_DATABASE_URI')
|
||||
)
|
||||
|
||||
# One trust policy, shared by verify-at-load and verify-at-migrate.
|
||||
from .verification import PluginVerifier
|
||||
verifier = PluginVerifier(
|
||||
plugins_dir,
|
||||
require_signed=app.config.get('PLUGIN_REQUIRE_SIGNED', False),
|
||||
trusted_key_paths=app.config.get('PLUGIN_TRUSTED_KEYS', []),
|
||||
dev_trust_dirs=app.config.get('PLUGIN_DEV_TRUST_DIRS', []),
|
||||
is_dev=bool(app.config.get('DEBUG') or app.config.get('TESTING')),
|
||||
)
|
||||
self.verifier = verifier
|
||||
self.loader.verifier = verifier
|
||||
self.migration_manager.verifier = verifier
|
||||
|
||||
# Load enabled plugins
|
||||
self._load_enabled_plugins()
|
||||
|
||||
@@ -257,6 +271,18 @@ class PluginManager:
|
||||
"Seeded %d permission(s) for plugin %s",
|
||||
created, plugin.meta.name)
|
||||
|
||||
def _is_core_tier(self, name: str) -> bool:
|
||||
"""True when the plugin's manifest marks it tier=core (mandatory).
|
||||
|
||||
A core-tier plugin refuses uninstall/disable so a site cannot remove a
|
||||
plugin its deployment depends on. No plugin ships tier=core today; the
|
||||
guard makes that a manifest edit, not a code change (ADR-013).
|
||||
"""
|
||||
try:
|
||||
return self.loader.load_manifest(name).get('tier') == 'core'
|
||||
except PluginError:
|
||||
return False
|
||||
|
||||
def _installed_dependents(self, name: str,
|
||||
enabled_only: bool = False) -> List[str]:
|
||||
"""Installed plugins that declare `name` as a hard dependency.
|
||||
@@ -293,6 +319,10 @@ class PluginManager:
|
||||
logger.warning(f"Plugin {name} is not installed")
|
||||
return False
|
||||
|
||||
if self._is_core_tier(name):
|
||||
logger.error(f"Cannot uninstall {name}: it is a core-tier plugin")
|
||||
return False
|
||||
|
||||
# Refuse if any INSTALLED plugin (enabled or not) depends on this one.
|
||||
dependents = self._installed_dependents(name)
|
||||
if dependents:
|
||||
@@ -372,6 +402,10 @@ class PluginManager:
|
||||
logger.info(f"Plugin {name} is already disabled")
|
||||
return True
|
||||
|
||||
if self._is_core_tier(name):
|
||||
logger.error(f"Cannot disable {name}: it is a core-tier plugin")
|
||||
return False
|
||||
|
||||
# Refuse if any ENABLED plugin depends on this one (a disabled dependent
|
||||
# is not running, so it does not block). Manifests read from disk.
|
||||
dependents = self._installed_dependents(name, enabled_only=True)
|
||||
|
||||
Reference in New Issue
Block a user