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:
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
264
plugins/computers/client/Set-ShopDBDeviceId.ps1
Normal file
264
plugins/computers/client/Set-ShopDBDeviceId.ps1
Normal 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
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
58
plugins/computers/frontend/views/pcDetailCredential.spec.js
Normal file
58
plugins/computers/frontend/views/pcDetailCredential.spec.js
Normal 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\(/)
|
||||
})
|
||||
})
|
||||
95
plugins/computers/frontend/views/pcDetailRunScripts.spec.js
Normal file
95
plugins/computers/frontend/views/pcDetailRunScripts.spec.js
Normal 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*#/)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>")
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
202
plugins/computers/services/apppolicy.py
Normal file
202
plugins/computers/services/apppolicy.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user