Add curated manifest-entry -> Application link (honest app tracking)
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:
@@ -277,6 +277,16 @@
|
||||
<small v-if="isPreinstallScope" class="input-hint">Preinstall runs at imaging and supports MSI/EXE only.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Tracked application (optional)</span>
|
||||
<select v-model="entryForm.appid">
|
||||
<option :value="null">(not linked)</option>
|
||||
<option v-for="app in applications" :key="app.appid" :value="app.appid">{{ app.appname }}</option>
|
||||
</select>
|
||||
<small class="input-hint">Link this entry to a tracked Application in the catalog (metadata; not part of the manifest).</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- payload fields per type -->
|
||||
@@ -566,6 +576,7 @@ const newScope = ref({ scopename: '', phase: 'runtime' })
|
||||
|
||||
const showEntry = ref(false)
|
||||
const entryForm = ref({})
|
||||
const applications = ref([])
|
||||
|
||||
const showVersions = ref(false)
|
||||
const versions = ref([])
|
||||
@@ -645,7 +656,7 @@ async function deleteScope() {
|
||||
// -- entries --
|
||||
function blankEntry() {
|
||||
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
|
||||
inuseBehavior: '', inuseProcesses: [],
|
||||
inuseBehavior: '', inuseProcesses: [], appid: null,
|
||||
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
|
||||
}
|
||||
function openNewEntry() {
|
||||
@@ -682,7 +693,9 @@ function splitList(value) {
|
||||
}
|
||||
function buildEntryPayload() {
|
||||
const form = entryForm.value
|
||||
const out = { Name: form.Name, Type: form.Type }
|
||||
// appid is shopdb metadata, always sent (null unlinks); the backend keeps it
|
||||
// off the manifest JSON.
|
||||
const out = { Name: form.Name, Type: form.Type, appid: form.appid ?? null }
|
||||
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
|
||||
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
|
||||
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
|
||||
@@ -828,14 +841,20 @@ function describeEntry(entry) {
|
||||
} else if (entry.PCTypes && entry.PCTypes.length) {
|
||||
text += `; ${entry.PCTypes.length} PC type(s)`
|
||||
}
|
||||
if (entry.appname) text += `; tracked: ${entry.appname}`
|
||||
return text
|
||||
}
|
||||
function formatDate(value) {
|
||||
return value ? new Date(value).toLocaleString() : ''
|
||||
}
|
||||
|
||||
async function loadApplications() {
|
||||
try { applications.value = payload(await api.get('/geenforce/applications')) } catch (e) { /* optional */ }
|
||||
}
|
||||
|
||||
loadScopes()
|
||||
loadConfig()
|
||||
loadApplications()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -108,6 +108,42 @@ def test_entry_edit_preserves_full_fidelity(client, db, auth_headers):
|
||||
assert proc['GracefulCloseTimeoutSec'] == 15
|
||||
|
||||
|
||||
def test_entry_curated_app_link(client, db, auth_headers):
|
||||
"""An entry can be linked to an existing Application; the link is shopdb
|
||||
metadata and must NOT leak into the manifest JSON (parity/enforcement)."""
|
||||
from shopdb.core.models import Application
|
||||
app_row = Application(appname='eDNC (tracked)')
|
||||
db.session.add(app_row)
|
||||
db.session.commit()
|
||||
appid = app_row.appid
|
||||
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
created = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
json={'Name': 'eDNC install', 'Type': 'MSI',
|
||||
'appid': appid}, headers=auth_headers)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
data = created.get_json()['data']
|
||||
assert data['appid'] == appid
|
||||
assert data['appname'] == 'eDNC (tracked)'
|
||||
|
||||
# the link is NOT in the rendered manifest (would break the engine contract)
|
||||
manifest = client.get(f'/api/geenforce/scopes/{scopeid}/preview',
|
||||
headers=auth_headers).get_json()['data']['manifest']
|
||||
entry_json = manifest['Applications'][0]
|
||||
assert 'appid' not in entry_json and 'appname' not in entry_json
|
||||
|
||||
# unlink
|
||||
updated = client.put(f"/api/geenforce/entries/{data['entryid']}",
|
||||
json={'Name': 'eDNC install', 'Type': 'MSI', 'appid': None},
|
||||
headers=auth_headers)
|
||||
assert updated.get_json()['data']['appid'] is None
|
||||
|
||||
# picker lists the app
|
||||
picker = client.get('/api/geenforce/applications',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert any(a['appname'] == 'eDNC (tracked)' for a in picker)
|
||||
|
||||
|
||||
def test_invalid_entry_type_rejected(client, db, auth_headers):
|
||||
scopeid = _create_scope(client, auth_headers)
|
||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||
|
||||
Reference in New Issue
Block a user