Add curated manifest-entry -> Application link (honest app tracking)
All checks were successful
CI / backend (push) Successful in 1m34s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

The honest replacement for the backed-out auto-seeding: instead of scraping
manifest labels into duplicate Application rows, an entry can be LINKED to an
existing catalog Application, cross-referencing what shopdb already tracks.

- Model: manifestentries.appid (nullable soft ref to core applications; in the
  0001 baseline). It is shopdb METADATA, deliberately NOT a manifest field - it
  never appears in the rendered manifest JSON, so enforcement + parity are
  unaffected (test asserts it stays out of the preview manifest).
- API: _entry_payload returns appid + resolved appname; create/update accept an
  optional appid (validated, unknown id ignored, null unlinks) via _apply_app_link;
  GET /geenforce/applications is the picker source (id + name).
- Editor: a "Tracked application (optional)" select in the entry modal, and the
  entry summary line notes the linked app ("...; tracked: eDNC").
- Foundation for a future desired-vs-observed compliance view.

889 tests green (incl. the link test + parity/migration unaffected); build +
naming green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-13 06:45:10 -04:00
parent 810687953f
commit 3355436fcd
5 changed files with 100 additions and 5 deletions

View File

@@ -17,7 +17,7 @@ from sqlalchemy.exc import IntegrityError
from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission,
service_token_authorized, Setting,
service_token_authorized, Setting, Application,
)
SHAREROOT_SETTING = 'geenforce_share_root'
@@ -160,6 +160,18 @@ def preview_scope(scopeid):
'manifest': scope_to_manifest(scope)})
@geenforce_bp.route('/applications', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_applications():
"""The core Applications catalog (id + name) for the curated entry->app link
picker in the entry editor."""
apps = Application.query.filter_by(isactive=True).order_by(
Application.appname).all()
return success_response([{'appid': a.appid, 'appname': a.appname}
for a in apps])
# -- scope CRUD (geenforce.manage) --------------------------------------------
def _scope_summary(scope):
@@ -179,12 +191,32 @@ def _scope_summary(scope):
def _entry_payload(entry):
"""Manifest-entry dict with entryid + sortorder for the editor."""
data = {'entryid': entry.entryid, 'sortorder': entry.sortorder}
"""Manifest-entry dict with entryid + sortorder + the curated app link.
appid/appname are shopdb metadata (not manifest fields), added alongside the
rendered manifest entry for the editor.
"""
data = {'entryid': entry.entryid, 'sortorder': entry.sortorder,
'appid': entry.appid, 'appname': None}
if entry.appid:
app = db.session.get(Application, entry.appid)
data['appname'] = app.appname if app else None
data.update(entry_to_dict(entry))
return data
def _apply_app_link(entry, payload):
"""Set the optional curated appid from the payload (shopdb metadata, not a
manifest field, so handled outside populate_entry). Ignores an unknown id."""
if 'appid' not in payload:
return
appid = payload.get('appid')
if appid in (None, '', 0):
entry.appid = None
elif db.session.get(Application, int(appid)):
entry.appid = int(appid)
@geenforce_bp.route('/scopes', methods=['POST'])
@jwt_required()
@require_permission('geenforce.manage')
@@ -280,6 +312,7 @@ def create_entry(scopeid):
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1
entry = build_entry(payload, nextorder)
_apply_app_link(entry, payload)
scope.entries.append(entry)
try:
db.session.commit()
@@ -309,6 +342,7 @@ def update_entry(entryid):
entry.inusecheck = None
db.session.flush()
populate_entry(entry, payload)
_apply_app_link(entry, payload)
try:
db.session.commit()
except IntegrityError:

View File

@@ -68,6 +68,7 @@ def upgrade():
sa.Column('waittimeoutsec', sa.Integer(), nullable=True),
sa.Column('applymode', sa.String(length=16), nullable=True),
sa.Column('updatewindow', sa.String(length=11), nullable=True),
sa.Column('appid', sa.Integer(), nullable=True),
sa.Column('preenrollment', sa.Boolean(), nullable=False),
sa.Column('killafterdetection', sa.Boolean(), nullable=False),
sa.Column('pctypesstrict', sa.Boolean(), nullable=False),

View File

@@ -119,6 +119,11 @@ class ManifestEntry(BaseModel):
applymode = db.Column(db.String(16), nullable=True) # inert in engine today
updatewindow = db.Column(db.String(11), nullable=True) # 'HH:MM-HH:MM', inert
# Optional CURATED link to a core Applications catalog row (soft ref, no FK
# to keep the plugin decoupled). shopdb metadata only - NOT part of the
# manifest JSON the engine reads, so it never affects enforcement or parity.
appid = db.Column(db.Integer, nullable=True)
# Preinstall-only flags.
preenrollment = db.Column(db.Boolean, nullable=False, default=False)
killafterdetection = db.Column(db.Boolean, nullable=False, default=False)