Files
UA-.NETStandard/docs/RoleBasedUserManagement.md
T
romanett 1af191ec26 Drop the .Common suffix from the three GDS projects (#4438)
# Description

Every other companion specification in the stack ships as `<Family>`,
`<Family>.Client` and `<Family>.Server` — `Opc.Ua.Di`, `Opc.Ua.ISA95`,
`Opc.Ua.Positioning`, `Opc.Ua.Robotics`, `Opc.Ua.Vision`,
`Opc.Ua.WotCon`, `Opc.Ua.XRegistry`, and `Opc.Ua.Lds.Server` without a
family package. GDS was the one exception, carrying a `.Common` suffix
that named nothing.

| Before | After |
| --- | --- |
| `src/Opc.Ua.Gds.Common` | `src/Opc.Ua.Gds` |
| `src/Opc.Ua.Gds.Client.Common` | `src/Opc.Ua.Gds.Client` |
| `src/Opc.Ua.Gds.Server.Common` | `src/Opc.Ua.Gds.Server` |

## No source changes were needed

`RootNamespace` was already the final name in all three projects — the
namespaces have always been `Opc.Ua.Gds`, `Opc.Ua.Gds.Client` and
`Opc.Ua.Gds.Server`, and only `AssemblyName` and `PackageId` disagreed.
Nothing that compiles against these assemblies changes a `using`
directive; the diff outside the moves is entirely ids, paths and prose.

Directories were moved with `git mv`, and every one of the 73 file moves
is detected as a rename at 100% similarity (bar the handful that also
had a name in their text), so `git log --follow` and blame survive.

## Consumer impact

The package ids change, which is visible to anyone consuming the NuGet
packages:

| 1.5.378 package | 2.0 package |
| --- | --- |
| `OPCFoundation.NetStandard.Opc.Ua.Gds.Common` |
`OPCFoundation.NetStandard.Opc.Ua.Gds` |
| `OPCFoundation.NetStandard.Opc.Ua.Gds.Client.Common` |
`OPCFoundation.NetStandard.Opc.Ua.Gds.Client` |
| `OPCFoundation.NetStandard.Opc.Ua.Gds.Server.Common` |
`OPCFoundation.NetStandard.Opc.Ua.Gds.Server` |

`docs/migrate/2.0.x/packages.md` gains a renamed-packages section — it
is the document that undertakes to cover NuGet renames, and had no GDS
entry — and the migration skill's package table gains the two rows a
1.5.378 consumer needs. That is the only hand-written content in this
PR; everything else is mechanical.

Assembly names follow the package ids, so the section also notes that
binding redirects, trimming descriptors and signing manifests naming the
files directly have to be updated.

## Build infrastructure

Everything naming the projects or their output moves with them:

- `UA.slnx` and `tools/MigrationAnalyzer.slnx` (both re-sorted — the
project lists are alphabetical, and `Gds` now sorts before
`Gds.Client`).
- `.azurepipelines/expected-packages.txt` — 3 ids, also re-sorted for
the same reason.
- `.azurepipelines/signlistDebug.txt` and `signlistRelease.txt` — 18
paths each. Their internal ordering is left alone: that group was
already unsorted, and reordering a signing manifest is diff noise.
- `samples/Reference/ConsoleReferenceServer/Dockerfile`, the two
metapackage nuspecs, the analyzer nuspec's package dependencies, and 8
`.csproj` references across tests, samples and tools.

`preview-pack.slnx` is generated from `UA.slnx` by `generate-slnx.ps1`,
so there is nothing static to edit. Nothing under `.github/workflows`,
and no `.ps1`/`.yml` in `.azurepipelines`, names these projects.

The migration analyzer's shim tree mirrors the source project layout by
its own documented convention, so `Gds.Client.Common/` becomes
`Gds.Client/` and its readme table follows.

## Deliberately not changed

Prose naming past work: `plans/23` still records that its deferral
happened during the "GDS Client.Common modernization", which is the name
that effort had. File *paths* in the same document were updated.

## Validation

`net10.0`:

- `UA.slnx`: 0 errors. 14 warnings, all pre-existing
CA1861/CA1850/CA1307 in `XRegistry`, `Types.Tests` and `WotCon.Tests` —
none in anything this PR touches.
- Assemblies build as `Opc.Ua.Gds.dll`, `Opc.Ua.Gds.Client.dll`,
`Opc.Ua.Gds.Server.dll`, with no `*.Common.dll` produced anywhere.
- `Opc.Ua.Gds.Tests`: 1,116 passed, 0 failed, 54 skipped.
- `Opc.Ua.MigrationAnalyzer.Core.Tests`: 10 passed, 3 skipped.
- A repo-wide grep for the old names returns only the intentional old →
new mappings in the migration docs.

## Checklist

- [x] Existing tests pass
- [x] No new tests — this is a rename with no behaviour change
- [x] Consumer-visible package rename documented in the migration guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 20:08:54 +02:00

12 KiB

Role-Based Security (OPC UA Part 18)

Overview

The OPC UA .NET Standard Stack implements the role-based security model from OPC UA Part 18: Role-Based Security (v1.05.06) together with the well-known roles defined in OPC UA Part 3 4.9.2.

The nine well-known roles are pre-registered at server start:

Role NodeId Reserved? Default identities
Anonymous i=15644 Yes Anonymous, AuthenticatedUser
AuthenticatedUser i=15656 Yes AuthenticatedUser
TrustedApplication i=18625 Yes TrustedApplication
Observer i=15668 No (empty)
Operator i=15680 No (empty)
Engineer i=16036 No (empty)
Supervisor i=15692 No (empty)
ConfigureAdmin i=15716 No (empty)
SecurityAdmin i=15704 No (empty)

Reserved roles cannot be modified or removed per Part 18 4.3 - any attempt to call AddIdentity, AddApplication, AddEndpoint, RemoveIdentity, RemoveApplication, RemoveEndpoint, or RemoveRole on them returns Bad_RequestNotAllowed.

See src/Opc.Ua.Server/RoleBasedUserManagement/ for the source.

Server side

IRoleManager

The IRoleManager interface (Opc.Ua.Server.IRoleManager) is the server-side extensibility surface. The default implementation Opc.Ua.Server.RoleManager is fully in-memory; integrators that need persistence implement IRoleManager directly and inject the instance via IServerInternal.SetRoleManager before the server starts.

Every mutator returns a ServiceResult that mirrors the spec-defined status codes:

  • AddIdentity (4.4.5) - Good or Bad_InvalidArgument / Bad_AlreadyExists / Bad_RequestNotAllowed / Bad_NodeIdUnknown.
  • RemoveIdentity (4.4.6) - Good or Bad_NotFound / Bad_RequestNotAllowed / Bad_NodeIdUnknown.
  • AddApplication (4.4.7) - Good or Bad_InvalidArgument / Bad_AlreadyExists / Bad_RequestNotAllowed / Bad_NodeIdUnknown.
  • RemoveApplication (4.4.8) - Good or Bad_NotFound / Bad_RequestNotAllowed / Bad_NodeIdUnknown.
  • AddEndpoint (4.4.9) - Good or Bad_InvalidArgument / Bad_AlreadyExists / Bad_RequestNotAllowed / Bad_NodeIdUnknown.
  • RemoveEndpoint (4.4.10) - Good or Bad_NotFound / Bad_RequestNotAllowed / Bad_NodeIdUnknown.
  • AddRole (4.2.2) - Good or Bad_InvalidArgument / Bad_AlreadyExists.
  • RemoveRole (4.2.3) - Good or Bad_NodeIdUnknown / Bad_RequestNotAllowed.
  • SetApplicationsExclude / SetEndpointsExclude / SetCustomConfiguration - Good or Bad_RequestNotAllowed / Bad_NodeIdUnknown.

IdentityMappingRuleType is validated per Part 18 4.4.3 before storage:

  • Anonymous, AuthenticatedUser, TrustedApplication - criteria must be empty.
  • Thumbprint - upper-case hexadecimal, no spaces.
  • X509Subject - Name=Value(/Name=Value)* grammar with names from Part 18 Table 10.

EndpointType comparisons in identity resolution honour Part 18 4.4.2: fields set to their default value on the rule side act as wildcards. A rule may therefore constrain any subset of endpoint fields by assigning non-default values to those fields. For example, this rule applies to every endpoint URL that uses SignAndEncrypt:

IRoleManager roles = server.CurrentInstance.RoleManager;

roles.AddEndpoint(
    ObjectIds.WellKnownRole_Engineer,
    new EndpointType
    {
        SecurityMode = MessageSecurityMode.SignAndEncrypt
    });

At least one field must be non-default; AddEndpoint returns Bad_InvalidArgument for a null or completely default EndpointType.

Identity-claim criteria support:

  • IdentityCriteriaType.GroupId probes the returned IUserIdentity for Opc.Ua.Identity.IIdentityClaims (see Identity Providers) and matches IIdentityClaims.Groups.
  • IdentityCriteriaType.Role matches roles asserted inside the access token via IIdentityClaims.Roles, optionally prefixed by the issuer URI as iss/roleName, per Part 18 4.4.4.

Authoring an identity-mapping rule with claims

JWT authenticators that return an identity implementing IIdentityClaims allow Part 18 rules to match Entra-style claims. The in-box RoleManager treats entries in a role's Identities collection as OR criteria, per the standard IdentityMappingRuleType[] shape. Add one entry for each accepted claim value:

IRoleManager roles = server.CurrentInstance.RoleManager;

roles.AddIdentity(
    ObjectIds.WellKnownRole_Engineer,
    new IdentityMappingRuleType
    {
        CriteriaType = IdentityCriteriaType.Role,
        Criteria = "https://login.microsoftonline.com/{tenant}/v2.0/OpcUa.Engineer"
    });

roles.AddIdentity(
    ObjectIds.WellKnownRole_Engineer,
    new IdentityMappingRuleType
    {
        CriteriaType = IdentityCriteriaType.GroupId,
        Criteria = "00000000-1111-2222-3333-444444444444"
    });

The first rule matches an access-token role claim OpcUa.Engineer only when IIdentityClaims.Issuer is the Entra issuer. The second matches a groups claim containing the Entra group object id. If your policy must require both a role and a group simultaneously, implement a custom IRoleManager (or a custom authorization layer) because the shipped RoleConfigurationOptions DTO does not define an AND-rule collection.

RoleConfigurationChanged event

IRoleManager.RoleConfigurationChanged fires after every successful mutation with the role NodeId and a RoleConfigurationChangeKind. Integrators can subscribe to:

  • materialize dynamic-role address-space nodes when RoleAdded fires (the default in-memory RoleManager does not create address-space nodes for AddRole - the well-known roles are already in the standard nodeset);
  • re-evaluate active sessions per Part 18 4.4.1 ("If the configuration of a Role is changed, the Role assignment to active Session shall be re-evaluated and applied");
  • emit audit events beyond the spec-defined RoleMappingRuleChangedAuditEvent.

Typed-proxy address-space binding

RoleStateBinding.Bind(diagnosticsNodeManager, roleManager, auditServer) uses the source-generated typed proxies (RoleSetState, RoleState, AddIdentityMethodState, AddRoleMethodState, ...). Each typed OnCallAsync delegate:

  1. Enforces RoleAuthorizationGate.CheckAdmin (SecurityAdmin role over a SignAndEncrypt channel) - returns Bad_SecurityModeInsufficient or Bad_UserAccessDenied otherwise (Part 18 4.2 / 4.4).
  2. Delegates to IRoleManager.
  3. Reports a RoleMappingRuleChangedAuditEvent (Part 18 4.5).
  4. Keeps the typed Identities, Applications, Endpoints, ApplicationsExclude, EndpointsExclude and CustomConfiguration property values in sync with the manager via the RoleConfigurationChanged event.

DiagnosticsNodeManager.AddBehaviourToPredefinedNodeAsync upgrades passive BaseObjectState instances of RoleSetType and RoleType to the typed RoleSetState and RoleState proxies at predefined-node load time.

Default impersonation flow

Per Part 3 4.9 and Part 18 4.3 the default RoleManager rules already grant:

  • Anonymous for sessions without authentication;
  • AuthenticatedUser for any session with a non-anonymous token;
  • TrustedApplication when the session was authenticated with a trusted ApplicationInstance certificate over a signed channel.

Additional identity-mapping rules are evaluated by IRoleManager.ResolveGrantedRoles after authentication. New integrations should expose claims through IIdentityClaims and add IdentityMappingRuleType entries to the role manager.

User Management (Part 18 §5)

The Part 18 §5 UserManagementType is bound to the standard Server.ServerConfiguration.UserManagement object (NodeId i=24290). Integrators inject an IUserManagement instance via IServerInternal.SetUserManagement before the configuration node manager binds the address space; the default UserManagement implementation wraps an existing IUserDatabase for credential persistence and stores the per-user UserConfigurationMask and description in memory:

using Opc.Ua.Server.UserDatabase;
using Opc.Ua.Server.UserManagement;

var users = new LinqUserDatabase();
users.CreateUser("alice", "secret"u8, [Role.AuthenticatedUser]);

var userManagement = new UserManagement(
    users,
    passwordLength: new Range { Low = 8, High = 256 },
    passwordOptions:
        PasswordOptionsMask.SupportDisableUser
        | PasswordOptionsMask.SupportInitialPasswordChange
        | PasswordOptionsMask.RequiresDigitCharacters);

serverInternal.SetUserManagement(userManagement);

UserManagementBinding.Bind (called automatically by ConfigurationNodeManager.CreateServerConfiguration when an IUserManagement is injected) wires the typed AddUser, ModifyUser, RemoveUser and ChangePassword method-state proxies, enforces RoleAuthorizationGate.CheckAdmin on the admin methods (SecurityAdmin + SignAndEncrypt) and RoleAuthorizationGate.CheckSelfUserName on ChangePassword, and closes any active sessions for a deactivated user via the supplied ISessionManager.

Spec result codes honoured:

Operation Failure codes
AddUser (§5.2.5) Bad_AlreadyExists, Bad_OutOfRange, Bad_NotSupported, Bad_ConfigurationError, Bad_UserAccessDenied, Bad_SecurityModeInsufficient, Bad_ResourceUnavailable
ModifyUser (§5.2.6) Bad_NotFound, Bad_OutOfRange, Bad_NotSupported, Bad_ConfigurationError, Bad_UserAccessDenied, Bad_SecurityModeInsufficient, Bad_InvalidSelfReference
RemoveUser (§5.2.7) Bad_NotFound, Bad_UserAccessDenied, Bad_NotSupported (NoDelete), Bad_SecurityModeInsufficient, Bad_InvalidSelfReference
ChangePassword (§5.2.8) Bad_IdentityTokenInvalid, Bad_OutOfRange, Bad_InvalidState, Bad_NotSupported (NoChangeByUser), Bad_SecurityModeInsufficient, Bad_AlreadyExists (new == old)

User name / password storage

The IUserDatabase interface (Opc.Ua.Server.UserDatabase.IUserDatabase) remains the extension point for username/password storage. The shipped implementations are:

  • LinqUserDatabase - in-memory.
  • JsonUserDatabase - JSON file backed.

External implementations may be provided by integrators (e.g. backed by SQL, LDAP, or a vendor-specific store).

Client side

Opc.Ua.Client.Roles.RoleManagementClient is a strongly-typed async client over the standard Server.ServerCapabilities.RoleSet object:

using Opc.Ua.Client.Roles;

RoleManagementClient client = session.OpenRoleManagementClient();

// List every role exposed by the server.
IReadOnlyList<RoleInfo> roles = await client.ListRolesAsync();

// Add a UserName identity rule to the Observer role.
await client.AddIdentityAsync(
    Opc.Ua.ObjectIds.WellKnownRole_Observer,
    new IdentityMappingRuleType
    {
        CriteriaType = IdentityCriteriaType.UserName,
        Criteria = "alice"
    });

// Toggle the ApplicationsExclude flag on the Operator role.
await client.SetApplicationsExcludeAsync(
    Opc.Ua.ObjectIds.WellKnownRole_Operator, value: false);

// Add a custom role under a vendor namespace.
NodeId newRoleId = await client.AddRoleAsync(
    "OpsLead",
    namespaceUri: "urn:example:roles");

// Remove a dynamic role.
await client.RemoveRoleAsync(newRoleId);

All mutator methods require the calling session to hold the SecurityAdmin role and use a SignAndEncrypt secure channel; otherwise the server returns Bad_UserAccessDenied / Bad_SecurityModeInsufficient which surfaces as a ServiceResultException.

GDS

The GDS adds the following roles in addition to the standard well-known set:

  • DiscoveryAdmin
  • SecurityAdmin
  • CertificateAuthorityAdmin
  • RegistrationAuthorityAdmin
  • ApplicationSelfAdmin

See GdsRole.cs.

Known limitations

  • Integration tests for end-to-end RoleSet / UserManagement scenarios (e.g. AddRole over the wire followed by browse + AddIdentity + activated session under the new grant) are tracked as a follow-up.

References