# OPC UA Client Authorization (ACL Design) — OtOpcUa v2

> **Status**: DRAFT — closes corrections-doc finding B1 (namespace / equipment-subtree ACLs not yet modeled in the data path).
>
> **Branch**: `v2`
> **Created**: 2026-04-17

## Scope

This document defines the **OPC UA client data-path authorization model** — who can read, write, subscribe, browse, ack alarms, etc. on which nodes when connecting to the OtOpcUa server endpoint. It is distinct from:

- **Admin UI authorization** (`admin-ui.md`) — who can edit configuration. That layer has FleetAdmin / ConfigEditor / ReadOnly roles + cluster-scoped grants per decisions #88, #105.
- **DB principal authorization** (`config-db-schema.md` §"Authorization Model") — who can call which stored procedures on the central config DB. That layer is per-NodeId for cluster nodes and per-Admin for Admin app users.

The data-path ACL layer covers OPC UA clients (ScadaBridge, Ignition, System Platform IO, third-party tools) that connect to the OPC UA endpoint to read or modify equipment data.

## Permission Model

Every node operation requires an explicit permission. Permissions are bitmask flags on the v2 schema; the OPC UA NodeManager checks them on every browse, read, write, subscribe, history read, alarm event, and method call.

### Permission flags

```csharp
[Flags]
public enum NodePermissions : uint
{
    None              = 0,

    // Read-side
    Browse            = 1 << 0,   // See node in BrowseRequest results
    Read              = 1 << 1,   // ReadRequest current value
    Subscribe         = 1 << 2,   // CreateMonitoredItems
    HistoryRead       = 1 << 3,   // HistoryReadRaw / HistoryReadProcessed

    // Write-side (mirrors v1 SecurityClassification model — see config-db-schema.md Equipment ACL)
    WriteOperate      = 1 << 4,   // Write attrs with FreeAccess/Operate classification
    WriteTune         = 1 << 5,   // Write attrs with Tune classification
    WriteConfigure    = 1 << 6,   // Write attrs with Configure classification

    // Alarm-side
    AlarmRead         = 1 << 7,   // Receive alarm events for this node
    AlarmAcknowledge  = 1 << 8,   // Ack alarms (separate from Confirm — OPC UA Part 9 distinction)
    AlarmConfirm      = 1 << 9,   // Confirm alarms
    AlarmShelve       = 1 << 10,  // Shelve / unshelve alarms

    // Method invocation (OPC UA Part 4 §5.11)
    MethodCall        = 1 << 11,  // Invoke methods on the node

    // Common bundles (also exposed in Admin UI as one-click selections)
    ReadOnly  = Browse | Read | Subscribe | HistoryRead | AlarmRead,
    Operator  = ReadOnly | WriteOperate | AlarmAcknowledge | AlarmConfirm,
    Engineer  = Operator | WriteTune | AlarmShelve,
    Admin     = Engineer | WriteConfigure | MethodCall,
}
```

The bundles (`ReadOnly` / `Operator` / `Engineer` / `Admin`) are derived from production patterns at sites running v1 LmxOpcUa — they're the common grant shapes operators reach for. Granular per-flag grants stay supported for unusual cases.

### Why three Write tiers (Operate / Tune / Configure)

Mirrors v1's `SecurityClassification` mapping (`docs/DataTypeMapping.md`). Galaxy attributes carry a security classification; v1 maps `FreeAccess`/`Operate` to writable, `SecuredWrite`/`VerifiedWrite`/`ViewOnly` to read-only. The v2 model preserves this for Galaxy and extends it to all drivers via `Tag.SecurityClassification`:

| Classification | Permission needed to write |
|----------------|---------------------------|
| FreeAccess | `WriteOperate` |
| Operate | `WriteOperate` |
| Tune | `WriteTune` |
| Configure | `WriteConfigure` |
| SecuredWrite / VerifiedWrite / ViewOnly | (not writable from OPC UA — v1 behavior preserved) |

A user with `WriteTune` can write Operate-classified attrs too (Tune is more privileged). The check is `requestedClassification ≤ grantedTier`.

### Why AlarmRead is separate from Read

In OPC UA Part 9 alarm subscriptions are a distinct subscription type — a client can subscribe to events on a node without reading its value. Granting Read alone does not let a client see alarm events; AlarmRead is required separately. The `ReadOnly` bundle includes both.

### Why MethodCall is separate

OPC UA methods (Part 4 §5.11) are arbitrary procedure invocations on a node. v1 LmxOpcUa exposes very few; future drivers (especially OPC UA Client gateway) will surface more. MethodCall is gated explicitly because side-effects can be unbounded — analogous to executing a stored procedure rather than reading a column.

## Scope Hierarchy

ACL grants attach to one of six scope levels. Granting at higher level cascades to lower (with browse implication for ancestors); explicit Deny at lower level is **deferred to v2.1** (decision below).

```
Cluster                ← cluster-wide grant (highest scope)
  └── Namespace        ← per-namespace grant (Equipment vs SystemPlatform vs Simulated)
        └── UnsArea    ← per-area grant (Equipment-namespace only)
              └── UnsLine    ← per-line grant
                    └── Equipment    ← per-equipment grant
                          └── Tag    ← per-tag grant (lowest scope; rarely used)
```

For SystemPlatform-namespace tags (no Equipment row, no UNS structure), the chain shortens to:

```
Cluster
  └── Namespace
        └── (Tag's FolderPath segments — treated as opaque hierarchy)
              └── Tag
```

### Inheritance and evaluation

For each operation on a node:

1. Walk the node's scope chain from leaf to root (`Tag → Equipment → UnsLine → UnsArea → Namespace → Cluster`)
2. At each level, look up `NodeAcl` rows where `LdapGroup ∈ user.Groups` and `(ScopeKind, ScopeId)` matches
3. Union the `PermissionFlags` from every matching row
4. Required permission must be set in the union → allow; else → deny
5. Browse is implied at every ancestor of any node where the user has any non-Browse permission — otherwise the user can't navigate to it

### Default-deny

If the union is empty (no group of the user's has any grant matching the node's chain), the operation is **denied**:
- Browse → node hidden from results
- Read / Subscribe / HistoryRead → `BadUserAccessDenied`
- Write → `BadUserAccessDenied`
- AlarmAck / AlarmConfirm / AlarmShelve → `BadUserAccessDenied`
- MethodCall → `BadUserAccessDenied`

### Why no explicit Deny in v2.0

Two patterns can express "X group can write everywhere except production line 3":

- **(a)** Verbose: grant Engineering on every line except line 3 — many rows but unambiguous
- **(b)** Explicit Deny that overrides Grant — fewer rows but evaluation logic must distinguish "no grant" from "explicit deny"

For v2.0 fleets (≤50 clusters, ≤20 lines per cluster typical) approach (a) is workable — operators use the bulk-grant Admin UI flow to apply grants across many lines minus exceptions. Explicit Deny adds non-trivial complexity to the evaluator and the Admin UI; defer to v2.1 unless a deployment demonstrates a real need.

## Schema — `NodeAcl` Table

Generation-versioned (decision #105 pattern — ACLs are content, travel through draft → diff → publish like every other consumer-visible config):

```sql
CREATE TABLE dbo.NodeAcl (
    NodeAclRowId        uniqueidentifier    NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    GenerationId        bigint              NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
    NodeAclId           nvarchar(64)        NOT NULL,           -- stable logical ID across generations
    ClusterId           nvarchar(64)        NOT NULL FOREIGN KEY REFERENCES dbo.ServerCluster(ClusterId),
    LdapGroup           nvarchar(256)       NOT NULL,           -- LDAP group name (e.g. "OtOpcUaOperators-LINE3")
    ScopeKind           nvarchar(16)        NOT NULL CHECK (ScopeKind IN ('Cluster', 'Namespace', 'UnsArea', 'UnsLine', 'Equipment', 'Tag')),
    ScopeId             nvarchar(64)        NULL,               -- NULL when ScopeKind='Cluster'; otherwise the logical ID of the scoped entity
    PermissionFlags     int                 NOT NULL,           -- bitmask of NodePermissions
    Notes               nvarchar(512)       NULL
);

CREATE INDEX IX_NodeAcl_Generation_Cluster
    ON dbo.NodeAcl (GenerationId, ClusterId);
CREATE INDEX IX_NodeAcl_Generation_Group
    ON dbo.NodeAcl (GenerationId, LdapGroup);
CREATE INDEX IX_NodeAcl_Generation_Scope
    ON dbo.NodeAcl (GenerationId, ScopeKind, ScopeId) WHERE ScopeId IS NOT NULL;
CREATE UNIQUE INDEX UX_NodeAcl_Generation_LogicalId
    ON dbo.NodeAcl (GenerationId, NodeAclId);
-- Within a generation, a (Group, Scope) pair has at most one row (additive grants would be confusing
-- in the audit trail; use a single row with the union of intended permissions instead)
CREATE UNIQUE INDEX UX_NodeAcl_Generation_GroupScope
    ON dbo.NodeAcl (GenerationId, ClusterId, LdapGroup, ScopeKind, ScopeId);
```

### Cross-generation invariant

Same pattern as Equipment / Namespace: `NodeAclId` is append-only per cluster — once published, the logical ID stays bound to its `(LdapGroup, ScopeKind, ScopeId)` triple. Renaming an LDAP group is forbidden — disable the old grant and create a new one. This protects the audit trail.

### Validation in `sp_ValidateDraft`

Adds these checks beyond the existing schema rules:

- **ScopeId resolution**: when `ScopeKind ∈ {Namespace, UnsArea, UnsLine, Equipment, Tag}`, `ScopeId` must resolve to the corresponding entity in the same generation
- **Cluster cohesion**: the resolved scope must belong to the same `ClusterId` as the ACL row
- **PermissionFlags validity**: bitmask must only contain bits defined in `NodePermissions` enum (no future-bit speculation)
- **LdapGroup format**: non-empty, ≤256 chars, no characters that would break LDAP DN escaping (allowlist)
- **No identity drift**: `NodeAclId` once published with `(LdapGroup, ScopeKind, ScopeId)` cannot have any of those four columns change in a future generation

## Evaluation Algorithm

### At session establishment

```
on AcceptSession(user):
    user.Groups = LdapAuth.ResolveGroups(user.Token)
    user.PermissionMap = BuildEffectivePermissionMap(currentGeneration, user.Groups)
    cache user.PermissionMap on the session
```

`BuildEffectivePermissionMap` produces a sparse trie keyed by node-path-prefix:

```
PermissionMap structure (per session):
  /                                                        → grant union from Cluster + Namespace-level rows
  /Equipment-NS/UnsArea-A/                                 → adds UnsArea-level grants
  /Equipment-NS/UnsArea-A/UnsLine-1/                       → adds UnsLine-level grants
  /Equipment-NS/UnsArea-A/UnsLine-1/Equipment-X/           → adds Equipment-level grants
  /Equipment-NS/UnsArea-A/UnsLine-1/Equipment-X/Tag-Y      → adds Tag-level grants (rare)
```

Lookup for a node at path P: walk the trie from `/` to P, OR-ing PermissionFlags at each visited level. Result = effective permissions for P. O(depth) — typically 6 or fewer hops.

### Per-operation check

```csharp
bool Authorize(SessionContext ctx, NodePath path, NodePermissions required)
{
    var effective = ctx.PermissionMap.Lookup(path);
    return (effective & required) == required;
}
```

- Browse: `Authorize(ctx, path, Browse)` — falsy → omit from results
- Read: `Authorize(ctx, path, Read)` → falsy → `BadUserAccessDenied`
- Write: `Authorize(ctx, path, requiredWriteFlag)` where `requiredWriteFlag` is derived from the target attribute's `SecurityClassification`
- Subscribe: `Authorize(ctx, path, Subscribe)` — also implies Browse on the path
- HistoryRead: `Authorize(ctx, path, HistoryRead)`
- Alarm event: `Authorize(ctx, path, AlarmRead)` — events for unauthorized nodes are filtered out before delivery
- AlarmAck/Confirm/Shelve: corresponding flag check
- MethodCall: `Authorize(ctx, methodNode.path, MethodCall)`

### Cache invalidation

The session's `PermissionMap` is rebuilt when:
- A new config generation is applied locally (the path-trie may have changed structure due to UNS reorg or new equipment)
- The LDAP group cache for the user expires (default: 15 min — driven by the LDAP layer, separate from this design)
- The user's session is re-established

For unattended consumer connections (ScadaBridge, Ignition) that hold long sessions, the per-generation rebuild keeps permissions current without forcing reconnects.

## Performance

Worst-case per-operation cost: O(depth × group-count). For typical fleet sizes (10 LDAP groups per user, 6-deep UNS path), that's ~60 trie lookups per operation — sub-microsecond on modern hardware. The session-scoped cache means the per-operation hot path is array indexing, not DB queries.

Build cost (at session establish or generation reapply): O(N_acl × M_groups) for N_acl rows and M_groups in user's claim set. For 1000 ACL rows × 10 groups = 10k joins; sub-second on a sane DB.

Memory cost: per-session trie ~4 KB for typical scopes; bounded by O(N_acl) worst case. Sessions hold their own trie — no shared state to invalidate.

## Default Permissions for Existing v1 LDAP Groups

To preserve v1 LmxOpcUa behavior on first migration, the v2 default ACL set on cluster creation maps the existing v1 LDAP-role-to-permission grants:

| v1 LDAP role (per `Security.md`) | v2 NodePermissions bundle | Scope |
|----------------------------------|---------------------------|-------|
| `ReadOnly` (group: `OtOpcUaReadOnly`) | `ReadOnly` bundle | Cluster |
| `WriteOperate` (group: `OtOpcUaWriteOperate`) | `Operator` bundle | Cluster |
| `WriteTune` (group: `OtOpcUaWriteTune`) | `Engineer` bundle | Cluster |
| `WriteConfigure` (group: `OtOpcUaWriteConfigure`) | `Admin` bundle | Cluster |
| `AlarmAck` (group: `OtOpcUaAlarmAck`) | adds `AlarmAcknowledge \| AlarmConfirm` to user's existing grants | Cluster |

These are seeded by the cluster-create workflow into the initial draft generation (per decision #123 — namespaces and ACLs both travel through publish boundary). Operators can then refine to per-Equipment scopes as needed.

## Admin UI

### New tab: ACLs (under Cluster Detail)

```
/clusters/{ClusterId}            Cluster detail (tabs: Overview / Namespaces / UNS Structure / Drivers / Devices / Equipment / Tags / **ACLs** / Generations / Audit)
```

Two views, toggle at the top:

#### View 1 — By LDAP group

| LDAP Group | Scopes | Permissions | Notes |
|------------|--------|-------------|-------|
| `OtOpcUaOperators` | Cluster | Operator bundle | Default operators (seeded) |
| `OtOpcUaOperators-LINE3` | UnsArea bldg-3 | Engineer bundle | Line 3 supervisors |
| `OtOpcUaScadaBridge` | Cluster | ReadOnly | Tier 1 consumer (added before cutover) |

Click a row → edit grant: change scope, change permission set (one-click bundles or per-flag), edit notes.

#### View 2 — By scope (UNS tree)

Tree view of UnsArea → UnsLine → Equipment with permission badges per node showing which groups have what:

```
bldg-3/                                  [Operators: Operator, ScadaBridge: ReadOnly]
  ├── line-2/                            [+ LINE3-Supervisors: Engineer]
  │     ├── cnc-mill-05                  [+ CNC-Maintenance: WriteTune]
  │     ├── cnc-mill-06
  │     └── injection-molder-02
  └── line-3/
```

Click a node → see effective permissions per group, edit grants at that scope.

### Bulk grant flow

"Bulk grant" button on either view:
1. Pick LDAP group(s)
2. Pick permission bundle or per-flag
3. Pick scope set: pattern (e.g. all UnsArea matching `bldg-*`), or multi-select from tree
4. Preview: list of `NodeAcl` rows that will be created
5. Confirm → adds rows to current draft

### Permission simulator

"Simulate as user" panel: enter username + LDAP groups → UI shows the effective permission map across the cluster's UNS tree. Useful before publishing — operators verify "after this change, ScadaBridge can still read everything it needs" without actually deploying.

### Operator workflows added to admin-ui.md

Three new workflows:
1. **Grant ACL** — usual draft → diff → publish, scoped to ACLs tab
2. **Bulk grant** — multi-select scope + group + permission, preview, publish
3. **Simulate as user** — preview-only, no publish required

### v1 deviation log

For each cluster, the Admin UI shows a banner if its NodeAcl set diverges from the v1-default seed (per the table above). This makes intentional tightening or loosening visible at a glance — important for compliance review during the long v1 → v2 coexistence period.

## Audit

Every NodeAcl change is in `ConfigAuditLog` automatically (per the publish boundary — same as any other content edit). Plus the OPC UA NodeManager logs every **denied** operation:

```
EventType = 'OpcUaAccessDenied'
DetailsJson = { user, groups, requestedOperation, nodePath, requiredPermission, effectivePermissions }
```

Allowed operations are NOT logged at this layer (would dwarf the audit log; OPC UA SDK has its own session/operation diagnostics for high-frequency telemetry). The choice to log denials only mirrors typical authorization-audit practice and can be tightened per-deployment if a customer requires full positive-action logging.

## Test Strategy

Unit tests for the evaluator:
- Empty ACL set → all operations denied (default-deny invariant)
- Single Cluster-scope grant → operation allowed at every node in the cluster
- Single Equipment-scope grant → allowed at the equipment + its tags; denied at sibling equipment
- Multiple grants for same group → union (additive)
- Multiple groups for same user → union of all groups' grants
- Browse implication: granting Read on a deep equipment auto-allows Browse at every ancestor
- Permission bundle expansion: granting `Operator` bundle = granting `Browse | Read | Subscribe | HistoryRead | AlarmRead | WriteOperate | AlarmAcknowledge | AlarmConfirm`
- v1-compatibility seed: a fresh cluster with the default ACL set behaves identically to v1 LmxOpcUa for users in the v1 LDAP groups

Integration test (Phase 1+):
- Create cluster + equipment + tags + ACL grants
- Connect OPC UA client as a `ReadOnly`-mapped user → browse and read succeed; write fails
- Re-publish with a tighter ACL → existing session's permission map rebuilds; subsequent writes that were allowed are now denied
- Verify `OpcUaAccessDenied` audit log entries for the denied operations

Adversarial review checks (run during exit gate):
- Can a client connect with no LDAP group at all and read anything? (must be no — default deny)
- Can a client see a node in browse but not read its value? (yes, if Browse granted but not Read — unusual but valid)
- Does a UnsArea rename cascade ACL grants correctly? (the grant references UnsAreaId not name, so rename is transparent)
- Does an Equipment merge (Admin operator flow) preserve ACL grants on the surviving equipment? (must yes; merge flow updates references)
- Does generation rollback restore the prior ACL state? (must yes; ACLs are generation-versioned)

## Implementation Plan

ACL design enters the implementation pipeline as follows:

### Phase 1 (Configuration + Admin scaffold)
- Schema: add `NodeAcl` table to the Phase 1 migration
- Validation: add NodeAcl rules to `sp_ValidateDraft`
- Admin UI: scaffold the ACLs tab with view + edit + bulk grant + simulator
- Default seed: cluster-create workflow seeds the v1-compatibility ACL set
- Generation diff: include NodeAcl in `sp_ComputeGenerationDiff`

### Phase 2+ (every driver phase)
- Wire the ACL evaluator into `GenericDriverNodeManager` so every Browse / Read / Write / Subscribe / HistoryRead / AlarmRead / AlarmAck / MethodCall consults the per-session permission map
- Per-driver tests: assert that a default-deny user cannot read or subscribe to that driver's namespace; assert that a `ReadOnly`-bundled user can; assert that the appropriate Write tier is needed for each `SecurityClassification`

### Pre-tier-1-cutover (before Phase 6 / consumer cutover)
- Verify ScadaBridge's effective permissions in the Admin UI simulator before any cutover
- Adversarial review of the per-cluster ACL set with a fresh pair of eyes

## Decisions to Add to plan.md

(Will be appended to the decision log on the next plan.md edit.)

| # | Decision | Rationale |
|---|----------|-----------|
| 129 | OPC UA client data-path authorization model = bitmask `NodePermissions` flags + per-LDAP-group grants on a 6-level scope hierarchy (Cluster / Namespace / UnsArea / UnsLine / Equipment / Tag) | Closes corrections-doc finding B1. Mirrors v1 SecurityClassification model for Write tiers; adds explicit AlarmRead/Ack/Confirm/Shelve and MethodCall flags. Default-deny; additive grants; explicit Deny deferred to v2.1. See `acl-design.md` |
| 130 | `NodeAcl` table generation-versioned, edited via draft → diff → publish like every other content table | Same pattern as Namespace (decision #123) and Equipment (decision #109). ACL changes are content, not topology — they affect what consumers see at the OPC UA endpoint. Rollback restores the prior ACL state |
| 131 | Cluster-create workflow seeds default ACL set matching v1 LmxOpcUa LDAP-role-to-permission map | Preserves behavioral parity for v1 → v2 consumer migration. Operators tighten or loosen from there. Admin UI flags any cluster whose ACL set diverges from the seed |
| 132 | OPC UA NodeManager logs denied operations only; allowed operations rely on SDK session/operation diagnostics | Logging every allowed op would dwarf the audit log. Denied-only mirrors typical authorization audit practice. Per-deployment policy can tighten if compliance requires positive-action logging |

## Open Questions

- **OPC UA Method support scope**: how many methods does v1 expose? Need to enumerate before tier 3 cutover (System Platform IO is the most likely consumer of methods). The MethodCall permission is defined defensively but may not be exercised in v2.0.
- **Group claim source latency**: LDAP group cache TTL (default 15 min above) is taken from the v1 LDAP layer. If the OPC UA session's group claims need to be refreshed faster (e.g. for emergency revoke), we need a shorter TTL or an explicit revoke channel. Decide per operational risk appetite.
- **AlarmConfirm vs AlarmAcknowledge** semantics: OPC UA Part 9 distinguishes them (Ack = "I've seen this"; Confirm = "I've taken action"). Some sites only use Ack; the v2.0 model exposes both but a deployment-level policy can collapse them in practice.
