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.
- package/LICENSE +22 -0
- package/README.md +188 -0
- package/dist/audit.d.ts +33 -0
- package/dist/audit.js +28 -0
- package/dist/audit.js.map +1 -0
- package/dist/bind_restriction_methods.d.ts +31 -0
- package/dist/bind_restriction_methods.js +94 -0
- package/dist/bind_restriction_methods.js.map +1 -0
- package/dist/bind_role_methods.d.ts +48 -0
- package/dist/bind_role_methods.js +143 -0
- package/dist/bind_role_methods.js.map +1 -0
- package/dist/bind_user_management.d.ts +58 -0
- package/dist/bind_user_management.js +153 -0
- package/dist/bind_user_management.js.map +1 -0
- package/dist/harden.d.ts +35 -0
- package/dist/harden.js +28 -0
- package/dist/harden.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/install_role_based_security.d.ts +78 -0
- package/dist/install_role_based_security.js +41 -0
- package/dist/install_role_based_security.js.map +1 -0
- package/dist/install_role_set.d.ts +73 -0
- package/dist/install_role_set.js +308 -0
- package/dist/install_role_set.js.map +1 -0
- package/dist/install_user_management.d.ts +68 -0
- package/dist/install_user_management.js +158 -0
- package/dist/install_user_management.js.map +1 -0
- package/dist/role_set_resolver.d.ts +23 -0
- package/dist/role_set_resolver.js +30 -0
- package/dist/role_set_resolver.js.map +1 -0
- package/dist/security_checks.d.ts +25 -0
- package/dist/security_checks.js +41 -0
- package/dist/security_checks.js.map +1 -0
- package/dist/user_management_user_manager.d.ts +37 -0
- package/dist/user_management_user_manager.js +72 -0
- package/dist/user_management_user_manager.js.map +1 -0
- package/dist/variant_args.d.ts +13 -0
- package/dist/variant_args.js +18 -0
- package/dist/variant_args.js.map +1 -0
- package/package.json +54 -0
- package/source/audit.ts +53 -0
- package/source/bind_restriction_methods.ts +117 -0
- package/source/bind_role_methods.ts +196 -0
- package/source/bind_user_management.ts +215 -0
- package/source/harden.ts +46 -0
- package/source/index.ts +48 -0
- package/source/install_role_based_security.ts +130 -0
- package/source/install_role_set.ts +426 -0
- package/source/install_user_management.ts +250 -0
- package/source/role_set_resolver.ts +38 -0
- package/source/security_checks.ts +47 -0
- package/source/user_management_user_manager.ts +104 -0
- 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
|
+
}
|