Phase 6.2 Stream B - Permission-trie evaluator (Core.Authorization) #85
Reference in New Issue
Block a user
Delete Branch "phase-6-2-stream-b-permission-trie"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Ships the data-plane authorization engine Phase 6.2 runs on. Stream C wires it into OPC UA dispatch in a follow-up PR on this branch.
Summary
OpcUaOperationenum (Browse, Read, Write{Operate/Tune/Configure}, HistoryRead, HistoryUpdate, CreateMonitoredItems, TransferSubscriptions, Call, AlarmAck/Confirm/Shelve).Kindselector switches Equipment (UNS) vs SystemPlatform (Galaxy, folder-segment walk).PermissionTrieBuilder.Build: pure additive union walk. Cluster-level grant cascades; equipment-level doesn’t leak to siblings; Galaxy folder-segment ditto; cross-cluster rows filtered.MembershipFreshnessInterval15 min (#151) +AuthCacheMaxStaleness5 min (#152).OpcUaOperationto its requiredNodePermissionsflag.Test plan
dotnet test: 1078 passing (Phase 6.1 = 1042, 6.2A = +9, 6.2B = +27).🤖 Generated with Claude Code
Ships Stream B.1-B.6 — the data-plane authorization engine Phase 6.2 runs on. Integration into OPC UA dispatch (Stream C — Read / Write / HistoryRead / Subscribe / Browse / Call etc.) is the next PR on this branch. New Core.Abstractions: - OpcUaOperation enum enumerates every OPC UA surface the evaluator gates: Browse, Read, WriteOperate/Tune/Configure (split by SecurityClassification), HistoryRead, HistoryUpdate, CreateMonitoredItems, TransferSubscriptions, Call, AlarmAcknowledge/Confirm/Shelve. Stream C maps each one back to its dispatch call site. New Core.Authorization namespace: - NodeScope record + NodeHierarchyKind — 6-level scope addressing for Equipment-kind (UNS) namespaces, folder-segment walk for SystemPlatform-kind (Galaxy). NodeScope carries a Kind selector so the evaluator knows which hierarchy to descend. - AuthorizationDecision { Verdict, Provenance } + AuthorizationVerdict {Allow, NotGranted, Denied} + MatchedGrant. Tri-state per decision #149; Phase 6.2 only produces Allow + NotGranted, Denied stays reserved for v2.1 Explicit Deny without API break. - IPermissionEvaluator.Authorize(session, operation, scope). - PermissionTrie + PermissionTrieNode + TrieGrant. In-memory trie keyed on the ACL scope hierarchy. CollectMatches walks Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag (or → FolderSegment(s) → Tag on Galaxy). Pure additive union — matches that share an LDAP group with the session contribute flags; OR across levels. - PermissionTrieBuilder static factory. Build(clusterId, generationId, rows, scopePaths?) returns a trie for one generation. Cross-cluster rows are filtered out so the trie is cluster-coherent. Stream C follow-up wires a real scopePaths lookup from the live DB; tests supply hand-built paths. - PermissionTrieCache — process-singleton, keyed on (ClusterId, GenerationId). Install(trie) adds a generation + promotes to "current" when the id is highest-known (handles out-of-order installs gracefully). Prior generations retained so an in-flight request against a prior trie still succeeds; GC via Prune(cluster, keepLatest). - UserAuthorizationState — per-session cache of resolved LDAP groups + AuthGenerationId + MembershipVersion + MembershipResolvedUtc. Bounded by MembershipFreshnessInterval (default 15 min per decision #151) + AuthCacheMaxStaleness (default 5 min per decision #152). - TriePermissionEvaluator — default IPermissionEvaluator. Fails closed on stale sessions (IsStale check short-circuits to NotGranted), on cross- cluster requests, on empty trie cache. Maps OpcUaOperation → NodePermissions via MapOperationToPermission (total — every enum value has a mapping; tested). Tests (27 new, all pass): - PermissionTrieTests (7): cluster-level grant cascades to every tag; equipment-level grant doesn't leak to sibling equipment; multi-group union ORs flags; no-matching-group returns empty; Galaxy folder-segment grant doesn't leak to sibling folder; cross-cluster rows don't land in this cluster's trie; build is idempotent (B.6 invariants). - TriePermissionEvaluatorTests (8): allow when flag matches; NotGranted when no matching group; NotGranted when flags insufficient; HistoryRead requires its own bit (decision-level requirement); cross-cluster session denied; stale session fails closed; no cached trie denied; MapOperationToPermission is total across every OpcUaOperation. - PermissionTrieCacheTests (8): empty cache returns null; install-then-get round-trips; new generation becomes current; out-of-order install doesn't downgrade current; invalidate drops one cluster; prune retains most recent; prune no-op when fewer than keep; cluster isolation. - UserAuthorizationStateTests (4): fresh is not stale; IsStale after 5 min default; NeedsRefresh true between freshness + staleness windows. Full solution dotnet test: 1078 passing (baseline 906, Phase 6.1 = 1042, Phase 6.2 Stream A = +9, Stream B = +27 = 1078). Pre-existing Client.CLI Subscribe flake unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>