node-opcua-role-set-server 2.174.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +188 -0
  3. package/dist/audit.d.ts +33 -0
  4. package/dist/audit.js +28 -0
  5. package/dist/audit.js.map +1 -0
  6. package/dist/bind_restriction_methods.d.ts +31 -0
  7. package/dist/bind_restriction_methods.js +94 -0
  8. package/dist/bind_restriction_methods.js.map +1 -0
  9. package/dist/bind_role_methods.d.ts +48 -0
  10. package/dist/bind_role_methods.js +143 -0
  11. package/dist/bind_role_methods.js.map +1 -0
  12. package/dist/bind_user_management.d.ts +58 -0
  13. package/dist/bind_user_management.js +153 -0
  14. package/dist/bind_user_management.js.map +1 -0
  15. package/dist/harden.d.ts +35 -0
  16. package/dist/harden.js +28 -0
  17. package/dist/harden.js.map +1 -0
  18. package/dist/index.d.ts +13 -0
  19. package/dist/index.js +35 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/install_role_based_security.d.ts +78 -0
  22. package/dist/install_role_based_security.js +41 -0
  23. package/dist/install_role_based_security.js.map +1 -0
  24. package/dist/install_role_set.d.ts +73 -0
  25. package/dist/install_role_set.js +308 -0
  26. package/dist/install_role_set.js.map +1 -0
  27. package/dist/install_user_management.d.ts +68 -0
  28. package/dist/install_user_management.js +158 -0
  29. package/dist/install_user_management.js.map +1 -0
  30. package/dist/role_set_resolver.d.ts +23 -0
  31. package/dist/role_set_resolver.js +30 -0
  32. package/dist/role_set_resolver.js.map +1 -0
  33. package/dist/security_checks.d.ts +25 -0
  34. package/dist/security_checks.js +41 -0
  35. package/dist/security_checks.js.map +1 -0
  36. package/dist/user_management_user_manager.d.ts +37 -0
  37. package/dist/user_management_user_manager.js +72 -0
  38. package/dist/user_management_user_manager.js.map +1 -0
  39. package/dist/variant_args.d.ts +13 -0
  40. package/dist/variant_args.js +18 -0
  41. package/dist/variant_args.js.map +1 -0
  42. package/package.json +54 -0
  43. package/source/audit.ts +53 -0
  44. package/source/bind_restriction_methods.ts +117 -0
  45. package/source/bind_role_methods.ts +196 -0
  46. package/source/bind_user_management.ts +215 -0
  47. package/source/harden.ts +46 -0
  48. package/source/index.ts +48 -0
  49. package/source/install_role_based_security.ts +130 -0
  50. package/source/install_role_set.ts +426 -0
  51. package/source/install_user_management.ts +250 -0
  52. package/source/role_set_resolver.ts +38 -0
  53. package/source/security_checks.ts +47 -0
  54. package/source/user_management_user_manager.ts +104 -0
  55. package/source/variant_args.ts +22 -0
@@ -0,0 +1,426 @@
1
+ /**
2
+ * @module node-opcua-role-set-server
3
+ *
4
+ * Install RoleSet management on an OPC UA server.
5
+ *
6
+ * This function is designed to be called **after** the server has started
7
+ * (when the address space is available). It:
8
+ * - Creates an InMemoryIdentityMappingStore (optionally loaded from disk)
9
+ * - Registers a RoleSetResolver on server.roleResolvers
10
+ * - Binds AddIdentity / RemoveIdentity on each Role and keeps the Identities
11
+ * Property in sync with the store
12
+ * - Binds AddRole / RemoveRole (OPC 10000-18 §4.2): custom Roles are created
13
+ * as `ns=1;g=<uuid>` instances of RoleType (collision-proof, stable when
14
+ * persisted); well-known Roles cannot be removed.
15
+ */
16
+
17
+ import { randomUUID } from "node:crypto";
18
+ import type { IAddressSpace, ISessionContext, UAMethod, UAObject, UARole, UARoleSet } from "node-opcua-address-space";
19
+ import { ObjectIds } from "node-opcua-constants";
20
+ import { NodeClass } from "node-opcua-data-model";
21
+ import { NodeId, NodeIdType, resolveNodeId, sameNodeId } from "node-opcua-nodeid";
22
+ import {
23
+ ArchiveStore,
24
+ deserializeRestrictions,
25
+ type IIdentityMappingStore,
26
+ InMemoryIdentityMappingStore,
27
+ InMemoryRoleRestrictionStore,
28
+ type IRoleRestrictionStore,
29
+ identitiesFromBase64,
30
+ identitiesToBase64,
31
+ type PersistedCustomRole,
32
+ serializeRestrictions,
33
+ WellKnownRoles
34
+ } from "node-opcua-role-set-common";
35
+ import type { CallMethodResultOptions } from "node-opcua-service-call";
36
+ import { StatusCodes } from "node-opcua-status-code";
37
+ import { EndpointType } from "node-opcua-types";
38
+ import type { Variant } from "node-opcua-variant";
39
+ import { DataType, VariantArrayType } from "node-opcua-variant";
40
+ import { raiseAuditMethodEvent } from "./audit.js";
41
+ import {
42
+ type BindRestrictionMethodsOptions,
43
+ makeAddApplicationHandler,
44
+ makeAddEndpointHandler,
45
+ makeRemoveApplicationHandler,
46
+ makeRemoveEndpointHandler
47
+ } from "./bind_restriction_methods.js";
48
+ import {
49
+ type BindRoleMethodsOptions,
50
+ makeAddIdentityHandler,
51
+ makeRemoveIdentityHandler,
52
+ type RoleMappingRuleChangedAudit
53
+ } from "./bind_role_methods.js";
54
+ import { hardenAdminOnly } from "./harden.js";
55
+ import { RoleSetResolver } from "./role_set_resolver.js";
56
+ import { checkEncryptedChannel, checkSecurityAdminAccess } from "./security_checks.js";
57
+ import { asString } from "./variant_args.js";
58
+
59
+ const UA_NAMESPACE_URI = "http://opcfoundation.org/UA/";
60
+
61
+ /** Persisted definition of a custom Role (so its GUID NodeId survives a restart). */
62
+ type CustomRoleDef = PersistedCustomRole;
63
+
64
+ /** Iterate the Role components of a RoleSet (UAObjects of type RoleType). */
65
+ function forEachRole(roleSet: UARoleSet, fn: (role: UARole) => void): void {
66
+ for (const c of roleSet.getComponents()) {
67
+ if (c.nodeClass !== NodeClass.Object) continue;
68
+ const obj = c as UAObject;
69
+ if (obj.typeDefinitionObj.browseName.name !== "RoleType") continue;
70
+ fn(obj as UARole);
71
+ }
72
+ }
73
+
74
+ /** True for the standard well-known Roles (ns=0 numeric NodeIds, OPC 10000-3). */
75
+ function isWellKnownRoleNodeId(nodeId: NodeId): boolean {
76
+ if (nodeId.namespace !== 0 || nodeId.identifierType !== NodeIdType.NUMERIC) {
77
+ return false;
78
+ }
79
+ const values = Object.values(WellKnownRoles).filter((v): v is number => typeof v === "number");
80
+ return values.includes(nodeId.value as number);
81
+ }
82
+
83
+ /** The BrowseNames of the standard well-known Roles (OPC 10000-3). */
84
+ const WELL_KNOWN_ROLE_NAMES = new Set(Object.keys(WellKnownRoles).filter((k) => Number.isNaN(Number(k))));
85
+
86
+ export interface InstallRoleSetOptions {
87
+ /**
88
+ * Path to a single consolidated archive file holding the whole RoleSet
89
+ * configuration (identity mappings, custom Role definitions and
90
+ * application/endpoint restrictions). Written atomically on every change.
91
+ */
92
+ persistencePath?: string;
93
+ /**
94
+ * When set, the archive is encrypted at rest (AES-256-GCM, key derived from
95
+ * this operator-supplied secret). Omit to store plain JSON (relying on
96
+ * filesystem permissions); the stored password material is salted scrypt
97
+ * hashes either way.
98
+ */
99
+ persistenceSecret?: string;
100
+ /**
101
+ * A shared {@link ArchiveStore} coordinating one consolidated file across
102
+ * `installRoleSet` and `installUserManagement`. Pass the **same** instance to
103
+ * both so users and roles live in one archive. When omitted, an internal one
104
+ * is created from `persistencePath`/`persistenceSecret` (role config only).
105
+ */
106
+ persistence?: ArchiveStore;
107
+ /**
108
+ * Identity-mapping store to bind the RoleSet to. When omitted a new
109
+ * {@link InMemoryIdentityMappingStore} is created. Inject a shared store so the
110
+ * **same** mappings drive role resolution (e.g. via the server `userManager`),
111
+ * the `Identities` Property shown in clients, and the RoleSet Methods —
112
+ * keeping a single source of truth. Persisted mappings (if any) are merged in.
113
+ */
114
+ store?: IIdentityMappingStore;
115
+ }
116
+
117
+ export interface InstallRoleSetResult {
118
+ /** The identity mapping store backing the role set. */
119
+ store: IIdentityMappingStore;
120
+ /** The per-Role application/endpoint restriction store (§4.4.1). */
121
+ restrictionStore: IRoleRestrictionStore;
122
+ /** The resolver registered on server.roleResolvers. */
123
+ resolver: RoleSetResolver;
124
+ }
125
+
126
+ /**
127
+ * The server-like object we need — just needs `roleResolvers` and
128
+ * access to the address space.
129
+ */
130
+ export interface IServerForRoleSet {
131
+ roleResolvers: Array<{ resolveRoles(token: unknown): unknown[] }>;
132
+ engine: {
133
+ addressSpace: IAddressSpace | null;
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Install RoleSet management on an OPC UA server. Call this **after**
139
+ * `server.start()` so the address space is available.
140
+ */
141
+ export async function installRoleSet(server: IServerForRoleSet, options?: InstallRoleSetOptions): Promise<InstallRoleSetResult> {
142
+ const addressSpace = server.engine.addressSpace;
143
+ if (!addressSpace) {
144
+ throw new Error("installRoleSet: address space is not available. Call this after server.start().");
145
+ }
146
+
147
+ const roleSet = addressSpace.findNode(ObjectIds.Server_ServerCapabilities_RoleSet) as UARoleSet | null;
148
+ if (!roleSet) {
149
+ throw new Error("installRoleSet: RoleSet node (i=15606) not found in address space.");
150
+ }
151
+
152
+ // The consolidated archive is owned either by a shared coordinator (when also
153
+ // installing user management into the same file) or created from the path here.
154
+ const persistence =
155
+ options?.persistence ??
156
+ (options?.persistencePath ? new ArchiveStore(options.persistencePath, { secret: options.persistenceSecret }) : undefined);
157
+
158
+ // Load the whole configuration from the single consolidated archive (if any).
159
+ const archive = persistence ? await persistence.load() : undefined;
160
+
161
+ const store = options?.store ?? new InMemoryIdentityMappingStore();
162
+ identitiesFromBase64(store, archive?.identities);
163
+
164
+ const restrictionStore = new InMemoryRoleRestrictionStore();
165
+ if (archive?.restrictions) {
166
+ deserializeRestrictions(restrictionStore, archive.restrictions);
167
+ }
168
+
169
+ const resolver = new RoleSetResolver(store, restrictionStore);
170
+ server.roleResolvers ??= [];
171
+ server.roleResolvers.push(resolver);
172
+
173
+ function refreshIdentities(role: UARole): void {
174
+ role.identities.setValueFromSource({
175
+ dataType: DataType.ExtensionObject,
176
+ value: store.getIdentitiesForRole(role.nodeId)
177
+ });
178
+ }
179
+
180
+ /** Refresh a Role's Applications/Endpoints restriction variables from the store. */
181
+ function refreshRestrictions(role: UARole): void {
182
+ role.applications?.setValueFromSource({
183
+ dataType: DataType.String,
184
+ arrayType: VariantArrayType.Array,
185
+ value: restrictionStore.getApplications(role.nodeId)
186
+ });
187
+ role.applicationsExclude?.setValueFromSource({
188
+ dataType: DataType.Boolean,
189
+ value: restrictionStore.getApplicationsExclude(role.nodeId)
190
+ });
191
+ role.endpoints?.setValueFromSource({
192
+ dataType: DataType.ExtensionObject,
193
+ arrayType: VariantArrayType.Array,
194
+ value: restrictionStore.getEndpoints(role.nodeId).map((e) => new EndpointType(e))
195
+ });
196
+ role.endpointsExclude?.setValueFromSource({
197
+ dataType: DataType.Boolean,
198
+ value: restrictionStore.getEndpointsExclude(role.nodeId)
199
+ });
200
+ }
201
+
202
+ const customRoles: CustomRoleDef[] = [];
203
+
204
+ // Register the sections we own; the coordinator gathers these (plus any users
205
+ // registered by installUserManagement) and rewrites the one file atomically.
206
+ persistence?.setIdentitiesProvider(() => identitiesToBase64(store));
207
+ persistence?.setRolesProvider(() => customRoles);
208
+ persistence?.setRestrictionsProvider(() => serializeRestrictions(restrictionStore));
209
+
210
+ /** Snapshot every store into the single consolidated archive (atomic write). */
211
+ async function persist(): Promise<void> {
212
+ await persistence?.save();
213
+ }
214
+
215
+ /** Refresh every Role's variables and persist the whole configuration. */
216
+ const afterMutation = async () => {
217
+ forEachRole(roleSet, (role) => {
218
+ refreshIdentities(role);
219
+ refreshRestrictions(role);
220
+ });
221
+ await persist();
222
+ };
223
+
224
+ // Raise a RoleMappingRuleChangedAuditEventType on the Server object (§4.5).
225
+ // The IdentityMappingRule carries no secret, so it is included.
226
+ const serverObject = addressSpace.rootFolder?.objects?.server;
227
+ const raiseRoleMappingAudit = (audit: RoleMappingRuleChangedAudit): void => {
228
+ raiseAuditMethodEvent(serverObject, "RoleMappingRuleChangedAuditEventType", {
229
+ sourceNode: audit.roleNodeId,
230
+ sourceName: `Method/${audit.method}`,
231
+ methodId: audit.methodNodeId,
232
+ clientUserId: audit.userName,
233
+ status: audit.statusCode === StatusCodes.Good,
234
+ message: `${audit.method} on role ${audit.roleNodeId.toString()} by '${audit.userName}' → ${audit.statusCode.name}`,
235
+ inputArguments: audit.inputArguments
236
+ });
237
+ };
238
+
239
+ const methodOptions: BindRoleMethodsOptions = { store, onMutation: afterMutation, onAudit: raiseRoleMappingAudit };
240
+ const addIdentityHandler = makeAddIdentityHandler(methodOptions);
241
+ const removeIdentityHandler = makeRemoveIdentityHandler(methodOptions);
242
+
243
+ const restrictionMethodOptions: BindRestrictionMethodsOptions = {
244
+ restrictionStore,
245
+ onMutation: afterMutation,
246
+ onAudit: raiseRoleMappingAudit
247
+ };
248
+ const addApplicationHandler = makeAddApplicationHandler(restrictionMethodOptions);
249
+ const removeApplicationHandler = makeRemoveApplicationHandler(restrictionMethodOptions);
250
+ const addEndpointHandler = makeAddEndpointHandler(restrictionMethodOptions);
251
+ const removeEndpointHandler = makeRemoveEndpointHandler(restrictionMethodOptions);
252
+
253
+ /** Bind the identity + restriction Methods on a Role and seed its variables. */
254
+ function bindRoleMethods(role: UARole): void {
255
+ refreshIdentities(role);
256
+ refreshRestrictions(role);
257
+ role.addIdentity?.bindMethod(addIdentityHandler);
258
+ role.removeIdentity?.bindMethod(removeIdentityHandler);
259
+ role.addApplication?.bindMethod(addApplicationHandler);
260
+ role.removeApplication?.bindMethod(removeApplicationHandler);
261
+ role.addEndpoint?.bindMethod(addEndpointHandler);
262
+ role.removeEndpoint?.bindMethod(removeEndpointHandler);
263
+ hardenRole(role);
264
+ }
265
+
266
+ /**
267
+ * Hide a Role's sensitive Properties and configuration Methods from
268
+ * non-admin Browse and require an encrypted channel (§4.4.1). The Role node
269
+ * itself stays browsable so the RoleSet hierarchy remains visible.
270
+ */
271
+ function hardenRole(role: UARole): void {
272
+ hardenAdminOnly(role.identities);
273
+ hardenAdminOnly(role.applications);
274
+ hardenAdminOnly(role.applicationsExclude);
275
+ hardenAdminOnly(role.endpoints);
276
+ hardenAdminOnly(role.endpointsExclude);
277
+ hardenAdminOnly(role.addIdentity);
278
+ hardenAdminOnly(role.removeIdentity);
279
+ hardenAdminOnly(role.addApplication);
280
+ hardenAdminOnly(role.removeApplication);
281
+ hardenAdminOnly(role.addEndpoint);
282
+ hardenAdminOnly(role.removeEndpoint);
283
+ }
284
+
285
+ /** Create a RoleType instance (with the configuration Methods) and bind it. */
286
+ const createRoleNode = (roleName: string, browseNameNamespace: number, nodeId: NodeId): UARole => {
287
+ const roleType = addressSpace.findObjectType("RoleType");
288
+ if (!roleType) {
289
+ throw new Error("installRoleSet: RoleType ObjectType not found");
290
+ }
291
+ const role = roleType.instantiate({
292
+ browseName: { name: roleName, namespaceIndex: browseNameNamespace },
293
+ componentOf: roleSet,
294
+ nodeId,
295
+ optionals: [
296
+ "AddIdentity",
297
+ "RemoveIdentity",
298
+ "AddApplication",
299
+ "RemoveApplication",
300
+ "AddEndpoint",
301
+ "RemoveEndpoint",
302
+ "Applications",
303
+ "ApplicationsExclude",
304
+ "Endpoints",
305
+ "EndpointsExclude"
306
+ ]
307
+ }) as UARole;
308
+ bindRoleMethods(role);
309
+ return role;
310
+ };
311
+
312
+ // Recreate persisted custom Roles before binding the standard ones.
313
+ for (const def of archive?.roles ?? []) {
314
+ const nodeId = resolveNodeId(def.nodeId);
315
+ const nsIndex = def.namespaceUri ? ensureNamespace(addressSpace, def.namespaceUri) : addressSpace.getOwnNamespace().index;
316
+ if (!addressSpace.findNode(nodeId)) {
317
+ createRoleNode(def.roleName, nsIndex, nodeId);
318
+ }
319
+ customRoles.push(def);
320
+ }
321
+
322
+ // Bind the configuration Methods on every current Role and seed its variables.
323
+ forEachRole(roleSet, bindRoleMethods);
324
+
325
+ // AddRole (§4.2.2)
326
+ const addRoleHandler = async function (
327
+ this: UAMethod,
328
+ inputArguments: Variant[],
329
+ context: ISessionContext
330
+ ): Promise<CallMethodResultOptions> {
331
+ const insecure = checkEncryptedChannel(context);
332
+ if (insecure) return insecure;
333
+ const denied = checkSecurityAdminAccess(context);
334
+ if (denied) return denied;
335
+
336
+ const roleName = asString(inputArguments[0]);
337
+ if (!roleName) {
338
+ return { statusCode: StatusCodes.BadInvalidArgument };
339
+ }
340
+ // A custom Role must not impersonate a well-known Role (which already
341
+ // exists in ns=0); reject the name in any namespace (§4.2.2).
342
+ if (WELL_KNOWN_ROLE_NAMES.has(roleName)) {
343
+ return { statusCode: StatusCodes.BadAlreadyExists };
344
+ }
345
+ const namespaceUri = asString(inputArguments[1]) ?? "";
346
+ const browseNameNamespace =
347
+ namespaceUri && namespaceUri !== UA_NAMESPACE_URI
348
+ ? ensureNamespace(addressSpace, namespaceUri)
349
+ : namespaceUri === UA_NAMESPACE_URI
350
+ ? 0
351
+ : addressSpace.getOwnNamespace().index;
352
+
353
+ // The RoleName must be unique within the RoleSet (§4.2.2). We check by
354
+ // name across *all* namespaces (not just name+namespace) so a custom Role
355
+ // can never duplicate the name of any existing Role — well-known or custom.
356
+ const exists = roleSet.getComponents().some((c) => c.browseName.name === roleName);
357
+ if (exists) {
358
+ return { statusCode: StatusCodes.BadAlreadyExists };
359
+ }
360
+
361
+ // GUID NodeId in the server's own namespace (collision-proof, persisted)
362
+ const nodeId = new NodeId(NodeIdType.GUID, randomUUID().toUpperCase(), addressSpace.getOwnNamespace().index);
363
+ createRoleNode(roleName, browseNameNamespace, nodeId);
364
+
365
+ customRoles.push({ nodeId: nodeId.toString(), roleName, namespaceUri });
366
+ await persist();
367
+
368
+ return {
369
+ statusCode: StatusCodes.Good,
370
+ outputArguments: [{ dataType: DataType.NodeId, value: nodeId }]
371
+ };
372
+ };
373
+
374
+ // RemoveRole (§4.2.3)
375
+ const removeRoleHandler = async function (
376
+ this: UAMethod,
377
+ inputArguments: Variant[],
378
+ context: ISessionContext
379
+ ): Promise<CallMethodResultOptions> {
380
+ const insecure = checkEncryptedChannel(context);
381
+ if (insecure) return insecure;
382
+ const denied = checkSecurityAdminAccess(context);
383
+ if (denied) return denied;
384
+
385
+ const roleNodeId = inputArguments[0]?.value;
386
+ if (!(roleNodeId instanceof NodeId)) {
387
+ return { statusCode: StatusCodes.BadInvalidArgument };
388
+ }
389
+ // Well-known Roles are required by the Server and cannot be removed (§4.3)
390
+ if (isWellKnownRoleNodeId(roleNodeId)) {
391
+ return { statusCode: StatusCodes.BadRequestNotAllowed };
392
+ }
393
+ const node = addressSpace.findNode(roleNodeId) as UARole | null;
394
+ if (node?.typeDefinitionObj?.browseName.name !== "RoleType") {
395
+ return { statusCode: StatusCodes.BadNodeIdUnknown };
396
+ }
397
+
398
+ // remove the node, its identity mappings and the persisted definition
399
+ for (const rule of store.getIdentitiesForRole(roleNodeId)) {
400
+ store.removeIdentity(roleNodeId, rule);
401
+ }
402
+ addressSpace.deleteNode(roleNodeId);
403
+ const idx = customRoles.findIndex((r) => sameNodeId(resolveNodeId(r.nodeId), roleNodeId));
404
+ if (idx >= 0) customRoles.splice(idx, 1);
405
+
406
+ await persist();
407
+ return { statusCode: StatusCodes.Good };
408
+ };
409
+
410
+ if (roleSet.addRole) {
411
+ (roleSet.addRole as UAMethod).bindMethod(addRoleHandler);
412
+ hardenAdminOnly(roleSet.addRole);
413
+ }
414
+ if (roleSet.removeRole) {
415
+ (roleSet.removeRole as UAMethod).bindMethod(removeRoleHandler);
416
+ hardenAdminOnly(roleSet.removeRole);
417
+ }
418
+
419
+ return { store, restrictionStore, resolver };
420
+ }
421
+
422
+ /** Resolve a namespace URI to its index, registering it if necessary. */
423
+ function ensureNamespace(addressSpace: IAddressSpace, namespaceUri: string): number {
424
+ const idx = addressSpace.getNamespaceIndex(namespaceUri);
425
+ return idx >= 0 ? idx : addressSpace.registerNamespace(namespaceUri).index;
426
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * @module node-opcua-role-set-server
3
+ *
4
+ * Install User Management (OPC 10000-18 §5) on an OPC UA server.
5
+ *
6
+ * Binds the AddUser / ModifyUser / RemoveUser / ChangePassword Methods of the
7
+ * standard `UserManagement` Object (i=24290, ComponentOf ServerConfiguration)
8
+ * to an {@link InMemoryUserManagementStore}, keeps the `Users` Property in sync
9
+ * with the store, and publishes the password policy via `PasswordLength` /
10
+ * `PasswordOptions`.
11
+ */
12
+ import type { IAddressSpace, UAMethod, UAObject, UAVariable } from "node-opcua-address-space";
13
+ import { MethodIds, ObjectIds, VariableIds } from "node-opcua-constants";
14
+ import type { NodeId } from "node-opcua-nodeid";
15
+ import {
16
+ ArchiveStore,
17
+ InMemoryUserManagementStore,
18
+ type IUserManagementStore,
19
+ type PasswordPolicy
20
+ } from "node-opcua-role-set-common";
21
+ import { StatusCodes } from "node-opcua-status-code";
22
+ import { Range, UserManagementDataType, UserNameIdentityToken } from "node-opcua-types";
23
+ import { DataType, VariantArrayType } from "node-opcua-variant";
24
+ import { raiseAuditMethodEvent } from "./audit.js";
25
+ import {
26
+ type BindUserManagementOptions,
27
+ makeAddUserHandler,
28
+ makeChangePasswordHandler,
29
+ makeModifyUserHandler,
30
+ makeRemoveUserHandler
31
+ } from "./bind_user_management.js";
32
+ import { hardenAdminOnly, hardenEncryptedOnly } from "./harden.js";
33
+
34
+ /** PasswordOptionsMask bit values (OPC 10000-18 §5.2.2). */
35
+ const PasswordOptions = {
36
+ RequiresUpperCaseCharacters: 1 << 5,
37
+ RequiresLowerCaseCharacters: 1 << 6,
38
+ RequiresDigitCharacters: 1 << 7,
39
+ RequiresSpecialCharacters: 1 << 8
40
+ } as const;
41
+
42
+ export interface InstallUserManagementOptions {
43
+ /** Password policy published via PasswordLength / PasswordOptions and enforced by the store. */
44
+ policy?: PasswordPolicy;
45
+ /**
46
+ * Existing user store to bind the Methods to. When omitted a new
47
+ * {@link InMemoryUserManagementStore} is created. Inject a shared store when
48
+ * the same store also backs the server `userManager`
49
+ * (see `createUserManager`).
50
+ */
51
+ store?: IUserManagementStore;
52
+ /**
53
+ * A shared {@link ArchiveStore} coordinating one consolidated file across
54
+ * `installRoleSet` and `installUserManagement`. Pass the **same** instance to
55
+ * both so users (salted scrypt hashes) live in the same archive as the role
56
+ * config; install order does not matter (unregistered sections are preserved).
57
+ * When omitted, an internal one is created from `persistencePath`.
58
+ */
59
+ persistence?: ArchiveStore;
60
+ /** Path to a users-only archive (used when no shared {@link persistence} is given). */
61
+ persistencePath?: string;
62
+ /** Encrypt the users-only archive at rest (see `installRoleSet`'s `persistenceSecret`). */
63
+ persistenceSecret?: string;
64
+ }
65
+
66
+ export interface InstallUserManagementResult {
67
+ store: IUserManagementStore;
68
+ }
69
+
70
+ /** A live session as seen by {@link IServerForUserManagement} — enough to identify and close it. */
71
+ export interface IActiveSession {
72
+ readonly authenticationToken: NodeId;
73
+ /** The activated user-identity token (a {@link UserNameIdentityToken} for username logins). */
74
+ readonly userIdentityToken?: unknown;
75
+ }
76
+
77
+ /**
78
+ * Minimal server shape: access to the address space, and — optionally — to the
79
+ * live sessions so a disabled/removed user's sessions can be terminated
80
+ * (§5.2.6-7). When `getSessions`/`closeSession` are absent (e.g. a bare test
81
+ * double) the feature is silently skipped. The real `OPCUAServer`'s engine
82
+ * provides both.
83
+ */
84
+ export interface IServerForUserManagement {
85
+ engine: {
86
+ addressSpace: IAddressSpace | null;
87
+ getSessions?(): IActiveSession[];
88
+ closeSession?(authenticationToken: NodeId, deleteSubscriptions: boolean, reason: string): void;
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Build a "close every active session of this user" function from the server's
94
+ * engine, or `undefined` if the engine does not expose session control. Closing
95
+ * deletes the user's subscriptions too — a deactivated user keeps nothing live.
96
+ */
97
+ function makeSessionCloser(engine: IServerForUserManagement["engine"]): ((userName: string) => number) | undefined {
98
+ const { getSessions, closeSession } = engine;
99
+ if (!getSessions || !closeSession) return undefined;
100
+ return (userName: string): number => {
101
+ let closed = 0;
102
+ // snapshot first: closeSession mutates the engine's session map
103
+ for (const session of getSessions.call(engine)) {
104
+ const token = session.userIdentityToken;
105
+ if (token instanceof UserNameIdentityToken && token.userName === userName) {
106
+ closeSession.call(engine, session.authenticationToken, /* deleteSubscriptions */ true, "Terminated");
107
+ closed += 1;
108
+ }
109
+ }
110
+ return closed;
111
+ };
112
+ }
113
+
114
+ function passwordOptionsMask(policy: PasswordPolicy): number {
115
+ let mask = 0;
116
+ if (policy.requireUpperCase) mask |= PasswordOptions.RequiresUpperCaseCharacters;
117
+ if (policy.requireLowerCase) mask |= PasswordOptions.RequiresLowerCaseCharacters;
118
+ if (policy.requireDigit) mask |= PasswordOptions.RequiresDigitCharacters;
119
+ if (policy.requireSpecial) mask |= PasswordOptions.RequiresSpecialCharacters;
120
+ return mask;
121
+ }
122
+
123
+ /**
124
+ * Install User Management on an OPC UA server.
125
+ *
126
+ * Call this **after** the server has started and the address space is
127
+ * available (the standard nodeset must be loaded so the `UserManagement`
128
+ * Object exists).
129
+ */
130
+ export async function installUserManagement(
131
+ server: IServerForUserManagement,
132
+ options?: InstallUserManagementOptions
133
+ ): Promise<InstallUserManagementResult> {
134
+ const addressSpace = server.engine.addressSpace;
135
+ if (!addressSpace) {
136
+ throw new Error("installUserManagement: address space is not available. Call this after server.start().");
137
+ }
138
+
139
+ const userManagement = addressSpace.findNode(ObjectIds.UserManagement) as UAObject | null;
140
+ if (!userManagement) {
141
+ throw new Error("installUserManagement: UserManagement Object (i=24290) not found in address space.");
142
+ }
143
+
144
+ const policy = options?.policy ?? {};
145
+ const store = options?.store ?? new InMemoryUserManagementStore(policy);
146
+
147
+ // Consolidated-archive coordination: hydrate persisted users (salted hashes)
148
+ // and register the `users` section so a shared coordinator persists them
149
+ // alongside the role configuration.
150
+ const persistence =
151
+ options?.persistence ??
152
+ (options?.persistencePath ? new ArchiveStore(options.persistencePath, { secret: options.persistenceSecret }) : undefined);
153
+ if (persistence && store.importUsers) {
154
+ const archive = await persistence.load();
155
+ if (archive?.users) store.importUsers(archive.users);
156
+ }
157
+ const exportUsers = store.exportUsers?.bind(store);
158
+ if (persistence && exportUsers) {
159
+ persistence.setUsersProvider(() => exportUsers());
160
+ }
161
+
162
+ const usersVar = addressSpace.findNode(VariableIds.UserManagement_Users) as UAVariable | null;
163
+ function refreshUsers(): void {
164
+ usersVar?.setValueFromSource({
165
+ dataType: DataType.ExtensionObject,
166
+ arrayType: VariantArrayType.Array,
167
+ value: store.getUsers().map(
168
+ (u) =>
169
+ new UserManagementDataType({
170
+ userName: u.userName,
171
+ userConfiguration: u.userConfiguration,
172
+ description: u.description
173
+ })
174
+ )
175
+ });
176
+ }
177
+
178
+ // Raise an AuditUpdateMethodEventType on the Server object for each managed
179
+ // user-management call — WITHOUT any password (only who/what/whom/result).
180
+ const serverObject = addressSpace.rootFolder?.objects?.server;
181
+ const closeSessionsForUser = makeSessionCloser(server.engine);
182
+ const methodOptions: BindUserManagementOptions = {
183
+ store,
184
+ onMutation: async () => {
185
+ refreshUsers();
186
+ await persistence?.save();
187
+ },
188
+ onUserDeactivated: closeSessionsForUser
189
+ ? (userName) => {
190
+ const closed = closeSessionsForUser(userName);
191
+ if (closed > 0) {
192
+ raiseAuditMethodEvent(serverObject, "AuditUpdateMethodEventType", {
193
+ sourceNode: userManagement.nodeId,
194
+ sourceName: "Method/UserDeactivated",
195
+ clientUserId: userName,
196
+ status: true,
197
+ message: `terminated ${closed} active session(s) of deactivated user '${userName}'`
198
+ });
199
+ }
200
+ }
201
+ : undefined,
202
+ onAudit: (audit) => {
203
+ raiseAuditMethodEvent(serverObject, "AuditUpdateMethodEventType", {
204
+ sourceNode: userManagement.nodeId,
205
+ sourceName: `Method/${audit.method}`,
206
+ methodId: audit.methodNodeId,
207
+ clientUserId: audit.callerUserName,
208
+ status: audit.statusCode === StatusCodes.Good,
209
+ message: `${audit.method}('${audit.targetUserName}') by '${audit.callerUserName}' → ${audit.statusCode.name}`
210
+ // NOTE: inputArguments deliberately omitted — they contain passwords
211
+ });
212
+ }
213
+ };
214
+
215
+ // Bind and harden the Methods: the three administrative Methods (and the
216
+ // Users list, which reveals account names) are SecurityAdmin-only over an
217
+ // encrypted channel; ChangePassword stays callable by any authenticated user
218
+ // but still requires encryption (§4.4.1 / §5.2.8).
219
+ hardenAdminOnly(bindMethod(addressSpace, MethodIds.UserManagement_AddUser, makeAddUserHandler(methodOptions)));
220
+ hardenAdminOnly(bindMethod(addressSpace, MethodIds.UserManagement_ModifyUser, makeModifyUserHandler(methodOptions)));
221
+ hardenAdminOnly(bindMethod(addressSpace, MethodIds.UserManagement_RemoveUser, makeRemoveUserHandler(methodOptions)));
222
+ hardenEncryptedOnly(
223
+ bindMethod(addressSpace, MethodIds.UserManagement_ChangePassword, makeChangePasswordHandler(methodOptions))
224
+ );
225
+ hardenAdminOnly(usersVar);
226
+
227
+ // Publish the password policy and the initial (empty) user list.
228
+ const lengthVar = addressSpace.findNode(VariableIds.UserManagement_PasswordLength) as UAVariable | null;
229
+ lengthVar?.setValueFromSource({
230
+ dataType: DataType.ExtensionObject,
231
+ value: new Range({ low: policy.minLength ?? 0, high: policy.maxLength ?? 0 })
232
+ });
233
+
234
+ const optionsVar = addressSpace.findNode(VariableIds.UserManagement_PasswordOptions) as UAVariable | null;
235
+ optionsVar?.setValueFromSource({ dataType: DataType.UInt32, value: passwordOptionsMask(policy) });
236
+
237
+ refreshUsers();
238
+
239
+ return { store };
240
+ }
241
+
242
+ function bindMethod(
243
+ addressSpace: IAddressSpace,
244
+ methodNodeId: number,
245
+ handler: Parameters<UAMethod["bindMethod"]>[0]
246
+ ): UAMethod | null {
247
+ const method = addressSpace.findNode(methodNodeId) as UAMethod | null;
248
+ method?.bindMethod(handler);
249
+ return method;
250
+ }