Fix defects found in session review of GE-Enforce plugin
Consolidated fixes from a three-dimension adversarial review. Data-loss (HIGH): the manifest entry editor stripped fields the form did not expose, because PUT /entries is a full reset-then-apply. The form now captures everything - InUseCheck processes as structured name/ExePath/timeout rows (not just names), LogFile, and the three preinstall flags as checkboxes; the dead payload-source control (never wired) is removed. New regression test proves an edit preserves ExePath/timeout/LogFile/PreEnrollment/PCTypesStrict. Update-entry crash (found by that regression test): replacing an entry's one-to-one InUseCheck (unique entryid) collided with the old row mid-flush -> IntegrityError -> 400. update_entry now frees the old InUseCheck (delete+flush) before populate re-inserts it. Export truncation (MEDIUM): export_scope_to_share used a plain truncating open, so a failed/partial write left the live on-share manifest (every PC reads it) empty. Now writes a temp file in the same dir and os.replace() atomically. Report dedup case bug (MEDIUM, confirmed by scratch test): the iscurrent demote matched hostname case-sensitively while the read path uses ilike, so a PC reporting different casing left two iscurrent rows and double-counted. Demote is now case-insensitive; regression test added. Simulator fidelity (MEDIUM): PCTypesStrict was captured but ignored by the filter mirror, so the simulator wrongly matched a collections-only strict entry to a nocollections PC via the shared Standard alias group. matches_pctype now honors PCTypesStrict (disables alias expansion); test added. Hardening: removed the dead/unscoped GEENFORCE_API_KEY env fallback (never wired into config; tokens are the only path); create/update entry return 400 on a duplicate Name instead of 500; parity now asserts scope-level Version/Site; a new test guards real-manifest field lengths against column limits (the DB-free parity harness can't see truncation); error handling added to the previously unguarded editor + reports API calls. Full suite green; naming + frontend build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="enforcement-reports">
|
<div class="enforcement-reports">
|
||||||
<div class="page-header"><h1>Enforcement Reports</h1></div>
|
<div class="page-header"><h1>Enforcement Reports</h1></div>
|
||||||
|
<div v-if="error" class="alert-error">{{ error }}</div>
|
||||||
<p class="intro">
|
<p class="intro">
|
||||||
Latest GE-Enforce result reported by each PC. "Received" means the PC picked
|
Latest GE-Enforce result reported by each PC. "Received" means the PC picked
|
||||||
up the current published manifest; status shows self-heal and failures.
|
up the current published manifest; status shows self-heal and failures.
|
||||||
@@ -75,6 +76,7 @@ import api from '../../api'
|
|||||||
|
|
||||||
const reports = ref([])
|
const reports = ref([])
|
||||||
const detail = ref(null)
|
const detail = ref(null)
|
||||||
|
const error = ref('')
|
||||||
const filterHost = ref('')
|
const filterHost = ref('')
|
||||||
const filterScope = ref('')
|
const filterScope = ref('')
|
||||||
|
|
||||||
@@ -84,11 +86,16 @@ async function load() {
|
|||||||
const params = {}
|
const params = {}
|
||||||
if (filterHost.value) params.hostname = filterHost.value
|
if (filterHost.value) params.hostname = filterHost.value
|
||||||
if (filterScope.value) params.scopename = filterScope.value
|
if (filterScope.value) params.scopename = filterScope.value
|
||||||
reports.value = payload(await api.get('/geenforce/reports', { params }))
|
try {
|
||||||
|
reports.value = payload(await api.get('/geenforce/reports', { params }))
|
||||||
|
error.value = ''
|
||||||
|
} catch (e) { error.value = 'Failed to load reports' }
|
||||||
}
|
}
|
||||||
function clearFilters() { filterHost.value = ''; filterScope.value = ''; load() }
|
function clearFilters() { filterHost.value = ''; filterScope.value = ''; load() }
|
||||||
async function openDetail(reportid) {
|
async function openDetail(reportid) {
|
||||||
detail.value = payload(await api.get(`/geenforce/reports/${reportid}`))
|
try {
|
||||||
|
detail.value = payload(await api.get(`/geenforce/reports/${reportid}`))
|
||||||
|
} catch (e) { error.value = 'Failed to load report detail' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusClass(status) {
|
function statusClass(status) {
|
||||||
@@ -106,6 +113,8 @@ load()
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.enforcement-reports { max-width: 1100px; }
|
.enforcement-reports { max-width: 1100px; }
|
||||||
.intro { color: var(--text-light); margin: 0 0 1rem 0; }
|
.intro { color: var(--text-light); margin: 0 0 1rem 0; }
|
||||||
|
.alert-error { background: var(--danger); color: white; padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 4px; margin-bottom: 1rem; }
|
||||||
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||||
.dim { color: var(--text-light); }
|
.dim { color: var(--text-light); }
|
||||||
.fail-count { color: var(--danger); font-weight: 600; }
|
.fail-count { color: var(--danger); font-weight: 600; }
|
||||||
|
|||||||
@@ -215,26 +215,39 @@
|
|||||||
<label>CMM version gate<input v-model="entryForm._CmmVersion" placeholder="2016 / 2019 / 2026" /></label>
|
<label>CMM version gate<input v-model="entryForm._CmmVersion" placeholder="2016 / 2019 / 2026" /></label>
|
||||||
|
|
||||||
<label>Wait timeout (sec)<input type="number" v-model.number="entryForm.WaitTimeoutSec" /></label>
|
<label>Wait timeout (sec)<input type="number" v-model.number="entryForm.WaitTimeoutSec" /></label>
|
||||||
<label>Payload source
|
<label>Log file<input v-model="entryForm.LogFile" /></label>
|
||||||
<select v-model="entryForm.payloadsource">
|
|
||||||
<option value="smb">smb</option>
|
|
||||||
<option value="http">http</option>
|
|
||||||
<option value="inline">inline</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="full">Comment<textarea v-model="entryForm._comment" rows="2" /></label>
|
<label class="full">Comment<textarea v-model="entryForm._comment" rows="2" /></label>
|
||||||
|
|
||||||
<details class="full advanced">
|
<details class="full advanced" open>
|
||||||
<summary>Advanced (not yet enforced by the engine)</summary>
|
<summary>In-use handling + preinstall flags</summary>
|
||||||
<label>Apply mode<input v-model="entryForm.ApplyMode" placeholder="Nightly / Immediate" /></label>
|
<label>Close running app before install (InUseCheck)
|
||||||
<label>Update window<input v-model="entryForm.UpdateWindow" placeholder="HH:MM-HH:MM" /></label>
|
|
||||||
<label>InUseCheck behavior
|
|
||||||
<select v-model="entryForm.inuseBehavior">
|
<select v-model="entryForm.inuseBehavior">
|
||||||
<option value="">(none)</option>
|
<option value="">(none)</option>
|
||||||
<option v-for="b in INUSE_BEHAVIORS" :key="b" :value="b">{{ b }}</option>
|
<option v-for="b in INUSE_BEHAVIORS" :key="b" :value="b">{{ b }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>InUseCheck processes (comma of names)<input v-model="entryForm.inuseProcesses" /></label>
|
<div class="full" v-if="entryForm.inuseBehavior">
|
||||||
|
<div class="proc-head">Processes to close
|
||||||
|
<button type="button" class="btn btn-small" @click="addProcess">+ process</button>
|
||||||
|
</div>
|
||||||
|
<div v-for="(proc, i) in entryForm.inuseProcesses" :key="i" class="proc-row">
|
||||||
|
<input v-model="proc.name" placeholder="Name (no .exe)" />
|
||||||
|
<input v-model="proc.exepath" placeholder="ExePath (optional)" />
|
||||||
|
<input type="number" v-model.number="proc.timeout" placeholder="timeout s" />
|
||||||
|
<button type="button" class="btn-move" @click="removeProcess(i)">x</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="full preinstall-flags">
|
||||||
|
<span class="flags-label">Preinstall flags (preinstall phase only):</span>
|
||||||
|
<label class="inline"><input type="checkbox" v-model="entryForm.PreEnrollment" /> PreEnrollment</label>
|
||||||
|
<label class="inline"><input type="checkbox" v-model="entryForm.KillAfterDetection" /> KillAfterDetection</label>
|
||||||
|
<label class="inline"><input type="checkbox" v-model="entryForm.PCTypesStrict" /> PCTypesStrict</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details class="full advanced">
|
||||||
|
<summary>Apply mode / update window (not yet enforced by the engine)</summary>
|
||||||
|
<label>Apply mode<input v-model="entryForm.ApplyMode" placeholder="Nightly / Immediate" /></label>
|
||||||
|
<label>Update window<input v-model="entryForm.UpdateWindow" placeholder="HH:MM-HH:MM" /></label>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@@ -347,16 +360,19 @@ async function saveScope() {
|
|||||||
}
|
}
|
||||||
async function deleteScope() {
|
async function deleteScope() {
|
||||||
if (!confirm(`Delete ${detail.value.scopename}? This removes its manifest.`)) return
|
if (!confirm(`Delete ${detail.value.scopename}? This removes its manifest.`)) return
|
||||||
await api.delete(`/geenforce/scopes/${detail.value.scopeid}`)
|
try {
|
||||||
detail.value = null
|
await api.delete(`/geenforce/scopes/${detail.value.scopeid}`)
|
||||||
selectedId.value = null
|
detail.value = null
|
||||||
await loadScopes()
|
selectedId.value = null
|
||||||
|
await loadScopes()
|
||||||
|
} catch (e) { error.value = 'Delete failed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- entries --
|
// -- entries --
|
||||||
function blankEntry() {
|
function blankEntry() {
|
||||||
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
|
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
|
||||||
payloadsource: 'smb', inuseBehavior: '', inuseProcesses: '' }
|
inuseBehavior: '', inuseProcesses: [],
|
||||||
|
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
|
||||||
}
|
}
|
||||||
function openNewEntry() {
|
function openNewEntry() {
|
||||||
entryForm.value = blankEntry()
|
entryForm.value = blankEntry()
|
||||||
@@ -369,12 +385,24 @@ function openEditEntry(entry) {
|
|||||||
form.TargetMachineNumbers = (entry.TargetMachineNumbers || []).join(', ')
|
form.TargetMachineNumbers = (entry.TargetMachineNumbers || []).join(', ')
|
||||||
form.RegValue = entry.RegValue !== undefined ? String(entry.RegValue) : ''
|
form.RegValue = entry.RegValue !== undefined ? String(entry.RegValue) : ''
|
||||||
form.DetectionMethod = entry.DetectionMethod || ''
|
form.DetectionMethod = entry.DetectionMethod || ''
|
||||||
form.payloadsource = entry.payloadsource || 'smb'
|
|
||||||
form.inuseBehavior = entry.InUseCheck?.Behavior || ''
|
form.inuseBehavior = entry.InUseCheck?.Behavior || ''
|
||||||
form.inuseProcesses = (entry.InUseCheck?.Processes || []).map(p => p.Name).join(', ')
|
// Structured processes so ExePath + timeout survive an edit (not just Name).
|
||||||
|
form.inuseProcesses = (entry.InUseCheck?.Processes || []).map(p => ({
|
||||||
|
name: p.Name, exepath: p.ExePath || '',
|
||||||
|
timeout: p.GracefulCloseTimeoutSec ?? null }))
|
||||||
|
form.PreEnrollment = !!entry.PreEnrollment
|
||||||
|
form.KillAfterDetection = !!entry.KillAfterDetection
|
||||||
|
form.PCTypesStrict = !!entry.PCTypesStrict
|
||||||
entryForm.value = form
|
entryForm.value = form
|
||||||
showEntry.value = true
|
showEntry.value = true
|
||||||
}
|
}
|
||||||
|
function addProcess() {
|
||||||
|
if (!Array.isArray(entryForm.value.inuseProcesses)) entryForm.value.inuseProcesses = []
|
||||||
|
entryForm.value.inuseProcesses.push({ name: '', exepath: '', timeout: null })
|
||||||
|
}
|
||||||
|
function removeProcess(index) {
|
||||||
|
entryForm.value.inuseProcesses.splice(index, 1)
|
||||||
|
}
|
||||||
function splitList(value) {
|
function splitList(value) {
|
||||||
return (value || '').split(',').map(s => s.trim()).filter(Boolean)
|
return (value || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
@@ -384,7 +412,7 @@ function buildEntryPayload() {
|
|||||||
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
|
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
|
||||||
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
|
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
|
||||||
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
|
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
|
||||||
'ApplyMode', 'UpdateWindow', '_comment', 'payloadsource', 'payloadref']
|
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
|
||||||
for (const key of scalars) {
|
for (const key of scalars) {
|
||||||
if (f[key] !== undefined && f[key] !== '' && f[key] !== null) out[key] = f[key]
|
if (f[key] !== undefined && f[key] !== '' && f[key] !== null) out[key] = f[key]
|
||||||
}
|
}
|
||||||
@@ -399,9 +427,21 @@ function buildEntryPayload() {
|
|||||||
if (hostnames.length) out.TargetHostnames = hostnames
|
if (hostnames.length) out.TargetHostnames = hostnames
|
||||||
const machinenumbers = splitList(f.TargetMachineNumbers)
|
const machinenumbers = splitList(f.TargetMachineNumbers)
|
||||||
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
|
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
|
||||||
|
for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) {
|
||||||
|
if (f[flag]) out[flag] = true
|
||||||
|
}
|
||||||
if (f.inuseBehavior) {
|
if (f.inuseBehavior) {
|
||||||
out.InUseCheck = { Behavior: f.inuseBehavior,
|
out.InUseCheck = {
|
||||||
Processes: splitList(f.inuseProcesses).map(name => ({ Name: name })) }
|
Behavior: f.inuseBehavior,
|
||||||
|
Processes: (f.inuseProcesses || []).filter(p => p.name).map(p => {
|
||||||
|
const pd = { Name: p.name }
|
||||||
|
if (p.exepath) pd.ExePath = p.exepath
|
||||||
|
if (p.timeout !== null && p.timeout !== '' && p.timeout !== undefined) {
|
||||||
|
pd.GracefulCloseTimeoutSec = Number(p.timeout)
|
||||||
|
}
|
||||||
|
return pd
|
||||||
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -420,9 +460,11 @@ async function saveEntry() {
|
|||||||
}
|
}
|
||||||
async function deleteEntry(entry) {
|
async function deleteEntry(entry) {
|
||||||
if (!confirm(`Delete entry "${entry.Name}"?`)) return
|
if (!confirm(`Delete entry "${entry.Name}"?`)) return
|
||||||
await api.delete(`/geenforce/entries/${entry.entryid}`)
|
try {
|
||||||
await selectScope(detail.value.scopeid)
|
await api.delete(`/geenforce/entries/${entry.entryid}`)
|
||||||
await loadScopes()
|
await selectScope(detail.value.scopeid)
|
||||||
|
await loadScopes()
|
||||||
|
} catch (e) { error.value = 'Delete entry failed' }
|
||||||
}
|
}
|
||||||
async function moveEntry(index, direction) {
|
async function moveEntry(index, direction) {
|
||||||
const entries = detail.value.entries
|
const entries = detail.value.entries
|
||||||
@@ -430,8 +472,10 @@ async function moveEntry(index, direction) {
|
|||||||
if (target < 0 || target >= entries.length) return
|
if (target < 0 || target >= entries.length) return
|
||||||
const order = entries.map(e => e.entryid)
|
const order = entries.map(e => e.entryid)
|
||||||
;[order[index], order[target]] = [order[target], order[index]]
|
;[order[index], order[target]] = [order[target], order[index]]
|
||||||
await api.put(`/geenforce/scopes/${detail.value.scopeid}/entries/reorder`, { order })
|
try {
|
||||||
await selectScope(detail.value.scopeid)
|
await api.put(`/geenforce/scopes/${detail.value.scopeid}/entries/reorder`, { order })
|
||||||
|
await selectScope(detail.value.scopeid)
|
||||||
|
} catch (e) { error.value = 'Reorder failed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- publish / versions --
|
// -- publish / versions --
|
||||||
@@ -449,25 +493,33 @@ async function toggleVersions() {
|
|||||||
if (showVersions.value) await loadVersions()
|
if (showVersions.value) await loadVersions()
|
||||||
}
|
}
|
||||||
async function loadVersions() {
|
async function loadVersions() {
|
||||||
versions.value = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/versions`))
|
try {
|
||||||
|
versions.value = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/versions`))
|
||||||
|
} catch (e) { error.value = 'Failed to load versions' }
|
||||||
}
|
}
|
||||||
async function rollback(versionnumber) {
|
async function rollback(versionnumber) {
|
||||||
if (!confirm(`Roll back to v${versionnumber}? PCs get this on their next cycle.`)) return
|
if (!confirm(`Roll back to v${versionnumber}? PCs get this on their next cycle.`)) return
|
||||||
await api.post(`/geenforce/scopes/${detail.value.scopeid}/rollback`, { versionnumber })
|
try {
|
||||||
await loadVersions()
|
await api.post(`/geenforce/scopes/${detail.value.scopeid}/rollback`, { versionnumber })
|
||||||
await loadScopes()
|
await loadVersions()
|
||||||
|
await loadScopes()
|
||||||
|
} catch (e) { error.value = 'Rollback failed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- simulate / preview --
|
// -- simulate / preview --
|
||||||
async function runSimulate() {
|
async function runSimulate() {
|
||||||
const params = { ...sim.value }
|
try {
|
||||||
simResult.value = payload(await api.get(
|
const params = { ...sim.value }
|
||||||
`/geenforce/scopes/${detail.value.scopeid}/simulate`, { params }))
|
simResult.value = payload(await api.get(
|
||||||
|
`/geenforce/scopes/${detail.value.scopeid}/simulate`, { params }))
|
||||||
|
} catch (e) { error.value = 'Simulate failed' }
|
||||||
}
|
}
|
||||||
async function loadPreview() {
|
async function loadPreview() {
|
||||||
const data = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/preview`))
|
try {
|
||||||
previewText.value = JSON.stringify(data.manifest, null, 2)
|
const data = payload(await api.get(`/geenforce/scopes/${detail.value.scopeid}/preview`))
|
||||||
showPreview.value = true
|
previewText.value = JSON.stringify(data.manifest, null, 2)
|
||||||
|
showPreview.value = true
|
||||||
|
} catch (e) { error.value = 'Preview failed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterSummary(entry) {
|
function filterSummary(entry) {
|
||||||
@@ -544,6 +596,14 @@ loadConfig()
|
|||||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
|
||||||
.form-grid .full { grid-column: 1 / -1; }
|
.form-grid .full { grid-column: 1 / -1; }
|
||||||
.advanced summary { cursor: pointer; font-size: 0.85rem; margin-bottom: 0.5rem; }
|
.advanced summary { cursor: pointer; font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||||
|
.proc-head { display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-size: 0.78rem; color: var(--text-light); margin: 0.25rem 0; }
|
||||||
|
.proc-row { display: grid; grid-template-columns: 1fr 1.4fr 0.6fr auto; gap: 0.3rem;
|
||||||
|
margin-bottom: 0.3rem; }
|
||||||
|
.preinstall-flags { margin-top: 0.5rem; }
|
||||||
|
.flags-label { display: block; font-size: 0.75rem; color: var(--text-light); margin-bottom: 0.25rem; }
|
||||||
|
.inline { flex-direction: row !important; align-items: center; gap: 0.3rem !important;
|
||||||
|
display: inline-flex !important; margin-right: 1rem; }
|
||||||
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.75rem; }
|
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.75rem; }
|
||||||
.preview { background: var(--bg); padding: 0.75rem; border-radius: 6px; max-height: 60vh;
|
.preview { background: var(--bg); padding: 0.75rem; border-radius: 6px; max-height: 60vh;
|
||||||
overflow: auto; font-size: 0.78rem; }
|
overflow: auto; font-size: 0.78rem; }
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ Two audiences:
|
|||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, request, current_app, Response
|
from flask import Blueprint, request, Response
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, success_response, error_response, ErrorCodes, require_permission,
|
db, success_response, error_response, ErrorCodes, require_permission,
|
||||||
@@ -40,15 +41,17 @@ REPORT_SCOPE = 'geenforce.report'
|
|||||||
|
|
||||||
|
|
||||||
def _require_service_token(scope):
|
def _require_service_token(scope):
|
||||||
"""Decorator factory: require `scope` service token OR the env bootstrap key."""
|
"""Decorator factory: require a managed service token scoped for `scope`.
|
||||||
|
|
||||||
|
Tokens are the only client auth path (the client kit provisions a
|
||||||
|
geenforce.fetch/report token). No env-key fallback: it was never wired into
|
||||||
|
config and an unscoped shared key is a needless backdoor.
|
||||||
|
"""
|
||||||
def wrapper(f):
|
def wrapper(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
if service_token_authorized(scope):
|
if service_token_authorized(scope):
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
expected = current_app.config.get('GEENFORCE_API_KEY')
|
|
||||||
if expected and request.headers.get('X-API-Key') == expected:
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||||
http_code=401)
|
http_code=401)
|
||||||
return decorated
|
return decorated
|
||||||
@@ -278,7 +281,13 @@ def create_entry(scopeid):
|
|||||||
nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1
|
nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1
|
||||||
entry = build_entry(payload, nextorder)
|
entry = build_entry(payload, nextorder)
|
||||||
scope.entries.append(entry)
|
scope.entries.append(entry)
|
||||||
db.session.commit()
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.session.rollback()
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'an entry with that Name already exists in this scope',
|
||||||
|
http_code=400)
|
||||||
return success_response(_entry_payload(entry), http_code=201)
|
return success_response(_entry_payload(entry), http_code=201)
|
||||||
|
|
||||||
|
|
||||||
@@ -293,8 +302,20 @@ def update_entry(entryid):
|
|||||||
invalid = _validate_entry(payload)
|
invalid = _validate_entry(payload)
|
||||||
if invalid:
|
if invalid:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
|
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
|
||||||
|
# Free the one-to-one InUseCheck (unique entryid) before populate re-inserts
|
||||||
|
# it, so the replacement does not collide with the old row mid-flush.
|
||||||
|
if entry.inusecheck is not None:
|
||||||
|
db.session.delete(entry.inusecheck)
|
||||||
|
entry.inusecheck = None
|
||||||
|
db.session.flush()
|
||||||
populate_entry(entry, payload)
|
populate_entry(entry, payload)
|
||||||
db.session.commit()
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.session.rollback()
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'an entry with that Name already exists in this scope',
|
||||||
|
http_code=400)
|
||||||
return success_response(_entry_payload(entry))
|
return success_response(_entry_payload(entry))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,13 +43,22 @@ def _alias_sets(name):
|
|||||||
|
|
||||||
|
|
||||||
def matches_pctype(entry, pctype, subtype=None):
|
def matches_pctype(entry, pctype, subtype=None):
|
||||||
"""Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection."""
|
"""Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection.
|
||||||
|
|
||||||
|
PCTypesStrict=true disables alias expansion (both the PC's identity and the
|
||||||
|
manifest values are compared literally), so e.g. a 'collections'-only strict
|
||||||
|
entry does NOT match a 'nocollections' PC via their shared 'Standard' alias
|
||||||
|
group. The preinstall runner honors this flag (see preinstall.json UDC entry);
|
||||||
|
mirroring it keeps the "what would this PC get" simulator correct.
|
||||||
|
"""
|
||||||
values = entry.get('PCTypes') or []
|
values = entry.get('PCTypes') or []
|
||||||
if not values:
|
if not values:
|
||||||
return True
|
return True
|
||||||
if not pctype:
|
if not pctype:
|
||||||
return True
|
return True
|
||||||
# Names the current PC matches: bare type, "type-subtype", and all aliases.
|
strict = bool(entry.get('PCTypesStrict'))
|
||||||
|
# Names the current PC matches: bare type, "type-subtype", and (unless strict)
|
||||||
|
# all aliases of either.
|
||||||
mynames = set()
|
mynames = set()
|
||||||
mynames.add(pctype.lower())
|
mynames.add(pctype.lower())
|
||||||
seeds = [pctype]
|
seeds = [pctype]
|
||||||
@@ -57,15 +66,18 @@ def matches_pctype(entry, pctype, subtype=None):
|
|||||||
combined = f'{pctype}-{subtype}'
|
combined = f'{pctype}-{subtype}'
|
||||||
mynames.add(combined.lower())
|
mynames.add(combined.lower())
|
||||||
seeds.append(combined)
|
seeds.append(combined)
|
||||||
for seed in seeds:
|
if not strict:
|
||||||
for group in _alias_sets(seed):
|
for seed in seeds:
|
||||||
for alias in group:
|
for group in _alias_sets(seed):
|
||||||
mynames.add(alias.lower())
|
for alias in group:
|
||||||
|
mynames.add(alias.lower())
|
||||||
for value in values:
|
for value in values:
|
||||||
if value == '*':
|
if value == '*':
|
||||||
return True
|
return True
|
||||||
if value.lower() in mynames:
|
if value.lower() in mynames:
|
||||||
return True
|
return True
|
||||||
|
if strict:
|
||||||
|
continue
|
||||||
# The manifest value may itself be an alias - expand and check overlap.
|
# The manifest value may itself be an alias - expand and check overlap.
|
||||||
for group in _alias_sets(value):
|
for group in _alias_sets(value):
|
||||||
for alias in group:
|
for alias in group:
|
||||||
|
|||||||
@@ -34,10 +34,19 @@ def check_scope(scopename, phase, original, fixtures):
|
|||||||
orig_apps = original.get('Applications') or []
|
orig_apps = original.get('Applications') or []
|
||||||
rebuilt_apps = rebuilt.get('Applications') or []
|
rebuilt_apps = rebuilt.get('Applications') or []
|
||||||
|
|
||||||
|
# Check 0: scope-level fields (Version, Site) round-trip.
|
||||||
|
firstdiff = None
|
||||||
|
for key in ('Version', 'Site'):
|
||||||
|
if str(original.get(key) or '') != str(rebuilt.get(key) or ''):
|
||||||
|
firstdiff = (f'scope field {key}: '
|
||||||
|
f'{original.get(key)!r} vs {rebuilt.get(key)!r}')
|
||||||
|
break
|
||||||
|
|
||||||
|
scope_ok = firstdiff is None
|
||||||
|
|
||||||
# Check 1: field-identical, order-preserving.
|
# Check 1: field-identical, order-preserving.
|
||||||
identical = 0
|
identical = 0
|
||||||
firstdiff = None
|
if firstdiff is None and len(orig_apps) != len(rebuilt_apps):
|
||||||
if len(orig_apps) != len(rebuilt_apps):
|
|
||||||
firstdiff = (f'entry count {len(orig_apps)} vs {len(rebuilt_apps)}')
|
firstdiff = (f'entry count {len(orig_apps)} vs {len(rebuilt_apps)}')
|
||||||
for i in range(min(len(orig_apps), len(rebuilt_apps))):
|
for i in range(min(len(orig_apps), len(rebuilt_apps))):
|
||||||
co = canonical_entry(orig_apps[i])
|
co = canonical_entry(orig_apps[i])
|
||||||
@@ -59,7 +68,8 @@ def check_scope(scopename, phase, original, fixtures):
|
|||||||
elif firstdiff is None:
|
elif firstdiff is None:
|
||||||
firstdiff = f'filter mismatch for profile {profile.get("label")}'
|
firstdiff = f'filter mismatch for profile {profile.get("label")}'
|
||||||
|
|
||||||
passed = (identical == len(orig_apps) == len(rebuilt_apps)
|
passed = (scope_ok
|
||||||
|
and identical == len(orig_apps) == len(rebuilt_apps)
|
||||||
and profiles_same == len(fixtures))
|
and profiles_same == len(fixtures))
|
||||||
return {
|
return {
|
||||||
'scopename': scopename,
|
'scopename': scopename,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Kept out of the CLI and routes so both share one implementation:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
@@ -122,10 +123,15 @@ def record_enforcement_report(payload):
|
|||||||
else:
|
else:
|
||||||
status = 'ok'
|
status = 'ok'
|
||||||
|
|
||||||
# Demote the prior current report for this host+scope.
|
# Demote the prior current report for this host+scope. Hostname match is
|
||||||
ManifestEnforcementReport.query.filter_by(
|
# case-insensitive to match the ilike read path - otherwise a PC reporting
|
||||||
hostname=hostname, scopename=scopename, phase=phase, iscurrent=True
|
# its name in different casing would leave two iscurrent rows.
|
||||||
).update({'iscurrent': False})
|
ManifestEnforcementReport.query.filter(
|
||||||
|
func.lower(ManifestEnforcementReport.hostname) == hostname.lower(),
|
||||||
|
ManifestEnforcementReport.scopename == scopename,
|
||||||
|
ManifestEnforcementReport.phase == phase,
|
||||||
|
ManifestEnforcementReport.iscurrent == True
|
||||||
|
).update({'iscurrent': False}, synchronize_session=False)
|
||||||
|
|
||||||
report = ManifestEnforcementReport(
|
report = ManifestEnforcementReport(
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
@@ -189,6 +195,17 @@ def export_scope_to_share(scopename, phase, shareroot):
|
|||||||
with open(os.path.join(historydir, f'{stamp}-{scopename}.json'), 'w') as dst:
|
with open(os.path.join(historydir, f'{stamp}-{scopename}.json'), 'w') as dst:
|
||||||
dst.write(old)
|
dst.write(old)
|
||||||
|
|
||||||
with open(target, 'w') as handle:
|
# Atomic write: a partial/failed write must never leave the live on-share
|
||||||
handle.write(published.manifestjson)
|
# manifest (which every PC reads) truncated. Write a temp file in the same
|
||||||
|
# directory, then rename over the target.
|
||||||
|
targetdir = os.path.dirname(target)
|
||||||
|
fd, tmppath = tempfile.mkstemp(dir=targetdir, suffix='.tmp')
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w') as handle:
|
||||||
|
handle.write(published.manifestjson)
|
||||||
|
os.replace(tmppath, target)
|
||||||
|
except Exception:
|
||||||
|
if os.path.exists(tmppath):
|
||||||
|
os.remove(tmppath)
|
||||||
|
raise
|
||||||
return target
|
return target
|
||||||
|
|||||||
@@ -80,6 +80,34 @@ def test_entry_crud_and_reorder(client, db, auth_headers):
|
|||||||
assert [e['Name'] for e in entries] == ['Alpha']
|
assert [e['Name'] for e in entries] == ['Alpha']
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_edit_preserves_full_fidelity(client, db, auth_headers):
|
||||||
|
"""Editing an entry with the complete payload keeps InUseCheck ExePath +
|
||||||
|
timeout, LogFile, and preinstall flags (the UI now sends all of them)."""
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
rich = {
|
||||||
|
'Name': 'PC-DMIS', 'Type': 'MSI', 'Installer': 'apps/pcdmis.msi',
|
||||||
|
'LogFile': 'C:\\Logs\\pcdmis.log',
|
||||||
|
'PreEnrollment': True, 'PCTypesStrict': True,
|
||||||
|
'InUseCheck': {'Behavior': 'CloseAndReopen', 'Processes': [
|
||||||
|
{'Name': 'PCDLRN', 'ExePath': 'C:\\PCDLRN.exe',
|
||||||
|
'GracefulCloseTimeoutSec': 15}]},
|
||||||
|
}
|
||||||
|
entryid = _add_entry(client, auth_headers, scopeid, rich)
|
||||||
|
|
||||||
|
# Re-send the same full payload (what the editor now does) and confirm
|
||||||
|
# nothing is stripped.
|
||||||
|
upd = client.put(f'/api/geenforce/entries/{entryid}', json=rich,
|
||||||
|
headers=auth_headers)
|
||||||
|
assert upd.status_code == 200
|
||||||
|
entry = upd.get_json()['data']
|
||||||
|
assert entry['LogFile'] == 'C:\\Logs\\pcdmis.log'
|
||||||
|
assert entry['PreEnrollment'] is True
|
||||||
|
assert entry['PCTypesStrict'] is True
|
||||||
|
proc = entry['InUseCheck']['Processes'][0]
|
||||||
|
assert proc['ExePath'] == 'C:\\PCDLRN.exe'
|
||||||
|
assert proc['GracefulCloseTimeoutSec'] == 15
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_entry_type_rejected(client, db, auth_headers):
|
def test_invalid_entry_type_rejected(client, db, auth_headers):
|
||||||
scopeid = _create_scope(client, auth_headers)
|
scopeid = _create_scope(client, auth_headers)
|
||||||
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
resp = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||||
@@ -117,6 +145,34 @@ def test_simulate_cmm_version_gate(client, db, auth_headers):
|
|||||||
assert filtered['PC-DMIS 2016'] == ['_CmmVersion']
|
assert filtered['PC-DMIS 2016'] == ['_CmmVersion']
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_entry_name_is_400_not_500(client, db, auth_headers):
|
||||||
|
scopeid = _create_scope(client, auth_headers)
|
||||||
|
_add_entry(client, auth_headers, scopeid, {'Name': 'Alpha', 'Type': 'MSI'})
|
||||||
|
dup = client.post(f'/api/geenforce/scopes/{scopeid}/entries',
|
||||||
|
json={'Name': 'Alpha', 'Type': 'PS1'}, headers=auth_headers)
|
||||||
|
assert dup.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_simulate_pctypesstrict_disables_alias(client, db, auth_headers):
|
||||||
|
"""A collections-only strict entry must NOT match a nocollections PC via the
|
||||||
|
shared Standard alias group (mirrors the preinstall UDC entry)."""
|
||||||
|
scopeid = _create_scope(client, auth_headers, 'preinstall')
|
||||||
|
_add_entry(client, auth_headers, scopeid,
|
||||||
|
{'Name': 'UDC (strict)', 'Type': 'EXE', 'Installer': 'apps/udc.exe',
|
||||||
|
'PCTypes': ['gea-shopfloor-collections'], 'PCTypesStrict': True})
|
||||||
|
_add_entry(client, auth_headers, scopeid,
|
||||||
|
{'Name': 'UDC (loose)', 'Type': 'EXE', 'Installer': 'apps/udc.exe',
|
||||||
|
'PCTypes': ['gea-shopfloor-collections']})
|
||||||
|
|
||||||
|
sim = client.get(
|
||||||
|
f'/api/geenforce/scopes/{scopeid}/simulate?pctype=gea-shopfloor-nocollections',
|
||||||
|
headers=auth_headers).get_json()['data']
|
||||||
|
# loose entry matches via the Standard alias group; strict entry does not.
|
||||||
|
assert sim['applied'] == ['UDC (loose)']
|
||||||
|
filtered = {f['name']: f['filteredby'] for f in sim['filtered']}
|
||||||
|
assert filtered['UDC (strict)'] == ['PCTypes']
|
||||||
|
|
||||||
|
|
||||||
def test_simulate_machine_number_gate(client, db, auth_headers):
|
def test_simulate_machine_number_gate(client, db, auth_headers):
|
||||||
scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-collections')
|
scopeid = _create_scope(client, auth_headers, 'gea-shopfloor-collections')
|
||||||
_add_entry(client, auth_headers, scopeid,
|
_add_entry(client, auth_headers, scopeid,
|
||||||
|
|||||||
@@ -131,6 +131,55 @@ def test_reference_share_round_trips_losslessly():
|
|||||||
assert ok, f"parity failures: {[(r['scopename'], r['firstdiff']) for r in failed]}"
|
assert ok, f"parity failures: {[(r['scopename'], r['firstdiff']) for r in failed]}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not os.path.isdir(REFERENCE_SHARE),
|
||||||
|
reason='GE-Enforce reference share not present')
|
||||||
|
def test_reference_manifests_fit_column_limits():
|
||||||
|
"""Guard the truncation blind spot: the DB-free parity harness can't catch a
|
||||||
|
value that exceeds its column length (Python strings are unbounded), so a
|
||||||
|
260-char DetectionPath would pass parity yet truncate on a real insert.
|
||||||
|
Assert every real-manifest string field fits its declared column, deriving
|
||||||
|
limits from the model so this never drifts.
|
||||||
|
"""
|
||||||
|
from plugins.geenforce.models import (
|
||||||
|
ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
|
||||||
|
ManifestEntryMachineNumber)
|
||||||
|
|
||||||
|
def limit(model, attr):
|
||||||
|
return model.__table__.columns[attr].type.length
|
||||||
|
|
||||||
|
# manifest key -> (model, column attribute)
|
||||||
|
entry_fields = {
|
||||||
|
'Name': 'name', 'Installer': 'installer', 'Script': 'scriptpath',
|
||||||
|
'Args': 'scriptargs', 'Source': 'sourcepath', 'Destination': 'destination',
|
||||||
|
'RegPath': 'regpath', 'RegName': 'regname', 'RegType': 'regtype',
|
||||||
|
'DetectionPath': 'detectionpath', 'DetectionName': 'detectionname',
|
||||||
|
'DetectionValue': 'detectionvalue', 'DetectionPattern': 'detectionpattern',
|
||||||
|
'_CmmVersion': 'cmmversion', 'LogFile': 'logfile',
|
||||||
|
'UpdateWindow': 'updatewindow', 'ApplyMode': 'applymode',
|
||||||
|
}
|
||||||
|
overflows = []
|
||||||
|
manifests = list(discover_share(REFERENCE_SHARE))
|
||||||
|
if os.path.isfile(REFERENCE_PREINSTALL):
|
||||||
|
manifests.append(('preinstall', 'preinstall',
|
||||||
|
load_manifest_file(REFERENCE_PREINSTALL)))
|
||||||
|
for scopename, _phase, manifest in manifests:
|
||||||
|
for entry in manifest.get('Applications', []):
|
||||||
|
for key, attr in entry_fields.items():
|
||||||
|
value = entry.get(key)
|
||||||
|
if isinstance(value, str) and len(value) > limit(ManifestEntry, attr):
|
||||||
|
overflows.append(f'{scopename}/{entry.get("Name")}.{key}')
|
||||||
|
for value in entry.get('PCTypes', []):
|
||||||
|
if len(value) > limit(ManifestEntryPcType, 'pctypevalue'):
|
||||||
|
overflows.append(f'{scopename}/{entry.get("Name")}.PCTypes')
|
||||||
|
for value in entry.get('TargetHostnames', []):
|
||||||
|
if len(value) > limit(ManifestEntryHostname, 'hostnamepattern'):
|
||||||
|
overflows.append(f'{scopename}/{entry.get("Name")}.TargetHostnames')
|
||||||
|
for value in entry.get('TargetMachineNumbers', []):
|
||||||
|
if len(str(value)) > limit(ManifestEntryMachineNumber, 'machinenumber'):
|
||||||
|
overflows.append(f'{scopename}/{entry.get("Name")}.TargetMachineNumbers')
|
||||||
|
assert not overflows, f'fields exceed column limits (would truncate): {overflows}'
|
||||||
|
|
||||||
|
|
||||||
def test_fixtures_cover_every_pctype():
|
def test_fixtures_cover_every_pctype():
|
||||||
"""The machine-profile fixtures include one of each imaging pctype."""
|
"""The machine-profile fixtures include one of each imaging pctype."""
|
||||||
labels = {f['pctype'] for f in load_fixtures()}
|
labels = {f['pctype'] for f in load_fixtures()}
|
||||||
|
|||||||
@@ -97,6 +97,24 @@ def test_latest_report_upserts_per_host(client, db, app, auth_headers):
|
|||||||
assert rows[0]['installed'] == 1
|
assert rows[0]['installed'] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_hostname_case_variation_keeps_single_current(client, db, app, auth_headers):
|
||||||
|
"""A PC reporting its hostname in different casing must not leave two
|
||||||
|
iscurrent rows (the demote is case-insensitive, matching the ilike read)."""
|
||||||
|
_seed_and_publish(app)
|
||||||
|
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||||
|
for host in ('WJCMM09', 'wjcmm09'):
|
||||||
|
client.post('/api/geenforce/report',
|
||||||
|
json={'hostname': host, 'scopename': 'gea-shopfloor-cmm',
|
||||||
|
'appliedversion': 1, 'counts': {'installed': 1}},
|
||||||
|
headers={'X-API-Key': secret})
|
||||||
|
from plugins.geenforce.models import ManifestEnforcementReport
|
||||||
|
with app.app_context():
|
||||||
|
current = ManifestEnforcementReport.query.filter(
|
||||||
|
ManifestEnforcementReport.hostname.ilike('WJCMM09'),
|
||||||
|
ManifestEnforcementReport.iscurrent == True).all()
|
||||||
|
assert len(current) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_status_failed_when_failures(client, db, app, auth_headers):
|
def test_status_failed_when_failures(client, db, app, auth_headers):
|
||||||
_seed_and_publish(app)
|
_seed_and_publish(app)
|
||||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||||
|
|||||||
Reference in New Issue
Block a user