Implement Stage A: rule engine, validation, audit, and safety gates

Completes 109 of 121 tasks. Every remaining task needs a tenant connection
(T055, T056, T101-T103) or an Azure Automation account (T115-T121).

  354 offline Pester tests      PASS
  Engine purity (Principle IV)  PASS
  Sanitization (SC-013)         PASS  (156 files)
  Graph module loaded in tests  none  (SC-008 holds)

What landed
  - Four-layer configuration validation with stable finding codes, covering
    every VR-002 and VR-003 condition, plus a 23-fixture invalid-config corpus
  - Run loop, audit records (NDJSON through a single sink), summaries,
    reconciliation, and exit codes 0-6
  - Persistence behind a single write-body builder whose result always has
    exactly one key
  - Invoke-PersonaEngine.ps1 and Edit-PersonaEngineConfig.ps1
  - Six docs, two pipelines, traceability matrix, V-5a and sanitization records

Three deviations from tasks.md, each recorded in its status block

  T033 is not in Resolve-UserPersona. evaluationErrorThreshold is run-level
  state and the rule engine is pure; a counter there would break Principle IV.
  It lives in New-PersonaRunCounter and is applied in the run loop.

  A new src/Engine/ layer holds Invoke-PersonaEngineRun. The entry script
  imports the manifest, which requires Microsoft.Graph.Authentication, so a
  loop living only inside it could not run on a machine without the Graph SDK
  and SC-004 could not be proven at all. The entry script is now a thin
  wrapper and what ships is what is tested.

  The invalid-config corpus is generated by a committed script, with the
  generated fixtures committed too, so a reviewer sees the fixture in the diff.

Defects found by running the code, not by reading it

  Group and role ID lists were double-wrapped: @(Get-PersonaGroupIdPage ...)
  around a comma-returned array collapsed every membership list into one
  bogus space-joined entry. That is a silent false non-match, exactly what
  FR-013 exists to prevent.

  A 403 whose status appears only in the exception message parsed as $null,
  which the retry policy treats as a transport error - five requests per
  account against a tenant already refusing. Status extraction now falls back
  to the message text, bounded to 400-599.

  The sanitization scan walked tracked files only, so it covered 34 of 156
  files and none of this phase's code. It now scans untracked non-ignored
  files too, and a negative control confirms it catches a planted leak.

  Test-Json reports one error per violating location, not first-failure-only
  as the V-5a draft claimed. Record and pin corrected.

Enforcement remains blocked on the V-4 security sign-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 21:48:19 -04:00
parent c59c85dd55
commit cdc6bb33d3
124 changed files with 16638 additions and 199 deletions
+230
View File
@@ -0,0 +1,230 @@
{
"configVersion": "1.0.0",
"metadata": {
"owner": "<TEAM-NAME>",
"changeReference": "<CHANGE-REFERENCE>",
"description": "Placeholder-only example. Every identifier below is fictional. Real group Object IDs, attribute names, and domains belong in a protected configuration store, never in this repository."
},
"engine": {
"targetAttribute": "extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>",
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>"
],
"maxConditionDepth": 5,
"summaryInterval": 25,
"defaultMembershipMode": "direct",
"evaluationErrorThreshold": 50
},
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true,
"includeEligible": false
}
},
"logging": {
"destination": "both",
"path": "<LOG-OUTPUT-PATH>",
"traceConditionValues": false
},
"personas": [
"Guest",
"BreakGlass-Admin",
"Tier0-Admin",
"Tier1-Admin",
"Tier2-Admin",
"Restricted-User",
"Test-Account",
"Service-Account",
"Shared-Functional-Account",
"Meeting-Room-Device",
"Employee",
"Contractor",
"Student"
],
"rules": [
{
"id": "RULE-0010-BREAKGLASS",
"name": "Emergency access accounts",
"description": "Emergency access accounts identified by immutable Object ID (RE-009). Evaluated first so no later rule can reclassify them.",
"enabled": true,
"priority": 10,
"persona": "BreakGlass-Admin",
"owner": "<TEAM-NAME>",
"match": {
"operator": "any",
"conditions": [
{
"type": "property",
"property": "AccountObjectId",
"operator": "in",
"values": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002"
]
}
]
}
},
{
"id": "RULE-0020-GUEST",
"name": "Guest accounts",
"description": "Any account whose directory user type is Guest.",
"enabled": true,
"priority": 20,
"persona": "Guest",
"match": {
"operator": "all",
"conditions": [
{
"type": "property",
"property": "UserType",
"operator": "equals",
"value": "Guest"
}
]
}
},
{
"id": "RULE-0030-TIER0",
"name": "Tier 0 administrators",
"description": "Members of the Tier 0 administrative group, or holders of a Tier 0 directory role.",
"enabled": true,
"priority": 30,
"persona": "Tier0-Admin",
"match": {
"operator": "any",
"conditions": [
{
"type": "membership",
"operator": "memberOf",
"membershipMode": "transitive",
"groupObjectIds": [
"00000000-0000-0000-0000-0000000000a0"
]
},
{
"type": "role",
"operator": "memberOf",
"roleIds": [
"<TIER0-ROLE-TEMPLATE-ID>"
]
}
]
}
},
{
"id": "RULE-0040-SERVICE",
"name": "Service accounts",
"description": "Non-human accounts identified by naming convention and the service account group. Both must hold, so a naming-convention collision alone cannot classify a person as a service account.",
"enabled": true,
"priority": 40,
"persona": "Service-Account",
"match": {
"operator": "all",
"conditions": [
{
"type": "property",
"property": "UserPrincipalName",
"operator": "startsWith",
"value": "svc-"
},
{
"type": "membership",
"operator": "memberOf",
"groupObjectIds": [
"00000000-0000-0000-0000-0000000000b0"
]
}
]
}
},
{
"id": "RULE-0050-TEST",
"name": "Test accounts",
"description": "Accounts in the test account group, or matching the test naming convention while disabled.",
"enabled": true,
"priority": 50,
"persona": "Test-Account",
"match": {
"operator": "any",
"conditions": [
{
"type": "membership",
"operator": "memberOf",
"groupObjectIds": [
"00000000-0000-0000-0000-0000000000c0"
]
},
{
"operator": "all",
"conditions": [
{
"type": "property",
"property": "UserPrincipalName",
"operator": "startsWith",
"value": "test-"
},
{
"type": "property",
"property": "AccountEnabled",
"operator": "equals",
"value": "False"
}
]
}
]
}
},
{
"id": "RULE-0060-CONTRACTOR",
"name": "Contractors",
"description": "Accounts whose company name marks them as external, excluding those already classified by an earlier rule.",
"enabled": true,
"priority": 60,
"persona": "Contractor",
"match": {
"operator": "all",
"conditions": [
{
"type": "property",
"property": "CompanyName",
"operator": "isNotNull"
},
{
"type": "property",
"property": "CompanyName",
"operator": "notEquals",
"value": "<ORGANIZATION-NAME>"
}
]
}
},
{
"id": "RULE-0900-EMPLOYEE",
"name": "Employees",
"description": "Default classification for enabled member accounts with a department. Lowest priority so every more specific rule wins first.",
"enabled": true,
"priority": 900,
"persona": "Employee",
"match": {
"operator": "all",
"conditions": [
{
"type": "property",
"property": "UserType",
"operator": "equals",
"value": "Member"
},
{
"type": "property",
"property": "Department",
"operator": "isNotNull"
}
]
}
}
]
}
+278
View File
@@ -0,0 +1,278 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://example.invalid/persona-engine.schema.json",
"title": "Persona Engine Configuration",
"description": "Draft-07 by decision OTD-005: validated with the built-in Test-Json -SchemaFile cmdlet, whose validator reliably supports draft-04/06/07 only. Do not introduce 2019-09 or 2020-12 constructs. This schema is validation layer 2 of 4; semantic rules (VR-002) and safety rules (VR-003) are enforced in PowerShell, not here.",
"type": "object",
"required": ["configVersion", "engine", "dataSources", "personas", "rules"],
"additionalProperties": false,
"properties": {
"configVersion": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
"description": "Semantic version of this configuration. A downgrade is a safety violation (VR-003)."
},
"metadata": {
"type": "object",
"additionalProperties": true,
"properties": {
"owner": { "type": "string" },
"changeReference": { "type": "string" },
"description": { "type": "string" }
}
},
"engine": {
"type": "object",
"required": ["targetAttribute", "approvedWritableAttributes"],
"additionalProperties": false,
"properties": {
"targetAttribute": {
"type": "string",
"minLength": 1,
"description": "The single attribute the engine may write. Must also appear in approvedWritableAttributes (semantic layer). Example placeholder: extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>"
},
"approvedWritableAttributes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"maxConditionDepth": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5,
"description": "RE-004. The ceiling of 10 is a hard limit; the configured value may be lower."
},
"summaryInterval": {
"type": "integer",
"minimum": 0,
"default": 25,
"description": "FR-020. Zero suppresses interim summaries; a final summary is always produced."
},
"defaultMembershipMode": {
"type": "string",
"enum": ["direct", "transitive"],
"default": "direct"
},
"evaluationErrorThreshold": {
"type": "integer",
"minimum": 0,
"description": "Optional. Count of EvaluationError results above which the run reports failure."
}
}
},
"dataSources": {
"type": "object",
"required": ["groups", "roles"],
"additionalProperties": false,
"properties": {
"groups": {
"type": "object",
"required": ["enabled"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] }
}
},
"roles": {
"type": "object",
"required": ["enabled"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"includeEligible": {
"type": "boolean",
"default": false,
"description": "Out of scope for v1 unless authorization is confirmed and the provider is implemented."
}
}
}
}
},
"logging": {
"type": "object",
"additionalProperties": false,
"properties": {
"destination": {
"type": "string",
"enum": ["file", "stream", "both"],
"default": "both",
"description": "OTD-006. Additional transports are added behind the sink function, not by widening this enum without a version change."
},
"path": {
"type": "string",
"description": "Placeholder in committed artifacts: <LOG-OUTPUT-PATH>"
},
"traceConditionValues": {
"type": "boolean",
"default": false,
"description": "Diagnostic only. Enabling this without explicit acknowledgement is a safety finding (VR-003)."
},
"acknowledgeConditionTracing": {
"type": "boolean",
"default": false,
"description": "Explicit acknowledgement that condition-value tracing writes evaluated attribute values into audit records. Required by VR-003 whenever traceConditionValues is true. Kept in the configuration rather than passed as a command-line flag so the acknowledgement is reviewable in the change that enables tracing."
}
}
},
"personas": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"not": { "enum": ["EvaluationError"] }
},
"description": "Defined persona catalogue. EvaluationError is an execution result and must never be declared as a persona."
},
"rules": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/rule" }
}
},
"definitions": {
"rule": {
"type": "object",
"required": ["id", "name", "description", "enabled", "priority", "persona", "match"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"enabled": { "type": "boolean" },
"priority": { "type": "integer", "minimum": 0 },
"persona": {
"type": "string",
"minLength": 1,
"not": { "enum": ["Unclassified", "EvaluationError"] },
"description": "Unclassified is a processing result, not a rule outcome (VR-002)."
},
"match": { "$ref": "#/definitions/conditionGroup" },
"tags": { "type": "array", "items": { "type": "string" } },
"owner": { "type": "string" },
"changeReference": { "type": "string" },
"effectiveDate": {
"type": "string",
"format": "date",
"description": "Metadata only in v1. It must not gate evaluation — a date-dependent decision would break determinism (Principle I)."
},
"notes": { "type": "string" },
"testCases": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "expectedMatch"],
"additionalProperties": true,
"properties": {
"name": { "type": "string" },
"expectedMatch": { "type": "boolean" },
"user": { "type": "object" }
}
}
}
}
},
"conditionGroup": {
"type": "object",
"required": ["operator", "conditions"],
"additionalProperties": false,
"properties": {
"operator": { "type": "string", "enum": ["all", "any"] },
"conditions": {
"type": "array",
"minItems": 1,
"items": {
"anyOf": [
{ "$ref": "#/definitions/conditionGroup" },
{ "$ref": "#/definitions/condition" }
]
}
}
}
},
"condition": {
"type": "object",
"required": ["type", "operator"],
"additionalProperties": false,
"properties": {
"type": { "type": "string", "enum": ["property", "membership", "role"] },
"property": { "type": "string", "minLength": 1 },
"operator": {
"type": "string",
"enum": [
"equals", "notEquals", "contains", "notContains",
"startsWith", "endsWith", "matchesRegex",
"in", "notIn", "isNull", "isNotNull",
"memberOf", "notMemberOf"
]
},
"value": { "type": "string" },
"values": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string" }
},
"groupObjectIds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/definitions/guid" }
},
"roleIds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] },
"caseSensitive": {
"type": "boolean",
"default": false,
"description": "Reserved. Condition-level case sensitivity is out of scope for v1; the schema accepts the key so a later version does not require a breaking change."
}
},
"allOf": [
{
"if": { "properties": { "type": { "const": "property" } }, "required": ["type"] },
"then": { "required": ["property"] }
},
{
"if": { "properties": { "type": { "const": "membership" } }, "required": ["type"] },
"then": { "required": ["groupObjectIds"] }
},
{
"if": { "properties": { "type": { "const": "role" } }, "required": ["type"] },
"then": { "required": ["roleIds"] }
},
{
"if": {
"properties": { "operator": { "enum": ["in", "notIn"] } },
"required": ["operator"]
},
"then": { "required": ["values"] }
},
{
"if": {
"properties": { "operator": { "enum": ["isNull", "isNotNull"] } },
"required": ["operator"]
},
"then": {
"allOf": [
{ "not": { "required": ["value"] } },
{ "not": { "required": ["values"] } }
]
}
}
]
},
"guid": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
}
}
}