Make main the lean product a site receives

main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
This commit is contained in:
cproudlock
2026-09-19 11:24:40 -04:00
parent cb3975049c
commit 534f179ee6
675 changed files with 115759 additions and 60239 deletions

View File

@@ -14,18 +14,91 @@ allowlist, so a bad tree or an unknown action never reaches the database.
from flask import request
from shopdb.api import (db, AuditLog, success_response, error_response,
ErrorCodes, require_permission)
ErrorCodes, require_permission, caller_holds)
from .routes import computers_bp
from ..models import (ConfigurationPolicy, ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL,
POLICY_SOURCE_AUTOAPP, POLICY_SOURCE_SHIPPED,
ACTION_MODE_DESIRED, ACTION_MODE_GATE)
from ..policyflag import policy_flag_enabled
from ..services.apppolicy import disown_if_generated
from ..services.policyscope import validate_scope, ScopeError
_MODES = {ACTION_MODE_DESIRED, ACTION_MODE_GATE}
def _elevated_denial(actionnames):
"""Refuse unless the caller may author every elevated action in actionnames.
A policy is a deferred instruction to run something. Gating only the enqueue
route left authoring wide open: computers.edit alone could author a policy
carrying run_script and let the agent execute it, which is the whole point of
splitting the big four undone. The permission to CAUSE an action must be the
same permission required to RUN it.
caller_holds, not current_user.haspermission: a scoped personal token is a
narrower caller than its owner, and asking the owner is the exact bypass
require_permission exists to prevent.
"""
from plugins.endpoints.commands import elevated_permission_for
missing = []
for name in sorted(set(actionnames)):
elevated = elevated_permission_for(name)
if elevated and not caller_holds(elevated) and elevated not in missing:
missing.append(elevated)
if not missing:
return None
return error_response(
ErrorCodes.FORBIDDEN,
'This policy carries actions you may not author. Missing: '
+ ', '.join(missing),
http_code=403)
def _requested_actionnames(actions):
return [(row.get('action') or '').strip() for row in actions or []]
def _policy_actionnames(policy):
return [a.action for a in policy.actions]
def _composed_actionnames(configpolicyids):
"""Every action reachable through a set of configuration policies.
Composition is an authoring act: adding a configuration policy to a business
policy causes its actions to run on everything the business policy targets,
without the composer ever naming those actions.
"""
names = []
for cpid in configpolicyids:
policy = db.session.get(ConfigurationPolicy, cpid)
if policy:
names.extend(_policy_actionnames(policy))
return names
def _changes_effect(data, policy=None):
"""Whether this payload changes WHAT a policy does, rather than how it reads.
Scope widening, reactivation, priority and composition all change the effect
of an already-authored action without touching the action list, so gating the
action payload alone would leave every one of them open. Renaming a policy or
editing its description changes nothing that runs, so it stays ungated.
"""
for field in ('actions', 'items', 'scopejson', 'priority'):
if field in data:
return True
if 'isactive' in data:
# Only reactivation matters. Deactivating a policy cannot widen anything,
# and refusing it would strand a live policy nobody present can switch off.
was = getattr(policy, 'isactive', False) if policy is not None else False
return bool(data['isactive']) and not was
return False
def _feature_gate():
"""Return a 404 response when the policy feature is off, else None."""
if not policy_flag_enabled():
@@ -155,6 +228,10 @@ def create_configuration_policy():
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Invalid scope: {exc}')
denial = _elevated_denial(_requested_actionnames(data.get('actions')))
if denial:
return denial
policy = ConfigurationPolicy(
name=name,
description=data.get('description'),
@@ -183,12 +260,30 @@ def update_configuration_policy(policy_id: int):
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
# An autoapp row is generated from an Application's "Required on all PCs"
# checkbox and IS editable: editing it is precisely how an admin takes
# ownership. A shipped row is editable for the same reason - the product
# ships a starting point, not an opinion about one site's floor - and keeps
# source='shipped' so provenance survives the edit. Genuinely external
# sources (a future managed owner) stay read-only.
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP,
POLICY_SOURCE_SHIPPED):
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
data = request.get_json() or {}
# Both sides. The incoming actions are what the caller is asking to cause;
# the existing ones are what a scope widening or reactivation would newly
# unleash on machines that were never in range before.
if _changes_effect(data, policy):
names = list(_policy_actionnames(policy))
if 'actions' in data:
names.extend(_requested_actionnames(data['actions']))
denial = _elevated_denial(names)
if denial:
return denial
if 'name' in data:
newname = (data['name'] or '').strip()
if newname and newname != policy.name:
@@ -215,6 +310,10 @@ def update_configuration_policy(policy_id: int):
if err:
return err
# Hand-editing a generated policy transfers ownership: from here on the
# application checkbox will not delete it, it will warn instead.
disown_if_generated(policy)
AuditLog.log('updated', 'ConfigurationPolicy', entityname=policy.name)
db.session.commit()
return success_response(_configpolicy_dict(policy),
@@ -231,7 +330,11 @@ def delete_configuration_policy(policy_id: int):
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
# As with update: a generated or shipped row is the site's to remove, an
# externally managed one is not. A site that does not run MTConnect should
# be able to delete its monitor policy outright rather than carry it.
if policy.source not in (POLICY_SOURCE_LOCAL, POLICY_SOURCE_AUTOAPP,
POLICY_SOURCE_SHIPPED):
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
@@ -242,6 +345,11 @@ def delete_configuration_policy(policy_id: int):
ErrorCodes.CONFLICT,
f'Cannot delete: used by {inuse} business policy item(s)',
http_code=409)
# Deleting a gate policy REMOVES a restriction, so it changes what endpoints
# will run just as surely as authoring one does.
denial = _elevated_denial(_policy_actionnames(policy))
if denial:
return denial
db.session.delete(policy)
AuditLog.log('deleted', 'ConfigurationPolicy', entityname=policy.name)
db.session.commit()
@@ -324,6 +432,12 @@ def create_business_policy():
priority=data.get('priority', 100),
source=POLICY_SOURCE_LOCAL,
)
requested = [row.get('configurationpolicyid')
for row in data.get('items') or []]
denial = _elevated_denial(_composed_actionnames(
[cpid for cpid in requested if cpid is not None]))
if denial:
return denial
err = _apply_items(policy, data.get('items'))
if err:
return err
@@ -349,6 +463,20 @@ def update_business_policy(policy_id: int):
'Policy is managed externally and is read-only',
http_code=409)
data = request.get_json() or {}
# Composition is the quiet escalation: attaching a configuration policy runs
# its actions everywhere this business policy reaches, without the composer
# naming a single one of them.
if _changes_effect(data, policy):
cpids = [it.configurationpolicyid for it in policy.items]
for row in data.get('items') or []:
cpid = row.get('configurationpolicyid')
if cpid is not None:
cpids.append(cpid)
denial = _elevated_denial(_composed_actionnames(cpids))
if denial:
return denial
if 'name' in data:
newname = (data['name'] or '').strip()
if newname and newname != policy.name:
@@ -387,6 +515,10 @@ def delete_business_policy(policy_id: int):
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
denial = _elevated_denial(_composed_actionnames(
[it.configurationpolicyid for it in policy.items]))
if denial:
return denial
db.session.delete(policy)
AuditLog.log('deleted', 'BusinessPolicy', entityname=policy.name)
db.session.commit()
@@ -405,16 +537,29 @@ def list_policy_actions():
Returns command actions tagged kind='command'. Monitor attachments are not
listed here: the UI builds that branch from the endpoints monitor catalog
(endpointsApi.monitors) so newly created monitors appear without a reload.
Actions the caller may not author are marked rather than hidden. Hiding them
would make the editor look like the capability does not exist; marking them
says it exists and is not yours, which is the difference between a confusing
UI and an honest one. The authoring gate is enforced server-side regardless.
"""
gate = _feature_gate()
if gate:
return gate
from plugins.endpoints.commands import COMMAND_ALLOWLIST
return success_response([
{'action': name, 'label': meta.get('label'),
'description': meta.get('description'), 'kind': 'command'}
for name, meta in COMMAND_ALLOWLIST.items()
])
from plugins.endpoints.commands import (COMMAND_ALLOWLIST,
elevated_permission_for)
catalog = []
for name, meta in COMMAND_ALLOWLIST.items():
elevated = elevated_permission_for(name)
catalog.append({
'action': name,
'label': meta.get('label'),
'description': meta.get('description'),
'kind': 'command',
'requiredpermission': elevated,
'mayauthor': not elevated or caller_holds(elevated),
})
return success_response(catalog)
@computers_bp.route('/policies/effective/<int:asset_id>', methods=['GET'])

View File

@@ -17,11 +17,18 @@
# to Asset.assetnumber; the placeholder 9999 is skipped server-side.
#
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
# CollectorKey (same secret store the GE-Enforce client uses; provisioned at
# imaging), or overridden via the manifest entry's Args -ApiKey. Never bake the
# key into the manifest JSON on the share.
# covers the geenforce fetch/report endpoints). Once the endpoint agent (1.20+)
# has enrolled this PC, its per-PC credential is read from the agent's
# SYSTEM-only credential.bin and sent as X-Endpoint-Credential; the server then
# refuses the shared key for this PC (401 SHARED_KEY_REFUSED). Before that, a
# collector.ingest key is sent as the X-API-Key header, read from
# HKLM:\SOFTWARE\GE\ShopDB CollectorKey (same secret store the GE-Enforce client
# uses; provisioned at imaging), or overridden via the manifest entry's Args
# -ApiKey. Never bake the key into the manifest JSON on the share.
#
# PINS: when HKLM:\SOFTWARE\GE\ShopDB ServerPins is set, the server's TLS key
# must match one of them or nothing is sent. See
# plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md.
#
# Deployed in common\ (runs on EVERY shopfloor pc-type). Non-DNC bays report
# identity with no machineNo, so no PC-to-machine link is built - by design.
@@ -70,6 +77,271 @@ function Log([string]$msg) {
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
}
# ---------------------------------------------------------------------------
# Per-PC credential and server pin. Kept in step with the same block in
# Report-AssetToShopDB.ps1, Report-PrintersToShopDB.ps1 and
# upgrade-shopdbagent.ps1: each runs standalone, so the text is duplicated.
#
# Credential: the endpoint agent (1.20+) enrolls and stores its own credential
# in credential.bin, DPAPI LocalMachine, readable only by SYSTEM and
# Administrators. When present it is sent as X-Endpoint-Credential and the
# shared key is not sent. Without it the shared CollectorKey goes as X-API-Key.
#
# Pins: HKLM\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ), lowercase hex
# SHA-256 of the server leaf certificate's SubjectPublicKeyInfo. When any pin
# is set the TLS handshake is accepted only if the leaf key matches one.
# ---------------------------------------------------------------------------
$ShopDbCredentialFile = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\credential\credential.bin'
$ShopDbPinSource = @'
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Security;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
public static class ShopDbServerPinValidator
{
private static readonly HashSet<string> Pins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public static string LastPresented;
public static void Install(string[] pins)
{
Pins.Clear();
foreach (string pin in pins)
{
if (!String.IsNullOrEmpty(pin)) { Pins.Add(pin); }
}
ServicePointManager.ServerCertificateValidationCallback = Validate;
}
public static bool Validate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors)
{
if (Pins.Count == 0) { return errors == SslPolicyErrors.None; }
if (certificate == null) { return false; }
try
{
LastPresented = SpkiSha256Hex(certificate.GetRawCertData());
}
catch (Exception)
{
LastPresented = null;
return false;
}
return Pins.Contains(LastPresented);
}
public static bool IsPinned(string pin)
{
return !String.IsNullOrEmpty(pin) && Pins.Contains(pin);
}
// Walks the DER: Certificate > TBSCertificate > [0] version (optional),
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
public static string SpkiSha256Hex(byte[] der)
{
int length;
int position = EnterSequence(der, 0, out length);
position = EnterSequence(der, position, out length);
if (der[position] == 0xA0) { position = Skip(der, position); }
for (int field = 0; field < 5; field++) { position = Skip(der, position); }
if (der[position] != 0x30) { throw new FormatException("SubjectPublicKeyInfo not found"); }
int end = Skip(der, position);
using (SHA256 sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(der, position, end - position);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}
private static int ReadLength(byte[] der, int position, out int length)
{
int first = der[position];
if (first < 0x80) { length = first; return position + 1; }
int count = first & 0x7F;
if (count == 0 || count > 4) { throw new FormatException("Unsupported DER length"); }
length = 0;
for (int i = 1; i <= count; i++) { length = (length << 8) | der[position + i]; }
return position + 1 + count;
}
private static int EnterSequence(byte[] der, int position, out int length)
{
if (der[position] != 0x30) { throw new FormatException("Expected SEQUENCE"); }
return ReadLength(der, position + 1, out length);
}
private static int Skip(byte[] der, int position)
{
int length;
int content = ReadLength(der, position + 1, out length);
return content + length;
}
}
'@
function Get-ShopDbServerPins {
# Both registry views, like CollectorKey. Anything not 64 hex chars is dropped.
$pins = @()
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
try {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
if ($key) {
try {
foreach ($value in @($key.GetValue('ServerPins'))) {
if ($null -eq $value) { continue }
$pin = ([string]$value -replace '[\s:]', '').ToLowerInvariant()
if ($pin -match '^[0-9a-f]{64}$' -and $pins -notcontains $pin) { $pins += $pin }
}
} finally { $key.Dispose() }
}
} finally { $base.Dispose() }
} catch {}
}
return ,$pins
}
function Test-ShopDbServerPinsValue {
# True when ServerPins exists in either registry view, even empty. The
# registry then owns the pin list and appsettings pins are ignored, the
# same rule the agent follows.
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64, [Microsoft.Win32.RegistryView]::Registry32)) {
try {
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
try {
$key = $base.OpenSubKey('SOFTWARE\GE\ShopDB')
if ($key) {
try {
if ($key.GetValueNames() -contains 'ServerPins') { return $true }
} finally { $key.Dispose() }
}
} finally { $base.Dispose() }
} catch {}
}
return $false
}
function Enable-ShopDbServerPins([string[]]$Pins) {
# True when pinning is active. False means pins are set but could not be
# enforced; the caller must not send anything then.
if ($PSVersionTable.PSEdition -eq 'Core') {
# PowerShell 7 web cmdlets ignore ServicePointManager, so the check
# would silently not happen.
Log 'ERROR ServerPins is set; pin checking needs Windows PowerShell 5.1 (powershell.exe), not pwsh'
return $false
}
try {
if (-not ('ShopDbServerPinValidator' -as [type])) {
Add-Type -TypeDefinition $ShopDbPinSource -ErrorAction Stop
}
[ShopDbServerPinValidator]::Install($Pins)
return $true
} catch {
Log "ERROR ServerPins is set but pin checking could not be enabled: $($_.Exception.Message)"
return $false
}
}
function Get-ShopDbEndpointCredential([string]$TargetUrl) {
# Empty when there is none or it cannot be used for this URL.
if (-not (Test-Path -LiteralPath $ShopDbCredentialFile)) { return '' }
try {
Add-Type -AssemblyName System.Security -ErrorAction Stop
$protected = [System.IO.File]::ReadAllBytes($ShopDbCredentialFile)
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
$protected, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
$record = [System.Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json
} catch {
Log "WARN could not read the endpoint credential ($ShopDbCredentialFile): $($_.Exception.Message)"
return ''
}
$credential = [string]$record.credential
if (-not $credential.StartsWith('sdbe_')) {
Log 'WARN endpoint credential file has no usable credential; ignoring it'
return ''
}
if (-not $TargetUrl.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
Log "WARN not sending the endpoint credential over a non-https URL ($TargetUrl)"
return ''
}
# Never hand a credential to a server other than the one that issued it.
$issuer = ([string]$record.baseurl).Trim().TrimEnd('/')
if ($issuer -and -not ($TargetUrl.TrimEnd('/') + '/').StartsWith($issuer + '/', [System.StringComparison]::OrdinalIgnoreCase)) {
Log "WARN endpoint credential was issued by $issuer, not the target $TargetUrl; not sending it"
return ''
}
return $credential
}
function Get-ShopDbHttpFailure($ErrorRecord) {
# Status and error envelope code (data.error.code) from a failed request.
$status = 0
$code = ''
$text = ''
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch {}
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
$text = $ErrorRecord.ErrorDetails.Message
} else {
try {
$reader = New-Object System.IO.StreamReader($ErrorRecord.Exception.Response.GetResponseStream())
$text = $reader.ReadToEnd()
$reader.Dispose()
} catch {}
}
if ($text) {
try { $code = [string](($text | ConvertFrom-Json).data.error.code) } catch {}
}
$presented = ''
$matched = $false
if ('ShopDbServerPinValidator' -as [type]) {
$presented = [string][ShopDbServerPinValidator]::LastPresented
$matched = [ShopDbServerPinValidator]::IsPinned($presented)
}
# Innermost exception text: the outer WebException hides the real cause
# (unexpected EOF, connection reset, trust failure).
$message = ''
$exception = $ErrorRecord.Exception
while ($exception) {
if ($exception.Message) { $message = $exception.Message }
$exception = $exception.InnerException
}
return @{ Status = $status; Code = $code; PresentedPin = $presented; PinMatched = $matched; Message = $message }
}
function Write-ShopDbAuthFailure($Failure) {
switch ($Failure.Code) {
'CREDENTIAL_INVALID' {
Log ('ERROR 401 CREDENTIAL_INVALID: the server does not accept this PC''s endpoint credential ' +
'(revoked or unknown). The endpoint agent deletes it and re-enrolls on its own.')
}
'SHARED_KEY_REFUSED' {
Log ('ERROR 401 SHARED_KEY_REFUSED: the server refuses the shared key for this PC, because ' +
'the PC is enrolled or the shared key is switched off. Reports need the endpoint agent ' +
"credential: check agent 1.20+ is installed, running and enrolled ($ShopDbCredentialFile).")
}
'ASSET_MISMATCH' {
Log ('ERROR 403 ASSET_MISMATCH: this PC''s credential belongs to a different asset than the ' +
'hostname in the report. Check the hostname and the enrollment in ShopDB.')
}
default {
if ($Failure.Status -eq 0 -and $Failure.PresentedPin) {
# A pin was seen, but only an absent pin is a pin failure. A
# matched pin means the handshake passed and something later
# failed (server dropped mid-transfer, timeout).
if ($Failure.PinMatched) {
Log "ERROR request failed after the server key pin matched ($($Failure.PresentedPin)): $($Failure.Message)"
} else {
Log "ERROR TLS refused: server key pin $($Failure.PresentedPin) is not in ServerPins"
}
}
}
}
}
# Server from the GE-Enforce config hive when not passed via Args. Any site
# running this script is running the enforcement client, which cannot work
# without BaseUrl, so it is present wherever this is deployed.
@@ -102,11 +374,21 @@ if (-not $ApiKey) {
} catch {}
}
}
if (-not $ApiKey) {
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
$EndpointCredential = Get-ShopDbEndpointCredential $ApiUrl
if (-not $EndpointCredential -and -not $ApiKey) {
Log 'ERROR no endpoint credential and no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
exit 0
}
$serverPins = Get-ShopDbServerPins
if ($serverPins.Count -gt 0) {
if (-not (Enable-ShopDbServerPins $serverPins)) {
Log 'ERROR refusing to report without the server pin check. Skipping.'
exit 0
}
Log "server pin check on ($($serverPins.Count) pin(s))"
}
# -AllowedRanges and the CollectorRanges registry value are ACCEPTED AND
# IGNORED. Deciding which NIC is the PC's address moved to the server, where
# the site's addressing actually lives: this script now reports every NIC and
@@ -326,14 +608,27 @@ $json = $body | ConvertTo-Json -Compress -Depth 4
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} deviceId={11}" -f `
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId)
try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
-ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $ApiKey } `
-TimeoutSec $TimeoutSec -ErrorAction Stop
Log ("RESPONSE {0}" -f ($resp | ConvertTo-Json -Compress -Depth 4))
} catch {
Log "ERROR POST failed: $($_.Exception.Message)"
# Credential first; the shared key only when there is no credential, or when
# the server has just refused the credential (revoked: the shared key is
# admitted again for this PC until the agent re-enrolls). Never both at once.
$attempts = @()
if ($EndpointCredential) { $attempts += ,@('credential', @{ 'X-Endpoint-Credential' = $EndpointCredential }) }
if ($ApiKey) { $attempts += ,@('shared key', @{ 'X-API-Key' = $ApiKey }) }
foreach ($attempt in $attempts) {
try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
-ContentType 'application/json' `
-Headers $attempt[1] `
-TimeoutSec $TimeoutSec -ErrorAction Stop
Log ("RESPONSE ({0}) {1}" -f $attempt[0], ($resp | ConvertTo-Json -Compress -Depth 4))
break
} catch {
Log "ERROR POST with $($attempt[0]) failed: $($_.Exception.Message)"
$failure = Get-ShopDbHttpFailure $_
Write-ShopDbAuthFailure $failure
if ($failure.Code -ne 'CREDENTIAL_INVALID') { break }
}
}
exit 0

View File

@@ -0,0 +1,264 @@
#requires -Version 5.1
<#
.SYNOPSIS
Seed HKLM:\SOFTWARE\GE\ShopDB\DeviceId from whatever identity a bay already
carries, so device identity can move off C:\Enrollment and into the
registry.
.DESCRIPTION
Phase 3 of docs/proposals/device-identity.md. The registry is the
destination because it survives the profile cleanup and disk hygiene that
eats C:\Enrollment, is readable as SYSTEM with no file ACL to get wrong, and
is already where the collector looks for BaseUrl and ApiToken.
This script does the one-time backfill: it reads the identity a bay holds
today and writes it to DeviceId. It is idempotent and safe to run every
GE-Enforce cycle.
THE SOURCE DEPENDS ON THE PC TYPE, AND THAT IS THE WHOLE POINT.
DeviceId names the DEVICE hanging off this PC. A machine number is a
different fact - "which bay is this" rather than "which device is this" -
and the two coincide for exactly one family:
CMM bays the machine number IS the instrument id (CMM4), which is
why cmmid.txt has always doubled as identity. Copying it
is correct.
part markers the machine number is the OPERATION (0613), and several
markers serve one operation. Copying it would name the
operation as the device. The collector refuses that
("asset X is not a Part Marker; not linked") because a
machine filed under a device label reads as a marker
everywhere downstream. So machine number is NEVER a
source here.
Keyence, no per-bay id exists at all. asset-id.txt is the only
Genspect, identity these have ever had.
wax-trace
everything not a device bay. DeviceId is not consumed for these
else pc-types, so writing one is a landmine for the day that
pc-type gains a device spec. Writes nothing.
9999 is the unassigned placeholder and is skipped everywhere, matching
Report-AssetToShopDB.ps1 and the server.
.PARAMETER Force
Overwrite a DeviceId that is already set to a DIFFERENT value.
Off by default on purpose. asset-id.txt is written ONCE at imaging and
nothing updates it when a device is swapped, so a stale file overwriting a
corrected registry value would re-introduce the exact defect this move is
meant to fix. A disagreement is reported and left alone for a human.
.PARAMETER PCType
Override the pc-type instead of reading C:\Enrollment\pc-type.txt. For
testing and for a bay whose file is missing.
.PARAMETER DeviceId
Write this value directly, skipping resolution. Still honours -Force and
the 9999 rule.
.PARAMETER LogPath
Append a transcript here as well as writing to the pipeline.
.EXAMPLE
.\Set-ShopDBDeviceId.ps1 -WhatIf
Show what would be written on this bay without touching the registry.
.EXAMPLE
.\Set-ShopDBDeviceId.ps1
Seed DeviceId. Safe to run repeatedly and from a GE-Enforce PS1 entry.
.NOTES
Exit codes:
0 wrote a value, it was already correct, or this is not a device bay
1 an error stopped the write
2 an existing DeviceId disagrees with the resolved value; needs a human
or -Force
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[switch]$Force,
[string]$PCType,
[string]$DeviceId,
[string]$LogPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$RegPath = 'HKLM:\SOFTWARE\GE\ShopDB'
$RegValueName = 'DeviceId'
$Placeholder = '9999'
function Log {
param([string]$Message)
$line = "{0} [Set-ShopDBDeviceId] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Write-Output $line
if ($LogPath) {
try { Add-Content -LiteralPath $LogPath -Value $line -ErrorAction Stop } catch { }
}
}
function Read-FirstLine {
# One line, trimmed, empty when absent or unreadable. A bay missing a file
# is the normal case, not an error.
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return '' }
try {
$v = [string](Get-Content -LiteralPath $Path -First 1 -ErrorAction Stop)
return $v.Trim()
} catch {
Log "WARN could not read ${Path}: $($_.Exception.Message)"
return ''
}
}
function Get-MachineNoFromRegistry {
# eDNC/NTLARS, WOW6432Node first, matching Report-AssetToShopDB.ps1.
foreach ($rp in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
)) {
try {
if (Test-Path $rp) {
$v = [string](Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction Stop).MachineNo
if ($v) {
$v = $v.Trim()
if ($v -and $v -ne $Placeholder) { return $v }
}
}
} catch {
Log "WARN could not read MachineNo from ${rp}: $($_.Exception.Message)"
}
}
return ''
}
# Which pc-types drive a device, and whether the machine number is a legitimate
# identity for that device. Mirrors SUBORDINATE_DEVICE_MAP in
# plugins/computers/pctypemap.py. A site that added its own device pc-type
# through a subordinatedevice_<pctype> setting adds it here too.
$DeviceFamilies = @{
'gea-shopfloor-cmm' = @{ Label = 'CMM'; MachineNoIsDevice = $true }
'gea-shopfloor-keyence' = @{ Label = 'Vision System'; MachineNoIsDevice = $false }
'gea-shopfloor-genspect' = @{ Label = 'Genspect'; MachineNoIsDevice = $false }
'gea-shopfloor-waxtrace' = @{ Label = 'Form Tracer'; MachineNoIsDevice = $false }
'gea-shopfloor-partmarker'= @{ Label = 'Part Marker'; MachineNoIsDevice = $false }
}
# --- 1. what kind of bay is this -------------------------------------------
if (-not $PCType) { $PCType = Read-FirstLine 'C:\Enrollment\pc-type.txt' }
if (-not $PCType) {
Log 'no pc-type on this PC (C:\Enrollment\pc-type.txt missing or empty); nothing to do'
exit 0
}
if (-not $DeviceFamilies.ContainsKey($PCType)) {
Log "pc-type '$PCType' does not drive a device; DeviceId not written"
exit 0
}
$family = $DeviceFamilies[$PCType]
Log "pc-type '$PCType' drives a $($family.Label)"
# --- 2. resolve the identity ------------------------------------------------
$resolved = ''
$source = ''
if ($DeviceId) {
$resolved = $DeviceId.Trim()
$source = '-DeviceId parameter'
} else {
# Most stable identity first, and the machine number only where it really
# names the device.
if ($family.MachineNoIsDevice) {
$v = Read-FirstLine 'C:\Enrollment\cmm\cmmid.txt'
if ($v -and $v -ne $Placeholder) { $resolved = $v; $source = 'C:\Enrollment\cmm\cmmid.txt' }
if (-not $resolved) {
$v = Get-MachineNoFromRegistry
if ($v) { $resolved = $v; $source = 'eDNC MachineNo registry' }
}
}
if (-not $resolved) {
$v = Read-FirstLine 'C:\Enrollment\asset-id.txt'
if ($v -and $v -ne $Placeholder) { $resolved = $v; $source = 'C:\Enrollment\asset-id.txt' }
}
if (-not $resolved) {
# The 0.12.0 name, before the file was generalised.
$v = Read-FirstLine 'C:\Enrollment\measuringtool-id.txt'
if ($v -and $v -ne $Placeholder) { $resolved = $v; $source = 'C:\Enrollment\measuringtool-id.txt' }
}
}
if (-not $resolved) {
# Nothing to copy. Silence is the right answer: the collector still adopts
# or mints as it does today, and inventing a value here would name a device
# that nobody can account for.
Log "no identity found for this $($family.Label) bay; DeviceId not written"
exit 0
}
if ($resolved -eq $Placeholder) {
Log "resolved value is the $Placeholder placeholder; DeviceId not written"
exit 0
}
Log "resolved DeviceId '$resolved' from $source"
# --- 3. write it ------------------------------------------------------------
$existing = ''
try {
if (Test-Path $RegPath) {
$prop = Get-ItemProperty -Path $RegPath -Name $RegValueName -ErrorAction SilentlyContinue
if ($prop) { $existing = [string]$prop.$RegValueName; $existing = $existing.Trim() }
}
} catch {
Log "WARN could not read existing ${RegValueName}: $($_.Exception.Message)"
}
if ($existing -eq $resolved) {
Log "DeviceId already '$resolved'; nothing to do"
exit 0
}
if ($existing -and -not $Force) {
# The registry value wins by default. C:\Enrollment is written once at
# imaging and never updated on a device swap, so the file is the more
# likely one to be wrong.
Log "REFUSING to overwrite: DeviceId is '$existing', $source says '$resolved'"
Log 'Re-run with -Force if the file is right, or fix the file if the registry is.'
exit 2
}
try {
if (-not (Test-Path $RegPath)) {
if ($PSCmdlet.ShouldProcess($RegPath, 'Create registry key')) {
New-Item -Path $RegPath -Force | Out-Null
Log "created $RegPath"
}
}
$action = if ($existing) { "Set $RegValueName '$existing' -> '$resolved'" }
else { "Set $RegValueName '$resolved'" }
if ($PSCmdlet.ShouldProcess($RegPath, $action)) {
New-ItemProperty -Path $RegPath -Name $RegValueName -Value $resolved `
-PropertyType String -Force | Out-Null
Log "wrote $RegPath\$RegValueName = '$resolved'"
} else {
Log "WHATIF would write $RegPath\$RegValueName = '$resolved'"
}
} catch {
Log "ERROR could not write ${RegValueName}: $($_.Exception.Message)"
exit 1
}
exit 0

View File

@@ -17,9 +17,13 @@
v-for="opt in filteredCommands"
:key="opt.action"
class="action-option"
:class="{ 'action-locked': opt.mayauthor === false }"
@mousedown.prevent="selectCommand(opt)"
>
{{ opt.label || opt.action }}
<span v-if="opt.mayauthor === false" class="action-lock-note">
needs {{ opt.requiredpermission }}
</span>
</div>
<div
class="action-option action-branch"
@@ -171,6 +175,9 @@ function enterMonitorLevel() {
}
function selectCommand(opt) {
// The server refuses this action anyway; declining here turns a confusing 403
// on save into an obvious "you cannot pick that" at the point of choosing.
if (opt.mayauthor === false) return
emit('update:modelValue', opt.action)
open.value = false
level.value = 'command'
@@ -254,6 +261,8 @@ function cancelCreate() {
}
.action-option { padding: 8px 12px; cursor: pointer; color: var(--text); }
.action-option:hover { background: var(--bg); }
.action-locked { opacity: 0.55; cursor: not-allowed; }
.action-lock-note { font-size: 0.75rem; color: var(--text-light); margin-left: 6px; }
.action-branch { font-style: italic; }
.action-create { font-weight: 600; color: var(--primary); }
.action-divider { height: 1px; background: var(--border); margin: 2px 0; }

View File

@@ -42,8 +42,8 @@
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
<button class="btn btn-secondary btn-sm" :disabled="!iseditable(p)" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="!iseditable(p)" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="policies.length === 0">
@@ -140,7 +140,7 @@ const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return { name: '', description: '', scopejson: { type: 'true' }, actions: [] }
return { name: '', description: '', scopejson: { type: 'allpcs' }, actions: [] }
}
onMounted(() => loadData())
@@ -174,7 +174,9 @@ async function loadData() {
}
if (types.status === 'fulfilled') {
pctypes.value = types.value.data.data || []
// Plain array, same as applications below. Reading .data.data threw
// inside this try, so apps, actions and monitors never got assigned.
pctypes.value = types.value || []
} else {
failures.push('PC types')
console.error('Error loading PC types:', types.reason)
@@ -219,7 +221,7 @@ function openModal(item = null) {
form.value = {
name: item.name || '',
description: item.description || '',
scopejson: item.scopejson ? JSON.parse(JSON.stringify(item.scopejson)) : { type: 'true' },
scopejson: item.scopejson ? JSON.parse(JSON.stringify(item.scopejson)) : { type: 'allpcs' },
actions: (item.actions || []).map(a => ({
action: a.action, mode: a.mode || 'gate',
desiredstate: a.desiredstate, params: a.params, intervalminutes: a.intervalminutes
@@ -285,6 +287,18 @@ async function save() {
}
}
// Mirrors the API guard in plugins/computers/api/policy_routes.py: local,
// autoapp and shipped rows are all editable. A shipped row is a starting point
// the product supplies, not a fixed opinion, and it keeps source='shipped' so
// provenance survives the edit. Only a genuinely external managed owner is
// read-only. Disabling on source==='local' alone stranded shipped rows behind
// dead buttons while the API was accepting the write.
const EDITABLE_SOURCES = ['local', 'autoapp', 'shipped']
function iseditable(p) {
return EDITABLE_SOURCES.includes(p.source)
}
async function remove(p) {
if (!confirm(`Delete configuration policy "${p.name}"?`)) return
try {

File diff suppressed because it is too large Load Diff

View File

@@ -573,12 +573,14 @@ onMounted(async () => {
operatingsystemsApi.list({ perpage: 100 }),
computersApi.protocols.list(),
businessunitsApi.listAll(),
// Handles its own failure: a site without printers still gets a form.
])
pcTypes.value = ptRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
// listAll() pages past the server's 100-row cap and returns the ROWS, not
// an axios response, so these are read directly. Reading .data.data here
// throws and takes the whole form down with it.
vendors.value = vendorRes || []
models.value = allModels
locations.value = locRes || []
operatingsystems.value = osRes.data.data || []
@@ -636,7 +638,11 @@ onMounted(async () => {
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
// A client-side TypeError has no err.response, so apiError() would report
// only the fallback and hide which field broke. Name it either way.
error.value = err?.response
? apiError(err, 'Failed to load data')
: `Failed to load data: ${err?.message || err}`
} finally {
loading.value = false
}

View File

@@ -7,16 +7,14 @@
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
This page has been retired. Imaging pc-type handling now lives in
GE-Enforce, where each imaging PC type is a manifest scope with its own
Computer Type. Manage it from the GE-Enforce section instead.
This page has been retired. An imaging PC type is now decided by
whatever provisions the machine, not configured here.
</p>
<p class="setting-description">
The collector still applies the built-in pc-type to Computer Type
defaults, so existing enrollment keeps working; there is nothing to
configure here anymore.
</p>
<RouterLink class="btn" to="/geenforce/manifests">Open GE-Enforce</RouterLink>
</div>
</div>
</div>
@@ -24,7 +22,7 @@
<script setup>
// Deprecated page. Kept only so the /settings/pctypemapping route and old
// bookmarks still resolve; GE-Enforce (scope computertypeid) supersedes the old
// collector pc-type mapping UI. See ADR-012.
import { RouterLink } from 'vue-router'
// bookmarks still resolve. The "open the tool that replaced this" link went
// with the plugin that served it, and a button to a route this build has no
// page for is worse than no button.
</script>

View File

@@ -107,8 +107,9 @@ onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await computersApi.types.listAll({ active: false })
pcTypes.value = response.data.data || []
// listAll() pages past the server's 100-row cap and returns the ROWS, not
// an axios response - there is no .data to unwrap.
pcTypes.value = await computersApi.types.listAll({ active: false })
} catch (err) {
console.error('Error loading PC types:', err)
} finally {

View File

@@ -33,6 +33,7 @@
<thead>
<tr>
<th>Asset Tag</th>
<th>Alias</th>
<th>Hostname</th>
<th>Serial Number</th>
<th>PC Type</th>
@@ -45,6 +46,7 @@
<tbody>
<tr v-for="item in computers" :key="item.assetid" class="clickable-row" @click="$router.push(`/pcs/${item.computer?.computerid || item.assetid}`)">
<td>{{ item.assetnumber }}</td>
<td>{{ item.name || '-' }}</td>
<td>{{ item.computer?.hostname || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.computer?.computertypename || '-' }}</td>

View File

@@ -2,7 +2,8 @@
<div class="scope-node">
<div class="scope-node-row">
<select v-model="node.type" class="form-control scope-type" @change="onTypeChange">
<option value="true">Matches everything</option>
<option value="allpcs">All PCs (excludes servers)</option>
<option v-if="node.type === 'true'" value="true">Matches everything (incl. servers)</option>
<option value="pctype">PC type is</option>
<option value="app">App is installed</option>
<option value="and">ALL of (AND)</option>
@@ -71,20 +72,20 @@ function onTypeChange() {
delete node.nodes
delete node.node
if (node.type === 'and' || node.type === 'or') {
node.nodes = [{ type: 'true' }]
node.nodes = [{ type: 'allpcs' }]
} else if (node.type === 'not') {
node.node = { type: 'true' }
node.node = { type: 'allpcs' }
}
}
function addChild() {
props.node.nodes.push({ type: 'true' })
props.node.nodes.push({ type: 'allpcs' })
}
function removeChild(index) {
props.node.nodes.splice(index, 1)
if (props.node.nodes.length === 0) {
props.node.nodes.push({ type: 'true' })
props.node.nodes.push({ type: 'allpcs' })
}
}
</script>

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const here = dirname(fileURLToPath(import.meta.url))
const view = readFileSync(join(here, 'ConfigurationPoliciesList.vue'), 'utf8')
// The API decides what may be edited; the buttons only reflect that decision.
// These drifted once: policy_routes.py was widened to accept shipped rows while
// the buttons still disabled on source !== 'local', so every shipped policy sat
// behind a dead Edit button and the change looked like it had never rolled.
// If the API guard changes, EDITABLE_SOURCES here changes with it.
describe('the configuration policy buttons follow the API guard', () => {
it('treats shipped as editable, not read-only', () => {
expect(view).toContain("EDITABLE_SOURCES = ['local', 'autoapp', 'shipped']")
})
it('does not gate the buttons on local alone', () => {
expect(view).not.toContain(":disabled=\"p.source !== 'local'\"")
})
it('gates edit and delete on the same rule', () => {
const disabled = view.match(/:disabled="!iseditable\(p\)"/g) || []
expect(disabled).toHaveLength(2)
})
})

View File

@@ -0,0 +1,58 @@
/**
* Per-PC credential controls on PC Details.
*
* Revoke and allow-reenroll are admin-role routes on the server. The page reads
* the credential from the same endpoint state call the liveness chip uses, and
* offers the two actions to admins only; every other reader gets the state in
* the Endpoint status card, where the server has already redacted the details.
*
* Asserted against the file text, matching pcDetailRunScripts.spec.js.
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const here = dirname(fileURLToPath(import.meta.url))
const source = readFileSync(join(here, 'PCDetail.vue'), 'utf8')
// Relative to the staged copy under src/.plugins-staged/, which is what runs.
const api = readFileSync(join(here, '..', '..', '..', 'api', 'index.js'), 'utf8')
describe('PCDetail credential controls', () => {
it('gates the controls on the admin role', () => {
expect(source).toMatch(/const canManageCredential = computed\(\(\) => authStore\.isAdmin\)/)
expect(source).toMatch(/v-if="canManageCredential && credential"/)
})
it('reads the credential from the endpoint state response', () => {
const loader = source.slice(source.indexOf('async function loadLiveness'))
const body = loader.slice(0, loader.indexOf('\n}\n'))
expect(body).toMatch(/credential\.value = state\.credential \|\| null/)
})
it('offers revoke only for an enrolled PC and allow only for a blocked one', () => {
expect(source).toMatch(/v-if="credential\.state === 'enrolled'"[\s\S]{0,120}credential-revoke/)
expect(source).toMatch(/v-if="credential\.reenrollblocked"[\s\S]{0,120}credential-allow/)
})
it('offers the keep-from-enrolling option and sends it with the reason', () => {
expect(source).toMatch(/v-model="revokeblockreenroll"/)
expect(source).toMatch(/Keep this PC from enrolling again/)
const handler = source.slice(source.indexOf('async function submitRevokeCredential'))
const body = handler.slice(0, handler.indexOf('\n}\n'))
expect(body).toMatch(/blockreenroll: revokeblockreenroll\.value/)
expect(body).toMatch(/reason: revokereason\.value\.trim\(\)/)
})
it('calls the existing enrollment routes', () => {
expect(api).toMatch(/`\/endpoints\/enrollment\/assets\/\$\{assetId\}\/revoke`/)
expect(api).toMatch(/`\/endpoints\/enrollment\/assets\/\$\{assetId\}\/allow-reenroll`/)
expect(api).toMatch(/if \(opts\.blockreenroll\) \{\s*body\.blockreenroll = true/)
})
it('confirms before allowing re-enrollment', () => {
const handler = source.slice(source.indexOf('async function confirmAllowReenroll'))
const body = handler.slice(0, handler.indexOf('\n}\n'))
expect(body).toMatch(/if \(!confirm\(/)
})
})

View File

@@ -0,0 +1,95 @@
/**
* Run Scripts tab on PC Details.
*
* The thing worth pinning here is the SEPARATION. run_script and
* scheduledtask_run map to different grants in plugins/endpoints/commands.py
* (endpoints.runscript and endpoints.corptools), split out of a single
* endpoints.deploy bundle precisely so that being able to trigger a corporate
* tool does not also mean being able to run arbitrary PowerShell as SYSTEM.
*
* That split lives in the backend. The UI can quietly undo it by hanging the
* script picker off the Corporate Tools tab, whose only gate is
* endpoints.corptools - and the failure would be invisible in review, because
* the page would look correct and the server would simply 403 the push. These
* tests fail if someone merges the two.
*
* Asserted against the file text, matching systemScriptsSettings.spec.js.
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
const here = dirname(fileURLToPath(import.meta.url))
const source = readFileSync(join(here, 'PCDetail.vue'), 'utf8')
describe('PCDetail Run Scripts tab', () => {
it('gates the tab on endpoints.runscript, not the Corporate Tools grant', () => {
expect(source).toMatch(
/const canRunScript = computed\(\(\) => authStore\.hasPermission\('endpoints\.runscript'\)\)/)
})
it('pushes run_script from its own tab, not from the Corporate Tools block', () => {
// The corptools block must not gain a run_script call: that would put the
// action behind endpoints.corptools in the UI while the server still
// demands endpoints.runscript.
const corptools = source.slice(
source.indexOf("activegroup === 'corptools'"),
source.indexOf("activegroup === 'runscripts'"))
expect(corptools).not.toMatch(/run_script/)
expect(source).toMatch(/activegroup === 'runscripts'/)
})
it('enqueues run_script with a script-id target', () => {
expect(source).toMatch(/'run_script', `script:\$\{script\.scriptid\}`/)
})
it('requires a typed confirmation before running', () => {
// Every script on this tab is arbitrary code, so unlike Corporate Tools
// there is no safe branch that gets a plain OK button.
const handler = source.slice(source.indexOf('async function confirmRunScript'))
const body = handler.slice(0, handler.indexOf('\n}\n'))
expect(body).toMatch(/Type RUN to confirm/)
expect(body).toMatch(/typed !== 'RUN'/)
})
it('warns that a script runs as SYSTEM before it is sent', () => {
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
expect(tab.slice(0, 2000)).toMatch(/runs as SYSTEM/)
})
it('shows the run context and working directory read-only', () => {
// Both are authoring decisions made in the library. Offering an override
// here would mean the script that ran is not the script that was reviewed,
// so they must be rendered as facts and never bound with v-model.
const tab = source.slice(source.indexOf("activegroup === 'runscripts'"))
const block = tab.slice(0, tab.indexOf('runscripts-actions'))
expect(block).toMatch(/selectedscript\.workingdirectory/)
expect(block).toMatch(/selectedscript\.runcontext/)
expect(block).not.toMatch(/v-model="[^"]*workingdirectory/)
expect(block).not.toMatch(/v-model="[^"]*runcontext/)
})
it('offers only scripts that have a body', () => {
// A bodyless script is rejected at enqueue by _resolve_run_script, so
// listing it would offer a button whose only outcome is an error.
expect(source).toMatch(/filter\(\(script\) => script\.hasbody\)/)
})
it('reports the push as queued rather than as finished', () => {
// The agent collects the command on its next check-in; claiming it ran
// would assert an outcome nothing has observed.
const handler = source.slice(source.indexOf('async function confirmRunScript'))
expect(handler.slice(0, handler.indexOf('\n}\n'))).toMatch(/queued for/)
})
it('styles the Run Scripts tab from defined theme tokens only', () => {
// --text-primary/--text-secondary/--bg-secondary are not defined by this
// app's theme. var() then falls through to the fallback, or to inherit,
// which reads fine in light mode and misrenders for dark-mode users - the
// failure only shows up on the theme most of the shop floor runs.
const runscripts = source.slice(source.indexOf('.corptools-intro'))
expect(runscripts).not.toMatch(/--text-primary|--text-secondary|--bg-secondary|--surface/)
expect(runscripts).not.toMatch(/var\(--[a-z-]+,\s*#/)
})
})

View File

@@ -25,7 +25,7 @@ depends_on = None
# TEXT rather than sa.JSON on purpose. MySQL 5.6 has no JSON type - it arrived
# in 5.7 - and this migration failed there with a bare syntax error near the
# word JSON. Every other JSON-bearing column in the product is TEXT for the same
# reason (guides.blocksjson, geenforce.manifestjson); the model reads and writes
# reason (guides.blocksjson); the model reads and writes
# dicts through the JSONText type decorator, so nothing above the database
# changes.

View File

@@ -0,0 +1,68 @@
"""Add policyactionholds (break-glass desired-state suppression).
Backs the admin override: when an admin break-glasses past a policy denial on a
desired-mode action, a hold row suspends reconciliation of that action on that
endpoint until expiresat, so the override is not immediately undone by the next
drift-correcting enqueue.
Rows are never swept: expiry is evaluated at resolve time against expiresat, and
an expired row stays as the audit record of what was suppressed and why.
Idempotent: the create_table is skipped when the table already exists (e.g. a
test DB built by db.create_all()).
Revision ID: computers0005policyactionholds
Revises: computers0004policies
"""
from alembic import op
import sqlalchemy as sa
revision = 'computers0005policyactionholds'
down_revision = 'computers0004policies'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing = set(insp.get_table_names())
if 'policyactionholds' not in existing:
op.create_table(
'policyactionholds',
sa.Column('policyactionholdid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('expiresat', sa.DateTime(), nullable=False),
sa.Column('createdbyuserid', sa.Integer(), nullable=True),
sa.Column('createdbyusername', sa.String(length=100), nullable=True),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.PrimaryKeyConstraint('policyactionholdid'),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.ForeignKeyConstraint(['createdbyuserid'], ['users.userid'],
ondelete='SET NULL'),
)
op.create_index('idx_policyactionhold_asset', 'policyactionholds',
['assetid'])
op.create_index('idx_policyactionhold_expiry', 'policyactionholds',
['expiresat'])
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing = set(insp.get_table_names())
if 'policyactionholds' in existing:
op.drop_index('idx_policyactionhold_expiry',
table_name='policyactionholds')
op.drop_index('idx_policyactionhold_asset',
table_name='policyactionholds')
op.drop_table('policyactionholds')

View File

@@ -0,0 +1,85 @@
"""Allow indefinite policy action holds (policyactionholds.expiresat nullable).
0005 created expiresat NOT NULL because every hold was time-boxed: a hold that
nobody remembered to clear would expire on its own and policy would resume. That
self-healing property was the point of the column.
Admins now need to hold an action for work that cannot be bounded up front, so a
null expiresat is permitted and means indefinite - the hold stands until it is
released by hand. Everything reading this table must test
`expiresat IS NULL OR expiresat > now`; testing only the comparison silently
drops indefinite holds and lets policy resume against an admin who asked it not
to.
No backfill: existing rows all carry a real expiry and keep it. Widening
NOT NULL to NULL rewrites no data.
Idempotent: the alter is skipped when the column is already nullable, so a test
DB built by db.create_all() from the current model is left alone.
Revision ID: computers0006holdindefinite
Revises: computers0005policyactionholds
The id is kept short deliberately: alembic_version.version_num is VARCHAR(32),
and a longer id applies the DDL and then fails on the stamp, leaving the DB
migrated but recorded as un-migrated.
"""
from alembic import op
import sqlalchemy as sa
revision = 'computers0006holdindefinite'
down_revision = 'computers0005policyactionholds'
branch_labels = None
depends_on = None
def _expiresat_is_nullable(bind):
"""True when policyactionholds.expiresat already permits null.
Returns None when the table is absent entirely, which lets both directions
no-op rather than raising on a DB where the computers chain has not built
the table yet.
"""
insp = sa.inspect(bind)
if 'policyactionholds' not in set(insp.get_table_names()):
return None
for column in insp.get_columns('policyactionholds'):
if column['name'] == 'expiresat':
return bool(column.get('nullable'))
return None
def upgrade():
bind = op.get_bind()
nullable = _expiresat_is_nullable(bind)
if nullable is None or nullable:
return
op.alter_column('policyactionholds', 'expiresat',
existing_type=sa.DateTime(),
nullable=True)
def downgrade():
"""Re-impose NOT NULL, giving any indefinite hold a bounded expiry.
A straight alter would fail on the rows this feature exists to create, so
indefinite holds are first stamped with the current time - expiring them
rather than destroying them. That is the conservative direction: on
downgrade, policy resuming is safer than reconciliation staying suppressed
by a row the older code cannot represent. The audit row survives either way.
"""
bind = op.get_bind()
nullable = _expiresat_is_nullable(bind)
if nullable is None or not nullable:
return
op.execute(sa.text(
'UPDATE policyactionholds '
'SET expiresat = CURRENT_TIMESTAMP '
'WHERE expiresat IS NULL'))
op.alter_column('policyactionholds', 'expiresat',
existing_type=sa.DateTime(),
nullable=False)

View File

@@ -14,8 +14,11 @@ from .policy import (
BusinessPolicyItem,
POLICY_SOURCE_LOCAL,
POLICY_SOURCE_SHIPPED,
POLICY_SOURCE_AUTOAPP,
ACTION_MODE_DESIRED,
ACTION_MODE_GATE,
PolicyActionHold,
DEFAULT_HOLD_MINUTES,
)
__all__ = [
@@ -30,6 +33,9 @@ __all__ = [
'BusinessPolicyItem',
'POLICY_SOURCE_LOCAL',
'POLICY_SOURCE_SHIPPED',
'POLICY_SOURCE_AUTOAPP',
'ACTION_MODE_DESIRED',
'ACTION_MODE_GATE',
'PolicyActionHold',
'DEFAULT_HOLD_MINUTES',
]

View File

@@ -3,7 +3,7 @@
MySQL 5.6 has no JSON type - it arrived in 5.7 - and a site is still running
5.6, where `sa.JSON()` fails at migration time with a bare syntax error near the
word JSON. Every other JSON-bearing column in this product is already TEXT for
that reason: guides' `blocksjson`, GE-Enforce's `manifestjson`.
that reason: guides' `blocksjson`, backups' payload column.
This keeps the Python side identical. A model attribute still holds a dict or a
list, still assigns and reads like one; only the storage changes. The

View File

@@ -27,12 +27,35 @@ from .jsontext import JSONText
# owner. Kept a plain string, not an enum column, so adding a source is data.
POLICY_SOURCE_LOCAL = 'local'
# Product-owned rows we ship (the shopfloor app monitor bundle). Same read-only
# treatment as any non-local source: the CRUD API refuses to edit or delete
# them, the seeder reconciles them. Distinct from 'local' so a site's own
# policies are never confused with what the product shipped.
# Rows the product seeds (the shopfloor monitor policies, one per monitor).
# Distinct from 'local' so a site's own policies are never confused with what
# the product shipped, and so the UI can badge the origin.
#
# A shipped row is EDITABLE and DELETABLE, unlike a genuinely external source.
# The product ships a starting point, not an opinion about one site's floor: a
# plant that runs no MTConnect must be able to drop that policy. source stays
# 'shipped' through an edit, so provenance is a fact about where the row came
# from rather than a claim about who controls it now.
#
# Nothing reconciles these rows. The seeding migrations are insert-if-absent
# (they SELECT by name and return early if it exists), so an upgrade cannot
# stomp a site's edits. That property is load-bearing now that the rows are
# editable - do not replace it with a reconciling seeder without adding a
# dirty flag first.
POLICY_SOURCE_SHIPPED = 'shipped'
# Rows generated FOR US by another surface rather than authored by hand: today
# the "Required on all PCs" checkbox on an Application. Distinct from 'local' so
# unchecking the box knows which policy it owns and may safely remove.
#
# It doubles as the "untouched" test. An autoapp row is one nobody has
# customised, so it is safe to delete when the box is unchecked. The moment an
# admin edits it through the policy CRUD API it becomes 'local' - their work
# now, not ours - and unchecking warns instead of deleting. That keeps the
# generated policy convenient without letting it silently destroy hand-authored
# changes.
POLICY_SOURCE_AUTOAPP = 'autoapp'
class ConfigurationPolicy(BaseModel):
"""A single reusable policy: a scope plus the actions it applies.
@@ -202,3 +225,82 @@ class BusinessPolicyItem(BaseModel):
def __repr__(self):
return (f"<BusinessPolicyItem bp={self.businesspolicyid} "
f"cp={self.configurationpolicyid}>")
# Default life of a break-glass suppression hold, used when the caller names no
# window. Long enough to comfortably outlast one maintenance window, short
# enough that nobody has to remember to clear it: policy resumes on its own.
#
# A caller MAY name any window instead, including none at all (expiresat NULL,
# an indefinite hold). That is a deliberate escape hatch for work that cannot be
# bounded up front, and it forfeits the self-healing this default exists to
# provide - see the class docstring.
DEFAULT_HOLD_MINUTES = 240
class PolicyActionHold(BaseModel):
"""A time-boxed suspension of desired-state reconciliation for one action.
An admin break-glass override on a `gate`-mode action is a one-shot - policy
only said "not exposed", and nothing reverses it. An override on a
`desired`-mode action is a fight: the admin stops a service and the
reconciliation loop restarts it on the next check-in, so the override looks
broken in production. A hold records that fight's outcome server-side, and
the resolver drops the held action out of the desired set until it expires.
Expiry is evaluated at RESOLVE time against expiresat, never by a sweeper
job. A sweeper that stalls would silently extend every hold it failed to
clear, which is policy disabled by accident - the failure mode this design
exists to avoid. Rows are left behind after expiry deliberately: an expired
hold is the audit record of what was suppressed, by whom, and why.
expiresat is NULLABLE, and null means indefinite: the hold applies until an
admin releases it by hand. This is the one case where the self-healing
property above does not hold, so treat it as the sharp edge it is - an
indefinite hold silently disables reconciliation of that action forever, and
the endpoint will keep reporting healthy while it drifts. Nothing in the
system surfaces a stale hold; the only guard is the admin who placed it.
Every read of this table must therefore test `expiresat IS NULL OR
expiresat > now`. Testing only the comparison drops indefinite holds, which
would let policy quietly resume against an admin who asked it not to.
"""
__tablename__ = 'policyactionholds'
policyactionholdid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
)
# Allowlisted action name, same vocabulary as ConfigurationPolicyAction.
action = db.Column(db.String(100), nullable=False)
# Naive UTC, matching BaseModel timestamps. Null means indefinite - the
# hold stands until released by hand.
expiresat = db.Column(db.DateTime, nullable=True)
# Who placed it. The id goes null if the user is deleted, but the username
# snapshot survives: a break-glass record that forgets who used it when the
# account is removed is worthless at review time.
createdbyuserid = db.Column(
db.Integer,
db.ForeignKey('users.userid', ondelete='SET NULL'),
nullable=True,
)
createdbyusername = db.Column(db.String(100), nullable=True)
reason = db.Column(db.Text, nullable=False)
__table_args__ = (
db.Index('idx_policyactionhold_asset', 'assetid'),
db.Index('idx_policyactionhold_expiry', 'expiresat'),
)
def __repr__(self):
until = self.expiresat or 'indefinite'
return (f"<PolicyActionHold asset={self.assetid} {self.action} "
f"until={until}>")

View File

@@ -6,7 +6,7 @@ import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from flask import Flask, Blueprint, g
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
@@ -218,6 +218,16 @@ class ComputersPlugin(BasePlugin):
self._register_import_hook()
logger.info(f"Computers plugin initialized (v{self.meta.version})")
def get_settings_routes(self) -> List[Dict]:
"""Register Operating Systems management page in Model Catalog."""
return [{
'hub': 'model-catalog',
'path': 'operatingsystems',
'name': 'model-catalog-os',
'tab': 'Operating Systems',
'component': '.plugins-staged/computers/views/OperatingSystemsList.vue'
}]
# -- bulk-import machine link -------------------------------------------
def _register_import_hook(self):
@@ -444,6 +454,7 @@ class ComputersPlugin(BasePlugin):
"""
return {
'identityfield': 'hostname',
'acceptsagentcredential': True,
'fields': {
'hostname': {'type': 'string', 'required': True},
'machinenumber': {'type': 'string'},
@@ -714,6 +725,13 @@ class ComputersPlugin(BasePlugin):
comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid)
.filter(Asset.assetnumber.ilike(hostname)).first())
# A per-PC credential reports for its own PC only, and never creates one.
if (getattr(g, 'agentauth', None) == 'credential'
and (comp is None
or comp.assetid != getattr(g, 'agentassetid', None))):
raise PermissionError(
'The payload names a different endpoint than the credential')
action = 'updated'
if not comp:
atype = AssetType.query.filter_by(assettype='computer').first()
@@ -1721,6 +1739,14 @@ class ComputersPlugin(BasePlugin):
rel.isactive = False
return []
# Plugin-optional by contract, and on this line the option is OFF: no
# measuringtools plugin is bundled, so this import always fails and a
# metrology PC reports as an ordinary PC with no instrument asset and no
# tool link. That is the right answer here - ADR-018 says a CMM is a
# machine - and the whole branch stays because the contract is that a
# site MAY install a measuringtools plugin of its own, at which point
# this adopts and links exactly as it always did. The warning rides back
# on the collector response so the silence is at least explained.
try:
from plugins.measuringtools.models import MeasuringTool
except ImportError:
@@ -2307,3 +2333,16 @@ class ComputersPlugin(BasePlugin):
today should weigh it.
"""
return ['computers.view']
def get_asset_type_descriptor(self):
"""Return descriptor for computer types management in Asset Type Hub."""
return {
'assettype': 'computer',
'subtype_endpoint': '/computers/types',
'subtype_id_field': 'computertypeid',
'subtype_label_field': 'computertype',
'subtype_description_field': 'description',
'subtype_color_field': 'color',
'optional_tabs': [],
'edit_permission': 'computers.manage',
}

View File

@@ -0,0 +1,202 @@
"""Generate a Configuration Policy from an Application's "Required on all PCs".
Ticking that box on an application should not merely flag it in the compliance
report - it should actually enforce the app onto every PC. This module owns the
one policy that represents that intent, so the applications API does not have to
know how policies are shaped.
Ownership and the "untouched" rule
----------------------------------
A generated policy carries source=POLICY_SOURCE_AUTOAPP, which marks it as ours
to manage. Unticking the box removes it - but ONLY while it still carries that
source. If an admin edits the policy through the policy CRUD API it flips to
'local', and from then on unticking leaves it alone and reports that it was
kept. Deleting someone's customised enforcement rule because a checkbox changed
on a different screen would be a genuinely destructive surprise, and the box is
not a reliable signal that they wanted that.
Not attached to a Business Policy
---------------------------------
The generated policy is NOT added to any Business Policy. The resolver only
applies Configuration Policies reached through an active Business Policy, so
until an admin adds it to one, the generated policy installs nothing. The API
response and the acknowledgement text say so rather than promising a fleet
install that will not happen.
Scope
-----
Generated policies use the `allpcs` node, not `true`: "required on all PCs"
means the PCs people work at, and pushing an installer to a server because it
appeared in the app catalog is not what the checkbox says. See policyscope.py.
"""
from shopdb.api import db
from ..models import (
ConfigurationPolicy,
ConfigurationPolicyAction,
POLICY_SOURCE_AUTOAPP,
POLICY_SOURCE_LOCAL,
ACTION_MODE_DESIRED,
)
# The action a generated policy carries. update_app is the allowlisted verb that
# installs a pinned app package on an endpoint (plugins/endpoints/commands.py),
# so it is what "this app is required here" reduces to in enforcement terms.
REQUIRED_APP_ACTION = 'update_app'
# Name prefix for generated rows. ConfigurationPolicy.name is unique and NOT
# NULL, so generated names must be deterministic (we have to find the row again)
# and distinctive enough not to collide with a hand-authored policy.
AUTO_POLICY_PREFIX = 'Required app:'
def auto_policy_name(application):
"""The deterministic policy name for an application."""
return '{} {}'.format(AUTO_POLICY_PREFIX, application.appname)
def _action_appid(action_row):
"""The appid an action's params name, or None."""
params = action_row.params
if not isinstance(params, dict):
return None
try:
return int(params.get('appid'))
except (TypeError, ValueError):
return None
def policies_referencing_app(appid):
"""Every ConfigurationPolicy with an action whose params name this appid.
Params are a JSON text column (MySQL 5.6 has no JSON type to query), so the
match is done in Python over the actions that carry params at all.
"""
if appid is None:
return []
found = {}
rows = (ConfigurationPolicyAction.query
.filter(ConfigurationPolicyAction.params.isnot(None))
.all())
for action_row in rows:
if _action_appid(action_row) == int(appid):
policy = action_row.configurationpolicy
if policy is not None:
found[policy.configurationpolicyid] = policy
return [found[key] for key in sorted(found)]
def find_auto_policy(application):
"""Return the policy generated for this app, whoever owns it now.
Keyed on the appid in the generated update_app action's params, not on the
name: the name embeds appname, so renaming the application used to orphan
its policy (the next untick could not find it, and a re-tick generated a
second one). A policy counts as generated when it still carries the autoapp
source or the generated name prefix, so a row flipped to 'local' by an edit
is still found and callers can tell "customised" from "absent". The name
lookup stays as a fallback for a row whose params were edited away.
"""
candidates = [
policy for policy in policies_referencing_app(application.appid)
if policy.source == POLICY_SOURCE_AUTOAPP
or (policy.name or '').startswith(AUTO_POLICY_PREFIX)
]
if candidates:
candidates.sort(key=lambda policy: (
policy.source != POLICY_SOURCE_AUTOAPP,
policy.configurationpolicyid))
return candidates[0]
return ConfigurationPolicy.query.filter_by(
name=auto_policy_name(application)
).first()
def sync_required_app_policy(application):
"""Create or remove the generated policy to match application.isrequired.
Returns a short status string for the API to surface to the user:
'created' - a new policy now enforces this app
'removed' - the generated policy was deleted
'kept' - a customised policy was left in place deliberately
'unchanged' - nothing needed doing
"""
existing = find_auto_policy(application)
if application.isrequired:
if existing is not None:
# Already enforced. Deliberately does NOT rewrite a customised row
# back to our defaults; re-ticking a box should not silently revert
# an admin's edits. A still-generated row does follow an app rename
# so its name keeps describing what it installs.
wanted = auto_policy_name(application)
if (existing.source == POLICY_SOURCE_AUTOAPP
and existing.name != wanted
and ConfigurationPolicy.query.filter_by(name=wanted).first() is None):
existing.name = wanted
return 'unchanged'
policy = ConfigurationPolicy(
name=auto_policy_name(application),
description=(
'Auto-generated from the "Required on all PCs" checkbox on '
'application "{}". Editing this policy makes it yours: it will '
'no longer be removed automatically when that box is '
'unchecked.'.format(application.appname)
),
scopejson={'type': 'allpcs'},
source=POLICY_SOURCE_AUTOAPP,
)
db.session.add(policy)
db.session.flush()
db.session.add(ConfigurationPolicyAction(
configurationpolicyid=policy.configurationpolicyid,
action=REQUIRED_APP_ACTION,
mode=ACTION_MODE_DESIRED,
desiredstate={'installed': True},
params={'appid': application.appid},
))
return 'created'
# Not required.
if existing is None:
return 'unchanged'
if existing.source != POLICY_SOURCE_AUTOAPP:
# Someone customised it. Theirs now - leave it and say so.
return 'kept'
db.session.delete(existing)
return 'removed'
def disown_if_generated(policy):
"""Flip a generated policy to 'local' because it is being hand-edited.
Called from the policy CRUD write paths. After this, the application
checkbox no longer owns the row and will not delete it.
"""
if policy is not None and policy.source == POLICY_SOURCE_AUTOAPP:
policy.source = POLICY_SOURCE_LOCAL
return True
return False
def deactivate_policies_for_app(appid):
"""Deactivate every remaining policy whose actions name a deleted appid.
Called when an application is deleted, after the generated policy has been
withdrawn. What is left is either the customised ('kept') generated policy
or a hand-authored one; both would keep trying to install an application
that no longer exists. They are deactivated rather than deleted - they are
someone's work - and returned so the API can name them.
"""
deactivated = []
for policy in policies_referencing_app(appid):
if policy in db.session.deleted:
continue
if policy.isactive:
policy.isactive = False
deactivated.append(policy.name)
return deactivated

View File

@@ -20,7 +20,7 @@ The result is an EffectivePolicy: a flat, resolved map of action name -> decisio
and the allowlist need.
"""
from .policyscope import evaluate_scope
from .policyscope import SERVER_ICON, evaluate_scope
class EffectiveAction:
@@ -57,17 +57,39 @@ class EffectivePolicy:
actions maps action name -> EffectiveAction (first-match-wins already
applied). Convenience views expose the gate allowlist and desired-state set
that enforcement and the endpoints seam consume.
heldactions are action names under an unexpired break-glass hold. They are
withheld from desired_actions (reconciliation stops fighting the admin) but
deliberately LEFT IN allowed_actions: a hold suspends drift correction, it
does not revoke permission to run the action by hand.
"""
def __init__(self, actions):
def __init__(self, actions, heldactions=None):
self.actions = actions
self.heldactions = set(heldactions or ())
def allowed_actions(self):
"""Set of action names permitted on this endpoint (gate + desired)."""
return set(self.actions.keys())
def desired_actions(self):
"""action name -> EffectiveAction for mode == 'desired' only."""
"""action name -> EffectiveAction for mode == 'desired' only.
Held actions are excluded, so the reconciler does not re-enqueue the
very change an admin just broke glass to make.
"""
return {name: eff for name, eff in self.actions.items()
if eff.mode == 'desired' and name not in self.heldactions}
def holdable_actions(self):
"""Desired-mode action names, INCLUDING ones already held.
Deliberately not desired_actions(): that view hides held actions, so
building a "what can I hold" picker from it would drop an action the
moment it was held and make the current state unreadable. Only
desired-mode actions appear, because a hold suspends reconciliation and
gate-mode actions have none to suspend.
"""
return {name: eff for name, eff in self.actions.items()
if eff.mode == 'desired'}
@@ -78,15 +100,21 @@ class EffectivePolicy:
def build_endpoint_context(computer):
"""Resolve a Computer row into the context the scope evaluator needs.
Returns {'computertypeid': int|None, 'appids': set[int]}.
Returns {'computertypeid': int|None, 'appids': set[int], 'isserver': bool}.
"""
appids = set()
for link in getattr(computer, 'installedapps', None) or ():
if getattr(link, 'isactive', True) and link.appid is not None:
appids.add(link.appid)
# Server designation rides on the computer type's icon slug; policyscope
# owns that rule, we just resolve it for the endpoint. An unset type is not
# a server.
computertype = getattr(computer, 'computertype', None)
isserver = getattr(computertype, 'icon', None) == SERVER_ICON
return {
'computertypeid': getattr(computer, 'computertypeid', None),
'appids': appids,
'isserver': isserver,
}
@@ -165,7 +193,35 @@ def effective_policy_for(assetid):
return EffectivePolicy({})
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
return build_effective_policy(computer, business_policies)
effective = build_effective_policy(computer, business_policies)
effective.heldactions = _held_actions_for(assetid)
return effective
def _held_actions_for(assetid):
"""Action names under an unexpired break-glass hold for this endpoint.
Expiry is compared here, at resolve time, rather than swept by a background
job. A sweeper that stalled would silently keep every hold it failed to
clear alive, which is reconciliation disabled by accident - so the row's own
expiresat is the single authority on whether it still applies.
A null expiresat is an INDEFINITE hold and is always active. It must be
matched explicitly: a bare `expiresat > now` drops null rows, which would
resume reconciliation of exactly the actions an admin pinned open-endedly -
the reconciler restarting a service someone deliberately stopped.
"""
from datetime import datetime
from sqlalchemy import or_
from ..models import PolicyActionHold
rows = (PolicyActionHold.query
.filter(PolicyActionHold.assetid == assetid,
PolicyActionHold.isactive.is_(True),
or_(PolicyActionHold.expiresat.is_(None),
PolicyActionHold.expiresat > datetime.utcnow()))
.all())
return {row.action for row in rows}
def _monitor_action_ids(effective):

View File

@@ -11,7 +11,21 @@ Node grammar (all dict nodes carry a "type"):
{"type": "not", "node": <node>}
{"type": "pctype", "id": <computertypeid:int>}
{"type": "app", "id": <appid:int>}
{"type": "true"} # matches everything (an unscoped/global policy)
{"type": "allpcs"} # every PC EXCEPT those whose type is designated a server
{"type": "true"} # matches everything, servers included
"allpcs" vs "true": authoring an estate-wide policy almost always means "every
PC an operator sits at", not "every computer we have a row for". Those differ by
the servers, where a reboot or a script push is a materially different event.
"true" is retained because a stored scope of None is equivalent to it and older
policies were written with it, but the builder offers "All PCs" instead.
Server designation is read from the computer type's ICON slug ('server'). That
field is nominally a UI hint, which is a real wart - it is load-bearing here. It
was chosen over matching the type NAME because a site can rename "Server" but a
renamed type keeps its icon, and over a new isserver column because that needs a
migration this did not warrant. If server designation ever becomes a first-class
flag, this is the single place that reads it.
Kept pure and dependency-free (no db, no Flask) so it is trivially unit-tested
and so a future GE-Enforce assembly engine can reuse the exact same evaluator on
@@ -22,8 +36,12 @@ the exact same AST when it composes policies.
# recursion. Ten levels is far past any policy a human would author.
MAX_SCOPE_DEPTH = 10
# The computer-type icon slug that designates a type as a server. See the module
# docstring for why the icon carries this.
SERVER_ICON = 'server'
_LOGICAL = {'and', 'or', 'not'}
_LEAF = {'pctype', 'app', 'true'}
_LEAF = {'pctype', 'app', 'true', 'allpcs'}
class ScopeError(ValueError):
@@ -60,7 +78,7 @@ def validate_scope(node, _depth=0):
ident = node.get('id')
if not isinstance(ident, int) or isinstance(ident, bool):
raise ScopeError(f'{ntype} requires an integer "id"')
# 'true' carries no payload.
# 'true' and 'allpcs' carry no payload.
def evaluate_scope(node, context):
@@ -69,6 +87,7 @@ def evaluate_scope(node, context):
context is expected to expose:
context['computertypeid'] -> int or None
context['appids'] -> set/collection of installed appids
context['isserver'] -> bool, whether the type is server-designated
Returns True when the endpoint falls in scope. An empty/None scope means the
policy is unscoped and matches everything (same as {"type": "true"}), which
@@ -80,6 +99,10 @@ def evaluate_scope(node, context):
ntype = node['type']
if ntype == 'true':
return True
if ntype == 'allpcs':
# Absent designation means "not a server": an endpoint with no type set
# is an ordinary PC until something says otherwise.
return not context.get('isserver', False)
if ntype == 'and':
return all(evaluate_scope(child, context) for child in node['nodes'])
if ntype == 'or':