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,215 @@
1
+ /**
2
+ * @module node-opcua-role-set-server
3
+ *
4
+ * Method handlers for the UserManagementType Methods (OPC 10000-18 §5.2):
5
+ * AddUser, ModifyUser, RemoveUser and ChangePassword, backed by an
6
+ * {@link IUserManagementStore}.
7
+ *
8
+ * AddUser / ModifyUser / RemoveUser require the SecurityAdmin Role and an
9
+ * encrypted channel. ChangePassword is callable by the Session user itself but
10
+ * still requires an encrypted channel and a USERNAME user token (§5.2.8).
11
+ */
12
+ import type { ISessionContext, UAMethod } from "node-opcua-address-space-base";
13
+ import type { NodeId } from "node-opcua-nodeid";
14
+ import type { IUserManagementStore } from "node-opcua-role-set-common";
15
+ import type { CallMethodResultOptions } from "node-opcua-service-call";
16
+ import { type StatusCode, StatusCodes } from "node-opcua-status-code";
17
+ import { UserConfigurationMask, UserNameIdentityToken } from "node-opcua-types";
18
+ import type { Variant } from "node-opcua-variant";
19
+ import { checkEncryptedChannel, checkSecurityAdminAccess } from "./security_checks.js";
20
+ import { asBoolean, asMask, asString } from "./variant_args.js";
21
+
22
+ /**
23
+ * Details of a user-management change for {@link BindUserManagementOptions.onAudit}.
24
+ * Deliberately carries **no password** — only who did what to whom and the result —
25
+ * so an AuditUpdateMethodEventType can be raised without leaking secrets.
26
+ */
27
+ export interface UserManagementAudit {
28
+ method: "AddUser" | "ModifyUser" | "RemoveUser" | "ChangePassword";
29
+ /** The user the operation targets (the Session user for ChangePassword). */
30
+ targetUserName: string;
31
+ /** The Session user who invoked the Method. */
32
+ callerUserName: string;
33
+ methodNodeId: NodeId;
34
+ statusCode: StatusCode;
35
+ }
36
+
37
+ export interface BindUserManagementOptions {
38
+ store: IUserManagementStore;
39
+ /** Called after every successful mutation so the caller can persist / refresh. */
40
+ onMutation?: () => Promise<void>;
41
+ /** Called after an authorized Method attempt to raise an audit event (no secrets). */
42
+ onAudit?: (audit: UserManagementAudit) => void;
43
+ /**
44
+ * Called after a user is effectively deactivated — disabled via ModifyUser
45
+ * (§5.2.6) or deleted via RemoveUser (§5.2.7) — so the caller can terminate
46
+ * that user's still-active sessions (a disabled user must lose access, not
47
+ * merely be barred from re-authenticating).
48
+ */
49
+ onUserDeactivated?: (userName: string) => void | Promise<void>;
50
+ }
51
+
52
+ const isDisabled = (mask: UserConfigurationMask): boolean =>
53
+ (mask & UserConfigurationMask.Disabled) === UserConfigurationMask.Disabled;
54
+
55
+ /** Create an AddUser Method handler (§5.2.5). */
56
+ export function makeAddUserHandler(options: BindUserManagementOptions) {
57
+ const { store, onMutation, onAudit } = options;
58
+ return async function _addUser(
59
+ this: UAMethod,
60
+ inputArguments: Variant[],
61
+ context: ISessionContext
62
+ ): Promise<CallMethodResultOptions> {
63
+ const insecure = checkEncryptedChannel(context);
64
+ if (insecure) return insecure;
65
+ const denied = checkSecurityAdminAccess(context);
66
+ if (denied) return denied;
67
+
68
+ const userName = asString(inputArguments[0]);
69
+ const password = asString(inputArguments[1]);
70
+ if (userName === null || password === null) {
71
+ return { statusCode: StatusCodes.BadInvalidArgument };
72
+ }
73
+ const userConfiguration = asMask(inputArguments[2]);
74
+ const description = asString(inputArguments[3]) ?? "";
75
+
76
+ const statusCode = store.addUser(userName, password, userConfiguration, description);
77
+ if (statusCode === StatusCodes.Good && onMutation) {
78
+ await onMutation();
79
+ }
80
+ onAudit?.({
81
+ method: "AddUser",
82
+ targetUserName: userName,
83
+ callerUserName: context.getUserName(),
84
+ methodNodeId: this.nodeId,
85
+ statusCode
86
+ });
87
+ return { statusCode };
88
+ };
89
+ }
90
+
91
+ /** Create a ModifyUser Method handler (§5.2.6). */
92
+ export function makeModifyUserHandler(options: BindUserManagementOptions) {
93
+ const { store, onMutation, onAudit, onUserDeactivated } = options;
94
+ return async function _modifyUser(
95
+ this: UAMethod,
96
+ inputArguments: Variant[],
97
+ context: ISessionContext
98
+ ): Promise<CallMethodResultOptions> {
99
+ const insecure = checkEncryptedChannel(context);
100
+ if (insecure) return insecure;
101
+ const denied = checkSecurityAdminAccess(context);
102
+ if (denied) return denied;
103
+
104
+ const userName = asString(inputArguments[0]);
105
+ if (userName === null) {
106
+ return { statusCode: StatusCodes.BadInvalidArgument };
107
+ }
108
+ const modifyUserConfiguration = asBoolean(inputArguments[3]);
109
+ const userConfiguration = asMask(inputArguments[4]);
110
+ const statusCode = store.modifyUser(
111
+ userName,
112
+ {
113
+ modifyPassword: asBoolean(inputArguments[1]),
114
+ password: asString(inputArguments[2]) ?? "",
115
+ modifyUserConfiguration,
116
+ userConfiguration,
117
+ modifyDescription: asBoolean(inputArguments[5]),
118
+ description: asString(inputArguments[6]) ?? ""
119
+ },
120
+ context.getUserName()
121
+ );
122
+ if (statusCode === StatusCodes.Good) {
123
+ if (onMutation) await onMutation();
124
+ // a freshly disabled user must lose any live session, not just be barred from re-login
125
+ if (modifyUserConfiguration && isDisabled(userConfiguration) && onUserDeactivated) {
126
+ await onUserDeactivated(userName);
127
+ }
128
+ }
129
+ onAudit?.({
130
+ method: "ModifyUser",
131
+ targetUserName: userName,
132
+ callerUserName: context.getUserName(),
133
+ methodNodeId: this.nodeId,
134
+ statusCode
135
+ });
136
+ return { statusCode };
137
+ };
138
+ }
139
+
140
+ /** Create a RemoveUser Method handler (§5.2.7). */
141
+ export function makeRemoveUserHandler(options: BindUserManagementOptions) {
142
+ const { store, onMutation, onAudit, onUserDeactivated } = options;
143
+ return async function _removeUser(
144
+ this: UAMethod,
145
+ inputArguments: Variant[],
146
+ context: ISessionContext
147
+ ): Promise<CallMethodResultOptions> {
148
+ const insecure = checkEncryptedChannel(context);
149
+ if (insecure) return insecure;
150
+ const denied = checkSecurityAdminAccess(context);
151
+ if (denied) return denied;
152
+
153
+ const userName = asString(inputArguments[0]);
154
+ if (userName === null) {
155
+ return { statusCode: StatusCodes.BadInvalidArgument };
156
+ }
157
+ const statusCode = store.removeUser(userName, context.getUserName());
158
+ if (statusCode === StatusCodes.Good) {
159
+ if (onMutation) await onMutation();
160
+ // a removed user must lose any live session as well (§5.2.7)
161
+ if (onUserDeactivated) await onUserDeactivated(userName);
162
+ }
163
+ onAudit?.({
164
+ method: "RemoveUser",
165
+ targetUserName: userName,
166
+ callerUserName: context.getUserName(),
167
+ methodNodeId: this.nodeId,
168
+ statusCode
169
+ });
170
+ return { statusCode };
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Create a ChangePassword Method handler (§5.2.8).
176
+ *
177
+ * Operates on the Session user, requires an encrypted channel and a USERNAME
178
+ * user token (`Bad_InvalidState` otherwise). Does **not** require SecurityAdmin.
179
+ */
180
+ export function makeChangePasswordHandler(options: BindUserManagementOptions) {
181
+ const { store, onMutation, onAudit } = options;
182
+ return async function _changePassword(
183
+ this: UAMethod,
184
+ inputArguments: Variant[],
185
+ context: ISessionContext
186
+ ): Promise<CallMethodResultOptions> {
187
+ const insecure = checkEncryptedChannel(context);
188
+ if (insecure) return insecure;
189
+
190
+ const token = context.session?.userIdentityToken;
191
+ if (!(token instanceof UserNameIdentityToken)) {
192
+ return { statusCode: StatusCodes.BadInvalidState };
193
+ }
194
+ const userName = token.userName ?? "";
195
+
196
+ const oldPassword = asString(inputArguments[0]);
197
+ const newPassword = asString(inputArguments[1]);
198
+ if (oldPassword === null || newPassword === null) {
199
+ return { statusCode: StatusCodes.BadInvalidArgument };
200
+ }
201
+
202
+ const statusCode = store.changePassword(userName, oldPassword, newPassword);
203
+ if (statusCode === StatusCodes.Good && onMutation) {
204
+ await onMutation();
205
+ }
206
+ onAudit?.({
207
+ method: "ChangePassword",
208
+ targetUserName: userName,
209
+ callerUserName: userName,
210
+ methodNodeId: this.nodeId,
211
+ statusCode
212
+ });
213
+ return { statusCode };
214
+ };
215
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @module node-opcua-role-set-server
3
+ *
4
+ * Browse / channel hardening for the security-sensitive RoleSet & User
5
+ * Management nodes (OPC 10000-18 §4.4.1 / §5), built entirely on the two
6
+ * mechanisms the address space already enforces — no custom Browse filter or
7
+ * read hook:
8
+ *
9
+ * - **RolePermissions** — a node that carries RolePermissions grants *nothing*
10
+ * to a Role that is not listed. Restricting a node to `SecurityAdmin` both
11
+ * hides it from a non-admin's Browse (`isBrowseAccessRestricted`) and denies
12
+ * Read/Call (`Bad_UserAccessDenied`).
13
+ * - **AccessRestrictions(EncryptionRequired)** — the node may only be
14
+ * read/called over a `SignAndEncrypt` channel, otherwise the core returns
15
+ * `Bad_SecurityModeInsufficient`, so identity/user data never leaves the
16
+ * server over an unencrypted channel.
17
+ *
18
+ * Both checks run in the core (`ua_variable_impl` / `ua_method_impl`) *before*
19
+ * the bound Method handler, so the per-Method `checkSecurityAdminAccess` /
20
+ * `checkEncryptedChannel` guards become defense-in-depth rather than the only
21
+ * line of defense.
22
+ */
23
+ import type { BaseNode } from "node-opcua-address-space";
24
+ import { AccessRestrictionsFlag, allPermissions } from "node-opcua-data-model";
25
+ import { WellKnownRoleIds } from "node-opcua-role-set-common";
26
+
27
+ /**
28
+ * Restrict a node to the `SecurityAdmin` Role, reachable only over an encrypted
29
+ * channel — used for the admin-only configuration Methods and the sensitive
30
+ * identity/restriction/user Properties.
31
+ */
32
+ export function hardenAdminOnly(node: BaseNode | null | undefined): void {
33
+ if (!node) return;
34
+ node.setRolePermissions([{ roleId: WellKnownRoleIds.SecurityAdmin, permissions: allPermissions }]);
35
+ node.setAccessRestrictions(AccessRestrictionsFlag.EncryptionRequired);
36
+ }
37
+
38
+ /**
39
+ * Require an encrypted channel for a node that must stay visible/callable to
40
+ * ordinary authenticated users (e.g. `ChangePassword`, §5.2.8) — no Role
41
+ * restriction, just `EncryptionRequired`.
42
+ */
43
+ export function hardenEncryptedOnly(node: BaseNode | null | undefined): void {
44
+ if (!node) return;
45
+ node.setAccessRestrictions(AccessRestrictionsFlag.EncryptionRequired);
46
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @module node-opcua-role-set-server
3
+ */
4
+
5
+ export { type AuditMethodEventFields, raiseAuditMethodEvent } from "./audit.js";
6
+ export {
7
+ type BindRestrictionMethodsOptions,
8
+ makeAddApplicationHandler,
9
+ makeAddEndpointHandler,
10
+ makeRemoveApplicationHandler,
11
+ makeRemoveEndpointHandler
12
+ } from "./bind_restriction_methods.js";
13
+ export {
14
+ type BindRoleMethodsOptions,
15
+ makeAddIdentityHandler,
16
+ makeRemoveIdentityHandler,
17
+ type RoleMappingRuleChangedAudit
18
+ } from "./bind_role_methods.js";
19
+ export {
20
+ type BindUserManagementOptions,
21
+ makeAddUserHandler,
22
+ makeChangePasswordHandler,
23
+ makeModifyUserHandler,
24
+ makeRemoveUserHandler,
25
+ type UserManagementAudit
26
+ } from "./bind_user_management.js";
27
+ export {
28
+ type CreateRoleBasedSecurityOptions,
29
+ createRoleBasedSecurity,
30
+ type InstallRoleBasedSecurityOptions,
31
+ type RoleBasedSecurity,
32
+ type RoleBasedUser
33
+ } from "./install_role_based_security.js";
34
+ export {
35
+ type InstallRoleSetOptions,
36
+ type InstallRoleSetResult,
37
+ type IServerForRoleSet,
38
+ installRoleSet
39
+ } from "./install_role_set.js";
40
+ export {
41
+ type InstallUserManagementOptions,
42
+ type InstallUserManagementResult,
43
+ type IServerForUserManagement,
44
+ installUserManagement
45
+ } from "./install_user_management.js";
46
+ export { RoleSetResolver } from "./role_set_resolver.js";
47
+ export { checkEncryptedChannel, checkSecurityAdminAccess } from "./security_checks.js";
48
+ export { createUserManager, type IManagedUserManager } from "./user_management_user_manager.js";
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @module node-opcua-role-set-server
3
+ *
4
+ * One-call wiring for role-based security (OPC 10000-18) with a **single source
5
+ * of truth**. It owns one user store and one identity store and exposes them
6
+ * through the server `userManager` bridge — the only integration point the
7
+ * server core uses to resolve a session's Roles (`getUserRoles`) and to back
8
+ * each Role's `Identities` Property (`getIdentitiesForRole`).
9
+ *
10
+ * Because the `userManager` is created **before** the server (it is a
11
+ * constructor option) but the RoleSet / User Management Methods are installed
12
+ * **after** `server.start()`, this comes in two phases:
13
+ *
14
+ * ```ts
15
+ * const security = createRoleBasedSecurity({
16
+ * users: [{ userName: "admin", password: "pw", roles: [WellKnownRoleIds.SecurityAdmin] }]
17
+ * });
18
+ * const server = new OPCUAServer({ ..., userManager: security.userManager });
19
+ * await server.start();
20
+ * await security.install(server, { persistencePath: "./role-set.json" });
21
+ * ```
22
+ *
23
+ * Everything (resolution, the `Identities` Property, the AddIdentity/AddUser
24
+ * Methods, persistence) is backed by the same two stores, so the "legacy"
25
+ * (userManager) and "modern" (RoleSet Methods) views can never drift apart.
26
+ */
27
+ import type { NodeId } from "node-opcua-nodeid";
28
+ import {
29
+ ArchiveStore,
30
+ InMemoryIdentityMappingStore,
31
+ InMemoryUserManagementStore,
32
+ type PasswordPolicy
33
+ } from "node-opcua-role-set-common";
34
+ import { IdentityCriteriaType, IdentityMappingRuleType, UserConfigurationMask } from "node-opcua-types";
35
+ import { type InstallRoleSetResult, type IServerForRoleSet, installRoleSet } from "./install_role_set.js";
36
+ import {
37
+ type InstallUserManagementResult,
38
+ type IServerForUserManagement,
39
+ installUserManagement
40
+ } from "./install_user_management.js";
41
+ import { createUserManager, type IManagedUserManager } from "./user_management_user_manager.js";
42
+
43
+ /** A user to seed, with the well-known/custom Roles it holds. */
44
+ export interface RoleBasedUser {
45
+ userName: string;
46
+ password: string;
47
+ /** Role NodeIds granted to this user (e.g. `WellKnownRoleIds.Operator`). */
48
+ roles?: NodeId[];
49
+ userConfiguration?: UserConfigurationMask;
50
+ description?: string;
51
+ }
52
+
53
+ export interface CreateRoleBasedSecurityOptions {
54
+ /** Password policy enforced by the user store. */
55
+ policy?: PasswordPolicy;
56
+ /** Users to seed up front (more can be added later via the Methods). */
57
+ users?: RoleBasedUser[];
58
+ }
59
+
60
+ export interface InstallRoleBasedSecurityOptions {
61
+ /** A shared archive coordinator. When omitted, one is built from `persistencePath`. */
62
+ persistence?: ArchiveStore;
63
+ /** Path to a single consolidated archive for roles + users. */
64
+ persistencePath?: string;
65
+ /** Encrypt that archive at rest (AES-256-GCM, key derived from this secret). */
66
+ persistenceSecret?: string;
67
+ }
68
+
69
+ /** The shared stores + bridge produced by {@link createRoleBasedSecurity}. */
70
+ export interface RoleBasedSecurity {
71
+ /** The single user store (passwords / user lifecycle). */
72
+ userStore: InMemoryUserManagementStore;
73
+ /** The single identity store (UserName -> Role mappings). */
74
+ identityStore: InMemoryIdentityMappingStore;
75
+ /** The server `userManager` to pass to the `OPCUAServer` constructor. */
76
+ userManager: IManagedUserManager;
77
+ /**
78
+ * Install the RoleSet + User Management Methods on the running server,
79
+ * bound to the same two stores. Call **after** `server.start()`.
80
+ */
81
+ install(
82
+ server: IServerForRoleSet & IServerForUserManagement,
83
+ options?: InstallRoleBasedSecurityOptions
84
+ ): Promise<{ roleSet: InstallRoleSetResult; userManagement: InstallUserManagementResult }>;
85
+ }
86
+
87
+ const userNameRule = (userName: string): IdentityMappingRuleType =>
88
+ new IdentityMappingRuleType({ criteriaType: IdentityCriteriaType.UserName, criteria: userName });
89
+
90
+ /**
91
+ * Create the shared stores and the `userManager` bridge, seeding any initial
92
+ * users and their Roles. Pass `userManager` to the `OPCUAServer` constructor,
93
+ * then call {@link RoleBasedSecurity.install} after `server.start()`.
94
+ */
95
+ export function createRoleBasedSecurity(options?: CreateRoleBasedSecurityOptions): RoleBasedSecurity {
96
+ const userStore = new InMemoryUserManagementStore(options?.policy);
97
+ const identityStore = new InMemoryIdentityMappingStore();
98
+
99
+ for (const user of options?.users ?? []) {
100
+ userStore.addUser(
101
+ user.userName,
102
+ user.password,
103
+ user.userConfiguration ?? UserConfigurationMask.None,
104
+ user.description ?? ""
105
+ );
106
+ for (const role of user.roles ?? []) {
107
+ identityStore.addIdentity(role, userNameRule(user.userName));
108
+ }
109
+ }
110
+
111
+ const userManager = createUserManager(userStore, identityStore);
112
+
113
+ return {
114
+ userStore,
115
+ identityStore,
116
+ userManager,
117
+ async install(server, installOptions) {
118
+ // one coordinator shared by both installers (so users + roles land in one file)
119
+ const persistence =
120
+ installOptions?.persistence ??
121
+ (installOptions?.persistencePath
122
+ ? new ArchiveStore(installOptions.persistencePath, { secret: installOptions.persistenceSecret })
123
+ : undefined);
124
+
125
+ const roleSet = await installRoleSet(server, { store: identityStore, persistence });
126
+ const userManagement = await installUserManagement(server, { store: userStore, persistence });
127
+ return { roleSet, userManagement };
128
+ }
129
+ };
130
+ }