Per-API-key scoped permissions (subtree, glob, classification) #103

Closed
opened 2026-04-29 12:51:53 -04:00 by dohertj2 · 2 comments
Owner

Motivation

Today's authorization model (docs/Authorization.md, GatewayScopes) is flat and verb-only: an API key has scopes like invoke:read, invoke:write, invoke:secure, metadata:read, events:read. The interceptor checks scope membership before the service body runs, never inspects the tag address inside the command, the gobject id in a browse request, or any attribute metadata.

That's correct for a fully-trusted single-tenant client. It's not enough once multiple consumers (an OtOpcUa server, a future Historian replacement, a third-party diagnostic tool, an integration harness) share one gw — each should be limited to its own slice of the Galaxy.

Use cases:

  • An Admin UI key that may browse and read but never write.
  • A read-only diagnostic key for a specific Area (Area1/*) only.
  • A historian-replacement key that may read all is_historized=true attributes but write nothing.
  • A vendor integration key restricted to a tag-name pattern (OperatorTags.*).
  • A key that may only write attributes whose security_classification <= Operate.

Proposed model

Keep verb scopes (invoke:read/invoke:write/invoke:secure/metadata:read/events:read) — that gating happens first, fail-closed, in the interceptor as today.

Add an optional constraint set stored alongside ApiKeyIdentity.Scopes and applied at the service layer (after the interceptor, before the worker call):

ApiKeyIdentity.Constraints (optional, all empty = unconstrained):
  read_subtrees:        list<string>   // contained_path globs, e.g. ["Area1/*", "Line3/Sub*"]
  write_subtrees:       list<string>   // same shape; applies to Write/Write2/WriteSecured*
  read_tag_globs:       list<string>   // tag-name globs (alternative to subtree)
  write_tag_globs:      list<string>
  max_write_classification: int        // SecurityClassification ceiling (write blocked if attr's classification > ceiling)
  read_alarm_only:      bool           // restricts reads to is_alarm=true attrs
  read_historized_only: bool           // restricts reads to is_historized=true attrs
  browse_subtrees:      list<string>   // DiscoverHierarchy result is filtered server-side to this set

Matching semantics:

  • All globs are case-insensitive, anchored, support */?.
  • A request matching a *_subtrees entry OR a *_tag_globs entry passes that constraint.
  • Empty list = no constraint of that kind (allow).
  • browse_subtrees filters the result rather than rejecting the call.
  • A single tag failing constraint inside a bulk command (SubscribeBulk, AddItemBulk) returns per-tag PermissionDenied in the result list, not a whole-call fail.

Where the check lives

  • Interceptor stays unchanged — verb-scope check, fail-closed.
  • New IConstraintEnforcer invoked from the service body for: AddItem, AddItem2, AddItemBulk, SubscribeBulk, AdviseItemBulk, Write, Write2, WriteSecured, WriteSecured2. Resolves the target tag's gobject_id + contained_path + security_classification against the cached hierarchy (already in memory), then applies the constraint set.
  • GalaxyRepository.DiscoverHierarchy and WatchDeployEvents filter results to browse_subtrees. Depends on the subtree/filter mechanism from the companion issue.
  • Constraint failures return per-tag PermissionDenied (or whole-call for non-bulk). Distinct from Unauthenticated (no key) and the interceptor's verb-scope PermissionDenied. The status detail names which constraint blocked the call.

Storage and key minting

  • Extend the SQLite key store with a constraints JSON column.
  • mxgw-key CLI grows flags: --read-subtree, --write-subtree, --read-tag-glob, --write-tag-glob, --max-write-classification, --browse-subtree, --read-alarm-only, --read-historized-only. All optional, all repeatable.
  • Existing keys (no constraints column populated) behave exactly as today — fully unconstrained.

Acceptance

  • ApiKeyIdentity carries a Constraints record; key store schema migrated additively.
  • IConstraintEnforcer invoked from the documented service-side hooks.
  • DiscoverHierarchy and WatchDeployEvents filter results when browse_subtrees is set.
  • Bulk commands return per-tag PermissionDenied for constraint violations; non-bulk commands return whole-call.
  • mxgw-key CLI accepts every constraint flag listed above.
  • Tests: each constraint type in isolation, combinations, bulk vs. non-bulk denial shape, an unconstrained key keeps full access, audit log entry per denial naming the offending constraint.
  • docs/Authorization.md rewritten to cover the verb-scope + constraint two-layer model.
  • Dashboard view shows each key's constraint set (read-only).

Dependencies

  • Companion issue: server-side DiscoverHierarchy subtree+filter — browse_subtrees reuses that pipeline.
  • Hierarchy cache must expose a gobject_id → contained_path lookup synchronous to the request thread (already materialized).

Out of scope

  • Per-attribute (vs. per-object) glob filtering beyond the is_alarm/is_historized/max_write_classification toggles. Can be added later.
  • Time-bounded keys, rate limits, IP scoping. Separate features.

Source

Surfaced during lmxopcua Galaxy → MxGateway migration planning. OtOpcUa enforces user-level ACLs at its own server layer, so for OtOpcUa specifically the flat-scope key it currently mints is sufficient — this feature is for other gw consumers that would benefit from blast-radius limits.

## Motivation Today's authorization model (`docs/Authorization.md`, `GatewayScopes`) is **flat and verb-only**: an API key has scopes like `invoke:read`, `invoke:write`, `invoke:secure`, `metadata:read`, `events:read`. The interceptor checks scope membership before the service body runs, never inspects the tag address inside the command, the gobject id in a browse request, or any attribute metadata. That's correct for a fully-trusted single-tenant client. It's not enough once multiple consumers (an OtOpcUa server, a future Historian replacement, a third-party diagnostic tool, an integration harness) share one gw — each should be limited to its own slice of the Galaxy. Use cases: - An Admin UI key that may browse and read but never write. - A read-only diagnostic key for a specific Area (`Area1/*`) only. - A historian-replacement key that may read all `is_historized=true` attributes but write nothing. - A vendor integration key restricted to a tag-name pattern (`OperatorTags.*`). - A key that may only write attributes whose `security_classification <= Operate`. ## Proposed model Keep verb scopes (`invoke:read`/`invoke:write`/`invoke:secure`/`metadata:read`/`events:read`) — that gating happens first, fail-closed, in the interceptor as today. Add an optional **constraint set** stored alongside `ApiKeyIdentity.Scopes` and applied at the service layer (after the interceptor, before the worker call): ```text ApiKeyIdentity.Constraints (optional, all empty = unconstrained): read_subtrees: list<string> // contained_path globs, e.g. ["Area1/*", "Line3/Sub*"] write_subtrees: list<string> // same shape; applies to Write/Write2/WriteSecured* read_tag_globs: list<string> // tag-name globs (alternative to subtree) write_tag_globs: list<string> max_write_classification: int // SecurityClassification ceiling (write blocked if attr's classification > ceiling) read_alarm_only: bool // restricts reads to is_alarm=true attrs read_historized_only: bool // restricts reads to is_historized=true attrs browse_subtrees: list<string> // DiscoverHierarchy result is filtered server-side to this set ``` Matching semantics: - All globs are case-insensitive, anchored, support `*`/`?`. - A request matching a `*_subtrees` entry OR a `*_tag_globs` entry passes that constraint. - Empty list = no constraint of that kind (allow). - `browse_subtrees` *filters* the result rather than rejecting the call. - A single tag failing constraint inside a bulk command (`SubscribeBulk`, `AddItemBulk`) returns per-tag `PermissionDenied` in the result list, not a whole-call fail. ## Where the check lives - **Interceptor stays unchanged** — verb-scope check, fail-closed. - New `IConstraintEnforcer` invoked from the service body for: `AddItem`, `AddItem2`, `AddItemBulk`, `SubscribeBulk`, `AdviseItemBulk`, `Write`, `Write2`, `WriteSecured`, `WriteSecured2`. Resolves the target tag's `gobject_id` + `contained_path` + `security_classification` against the cached hierarchy (already in memory), then applies the constraint set. - `GalaxyRepository.DiscoverHierarchy` and `WatchDeployEvents` filter results to `browse_subtrees`. Depends on the subtree/filter mechanism from the companion issue. - Constraint failures return per-tag `PermissionDenied` (or whole-call for non-bulk). Distinct from `Unauthenticated` (no key) and the interceptor's verb-scope `PermissionDenied`. The status detail names which constraint blocked the call. ## Storage and key minting - Extend the SQLite key store with a `constraints` JSON column. - `mxgw-key` CLI grows flags: `--read-subtree`, `--write-subtree`, `--read-tag-glob`, `--write-tag-glob`, `--max-write-classification`, `--browse-subtree`, `--read-alarm-only`, `--read-historized-only`. All optional, all repeatable. - Existing keys (no constraints column populated) behave exactly as today — fully unconstrained. ## Acceptance - [ ] `ApiKeyIdentity` carries a `Constraints` record; key store schema migrated additively. - [ ] `IConstraintEnforcer` invoked from the documented service-side hooks. - [ ] `DiscoverHierarchy` and `WatchDeployEvents` filter results when `browse_subtrees` is set. - [ ] Bulk commands return per-tag `PermissionDenied` for constraint violations; non-bulk commands return whole-call. - [ ] `mxgw-key` CLI accepts every constraint flag listed above. - [ ] Tests: each constraint type in isolation, combinations, bulk vs. non-bulk denial shape, an unconstrained key keeps full access, audit log entry per denial naming the offending constraint. - [ ] `docs/Authorization.md` rewritten to cover the verb-scope + constraint two-layer model. - [ ] Dashboard view shows each key's constraint set (read-only). ## Dependencies - Companion issue: server-side `DiscoverHierarchy` subtree+filter — `browse_subtrees` reuses that pipeline. - Hierarchy cache must expose a `gobject_id → contained_path` lookup synchronous to the request thread (already materialized). ## Out of scope - Per-attribute (vs. per-object) glob filtering beyond the `is_alarm`/`is_historized`/`max_write_classification` toggles. Can be added later. - Time-bounded keys, rate limits, IP scoping. Separate features. ## Source Surfaced during `lmxopcua` Galaxy → MxGateway migration planning. OtOpcUa enforces user-level ACLs at its own server layer, so for OtOpcUa specifically the flat-scope key it currently mints is sufficient — this feature is for *other* gw consumers that would benefit from blast-radius limits.
dohertj2 added the area:gatewayarea:authtype:featurepriority:p2 labels 2026-04-29 12:51:53 -04:00
Author
Owner

Depends on #102browse_subtrees is implemented by reusing the subtree+filter mechanism added there.

Depends on #102 � `browse_subtrees` is implemented by reusing the subtree+filter mechanism added there.
Author
Owner

Implemented in b995c17 (codex/fix-runtime-review-findings).

Verification passed:

  • dotnet build src\MxGateway.Contracts\MxGateway.Contracts.csproj
  • scripts\publish-client-proto-inputs.ps1
  • clients\go\generate-proto.ps1
  • clients\python\generate-proto.ps1
  • gradle :mxgateway-client:generateProto
  • dotnet test src\MxGateway.sln --no-restore
  • dotnet test clients\dotnet\MxGateway.Client.sln --no-restore
  • go test ./...
  • python -m pytest
  • cargo fmt --all --check
  • cargo test --workspace
  • gradle test
  • git diff --check
Implemented in b995c17 (`codex/fix-runtime-review-findings`). Verification passed: - `dotnet build src\MxGateway.Contracts\MxGateway.Contracts.csproj` - `scripts\publish-client-proto-inputs.ps1` - `clients\go\generate-proto.ps1` - `clients\python\generate-proto.ps1` - `gradle :mxgateway-client:generateProto` - `dotnet test src\MxGateway.sln --no-restore` - `dotnet test clients\dotnet\MxGateway.Client.sln --no-restore` - `go test ./...` - `python -m pytest` - `cargo fmt --all --check` - `cargo test --workspace` - `gradle test` - `git diff --check`
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/mxaccessgw#103