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,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkSecurityAdminAccess = checkSecurityAdminAccess;
|
|
4
|
+
exports.checkEncryptedChannel = checkEncryptedChannel;
|
|
5
|
+
const node_opcua_nodeid_1 = require("node-opcua-nodeid");
|
|
6
|
+
const node_opcua_role_set_common_1 = require("node-opcua-role-set-common");
|
|
7
|
+
const node_opcua_status_code_1 = require("node-opcua-status-code");
|
|
8
|
+
const node_opcua_types_1 = require("node-opcua-types");
|
|
9
|
+
/**
|
|
10
|
+
* Verify the calling session holds the SecurityAdmin role.
|
|
11
|
+
* @returns a `Bad_UserAccessDenied` denial, or `null` if authorized.
|
|
12
|
+
*/
|
|
13
|
+
function checkSecurityAdminAccess(context) {
|
|
14
|
+
const roles = context.getCurrentUserRoles();
|
|
15
|
+
const hasSecurityAdmin = roles.some((r) => (0, node_opcua_nodeid_1.sameNodeId)(r, node_opcua_role_set_common_1.WellKnownRoleIds.SecurityAdmin));
|
|
16
|
+
if (!hasSecurityAdmin) {
|
|
17
|
+
return { statusCode: node_opcua_status_code_1.StatusCodes.BadUserAccessDenied };
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Verify the call arrives over an encrypted (SignAndEncrypt) SecureChannel
|
|
23
|
+
* (OPC 10000-18 — `Bad_SecurityModeInsufficient`).
|
|
24
|
+
*
|
|
25
|
+
* When the channel security mode cannot be determined (e.g. an in-process
|
|
26
|
+
* `PseudoSession` with no channel), the check is skipped — such sessions are
|
|
27
|
+
* inherently local/trusted. Remote sessions always expose a channel mode.
|
|
28
|
+
*
|
|
29
|
+
* @returns a denial result, or `null` if the channel is acceptable.
|
|
30
|
+
*/
|
|
31
|
+
function checkEncryptedChannel(context) {
|
|
32
|
+
const securityMode = context.session?.channel?.securityMode;
|
|
33
|
+
if (securityMode === undefined) {
|
|
34
|
+
return null; // mode unknown (in-process) → cannot enforce
|
|
35
|
+
}
|
|
36
|
+
if (securityMode !== node_opcua_types_1.MessageSecurityMode.SignAndEncrypt) {
|
|
37
|
+
return { statusCode: node_opcua_status_code_1.StatusCodes.BadSecurityModeInsufficient };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=security_checks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"security_checks.js","sourceRoot":"","sources":["../source/security_checks.ts"],"names":[],"mappings":";;AAkBA,4DAOC;AAYD,sDASC;AAtCD,yDAA+C;AAC/C,2EAA8D;AAE9D,mEAAqD;AACrD,uDAAuD;AAEvD;;;GAGG;AACH,SAAgB,wBAAwB,CAAC,OAAwB;IAC7D,MAAM,KAAK,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAC5C,MAAM,gBAAgB,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAA,8BAAU,EAAC,CAAC,EAAE,6CAAgB,CAAC,aAAa,CAAC,CAAC,CAAC;IAC1F,IAAI,CAAC,gBAAgB,EAAE,CAAC;QACpB,OAAO,EAAE,UAAU,EAAE,oCAAW,CAAC,mBAAmB,EAAE,CAAC;IAC3D,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED;;;;;;;;;GASG;AACH,SAAgB,qBAAqB,CAAC,OAAwB;IAC1D,MAAM,YAAY,GAAG,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,YAAY,CAAC;IAC5D,IAAI,YAAY,KAAK,SAAS,EAAE,CAAC;QAC7B,OAAO,IAAI,CAAC,CAAC,6CAA6C;IAC9D,CAAC;IACD,IAAI,YAAY,KAAK,sCAAmB,CAAC,cAAc,EAAE,CAAC;QACtD,OAAO,EAAE,UAAU,EAAE,oCAAW,CAAC,2BAA2B,EAAE,CAAC;IACnE,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type NodeId, type NodeIdLike } from "node-opcua-nodeid";
|
|
2
|
+
import type { IIdentityMappingStore, IUserManagementStore } from "node-opcua-role-set-common";
|
|
3
|
+
import { type StatusCode } from "node-opcua-status-code";
|
|
4
|
+
import { type IdentityMappingRuleType } from "node-opcua-types";
|
|
5
|
+
/** The subset of the ServerSession that `isValidUser` is bound to (`this`). */
|
|
6
|
+
interface SessionThis {
|
|
7
|
+
getSessionId?(): NodeId;
|
|
8
|
+
/** Set so ActivateSession returns Good_PasswordChangeRequired (node-opcua-server). */
|
|
9
|
+
passwordChangeRequired?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface IManagedUserManager {
|
|
12
|
+
/** Validate credentials (sync). Returns true for Good and Good_PasswordChangeRequired. */
|
|
13
|
+
isValidUser(this: SessionThis, userName: string, password: string): boolean;
|
|
14
|
+
/** Resolve the Roles granted to a user (only Anonymous while a change is required). */
|
|
15
|
+
getUserRoles(userName: string): NodeId[];
|
|
16
|
+
/**
|
|
17
|
+
* The IdentityMappingRules configured for a Role. The server core binds each
|
|
18
|
+
* Role's `Identities` Property to this, so it stays in sync with the same
|
|
19
|
+
* identity store that drives {@link getUserRoles} — one source of truth.
|
|
20
|
+
*/
|
|
21
|
+
getIdentitiesForRole(role: NodeId): IdentityMappingRuleType[];
|
|
22
|
+
/**
|
|
23
|
+
* StatusCode recorded at activation for the given session — `Good`,
|
|
24
|
+
* `Good_PasswordChangeRequired`, or a `Bad_*` code. Pass the SessionId the
|
|
25
|
+
* client holds (`clientSession.sessionId`). `undefined` if never activated.
|
|
26
|
+
*/
|
|
27
|
+
getSessionAuthStatus(sessionId: NodeIdLike): StatusCode | undefined;
|
|
28
|
+
/** StatusCode of the last authentication for each user (convenience). */
|
|
29
|
+
readonly lastAuthStatus: ReadonlyMap<string, StatusCode>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Create a server `userManager` backed by the given user store. Roles are
|
|
33
|
+
* resolved from `identityStore` (a UserName identity rule per user); while a
|
|
34
|
+
* user must change the password, only the Anonymous Role is granted.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createUserManager(userStore: IUserManagementStore, identityStore: IIdentityMappingStore): IManagedUserManager;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createUserManager = createUserManager;
|
|
4
|
+
/**
|
|
5
|
+
* @module node-opcua-role-set-server
|
|
6
|
+
*
|
|
7
|
+
* Bridges an {@link IUserManagementStore} (and an optional role
|
|
8
|
+
* {@link IIdentityMappingStore}) to the OPC UA server `userManager` interface so
|
|
9
|
+
* that CreateSession / ActivateSession authenticate against the managed users.
|
|
10
|
+
*
|
|
11
|
+
* It records the StatusCode of the **last authentication** — in particular
|
|
12
|
+
* `Good_PasswordChangeRequired` for a user that must change the password
|
|
13
|
+
* (OPC 10000-18 §5.2.8). Because the OPC UA stack does not propagate that code
|
|
14
|
+
* into the ActivateSession response, the status is recorded here:
|
|
15
|
+
* - per **session** ({@link IManagedUserManager.getSessionAuthStatus}),
|
|
16
|
+
* keyed by the SessionId the client also holds, so it can be observed in the
|
|
17
|
+
* context of the very session that activated;
|
|
18
|
+
* - per **user** ({@link IManagedUserManager.lastAuthStatus}) for convenience.
|
|
19
|
+
*
|
|
20
|
+
* The server `userManager` invokes `isValidUser` with `this` bound to the
|
|
21
|
+
* ServerSession, which is how the SessionId is captured (non-breaking).
|
|
22
|
+
*/
|
|
23
|
+
const node_opcua_constants_1 = require("node-opcua-constants");
|
|
24
|
+
const node_opcua_nodeid_1 = require("node-opcua-nodeid");
|
|
25
|
+
const node_opcua_status_code_1 = require("node-opcua-status-code");
|
|
26
|
+
const node_opcua_types_1 = require("node-opcua-types");
|
|
27
|
+
const has = (mask, bit) => (mask & bit) === bit;
|
|
28
|
+
/**
|
|
29
|
+
* Create a server `userManager` backed by the given user store. Roles are
|
|
30
|
+
* resolved from `identityStore` (a UserName identity rule per user); while a
|
|
31
|
+
* user must change the password, only the Anonymous Role is granted.
|
|
32
|
+
*/
|
|
33
|
+
function createUserManager(userStore, identityStore) {
|
|
34
|
+
const lastAuthStatus = new Map();
|
|
35
|
+
const statusBySession = new Map();
|
|
36
|
+
const anonymousOnly = () => [(0, node_opcua_nodeid_1.makeNodeId)(node_opcua_constants_1.WellKnownRoles.Anonymous)];
|
|
37
|
+
return {
|
|
38
|
+
lastAuthStatus,
|
|
39
|
+
getSessionAuthStatus(sessionId) {
|
|
40
|
+
return statusBySession.get(sessionId.toString());
|
|
41
|
+
},
|
|
42
|
+
isValidUser(userName, password) {
|
|
43
|
+
const result = userStore.authenticate(userName, password);
|
|
44
|
+
lastAuthStatus.set(userName, result.statusCode);
|
|
45
|
+
// `this` is the ServerSession; key the status by the SessionId the
|
|
46
|
+
// client also holds, so the outcome can be observed per session.
|
|
47
|
+
const sessionId = this?.getSessionId?.();
|
|
48
|
+
if (sessionId) {
|
|
49
|
+
statusBySession.set(sessionId.toString(), result.statusCode);
|
|
50
|
+
}
|
|
51
|
+
// flag the session so ActivateSession returns Good_PasswordChangeRequired
|
|
52
|
+
if (this) {
|
|
53
|
+
this.passwordChangeRequired = result.statusCode === node_opcua_status_code_1.StatusCodes.GoodPasswordChangeRequired;
|
|
54
|
+
}
|
|
55
|
+
return result.statusCode === node_opcua_status_code_1.StatusCodes.Good || result.statusCode === node_opcua_status_code_1.StatusCodes.GoodPasswordChangeRequired;
|
|
56
|
+
},
|
|
57
|
+
getUserRoles(userName) {
|
|
58
|
+
const user = userStore.getUsers().find((u) => u.userName === userName);
|
|
59
|
+
if (user && has(user.userConfiguration, node_opcua_types_1.UserConfigurationMask.MustChangePassword)) {
|
|
60
|
+
return anonymousOnly();
|
|
61
|
+
}
|
|
62
|
+
const token = new node_opcua_types_1.UserNameIdentityToken({ userName });
|
|
63
|
+
return identityStore.resolveRoles(token);
|
|
64
|
+
},
|
|
65
|
+
getIdentitiesForRole(role) {
|
|
66
|
+
// Backs each Role's `Identities` Property via the server core, from the
|
|
67
|
+
// same store as getUserRoles — so the two can never drift apart.
|
|
68
|
+
return identityStore.getIdentitiesForRole(role);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=user_management_user_manager.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"user_management_user_manager.js","sourceRoot":"","sources":["../source/user_management_user_manager.ts"],"names":[],"mappings":";;AA4DA,8CA2CC;AAvGD;;;;;;;;;;;;;;;;;;GAkBG;AACH,+DAAsD;AACtD,yDAA6E;AAE7E,mEAAsE;AACtE,uDAA8G;AA8B9G,MAAM,GAAG,GAAG,CAAC,IAAY,EAAE,GAAW,EAAW,EAAE,CAAC,CAAC,IAAI,GAAG,GAAG,CAAC,KAAK,GAAG,CAAC;AAEzE;;;;GAIG;AACH,SAAgB,iBAAiB,CAAC,SAA+B,EAAE,aAAoC;IACnG,MAAM,cAAc,GAAG,IAAI,GAAG,EAAsB,CAAC;IACrD,MAAM,eAAe,GAAG,IAAI,GAAG,EAAsB,CAAC;IACtD,MAAM,aAAa,GAAG,GAAa,EAAE,CAAC,CAAC,IAAA,8BAAU,EAAC,qCAAc,CAAC,SAAS,CAAC,CAAC,CAAC;IAE7E,OAAO;QACH,cAAc;QAEd,oBAAoB,CAAC,SAAqB;YACtC,OAAO,eAAe,CAAC,GAAG,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAC,CAAC;QACrD,CAAC;QAED,WAAW,CAAoB,QAAgB,EAAE,QAAgB;YAC7D,MAAM,MAAM,GAAG,SAAS,CAAC,YAAY,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;YAC1D,cAAc,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC;YAChD,mEAAmE;YACnE,iEAAiE;YACjE,MAAM,SAAS,GAAG,IAAI,EAAE,YAAY,EAAE,EAAE,CAAC;YACzC,IAAI,SAAS,EAAE,CAAC;gBACZ,eAAe,CAAC,GAAG,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC;YACjE,CAAC;YACD,0EAA0E;YAC1E,IAAI,IAAI,EAAE,CAAC;gBACP,IAAI,CAAC,sBAAsB,GAAG,MAAM,CAAC,UAAU,KAAK,oCAAW,CAAC,0BAA0B,CAAC;YAC/F,CAAC;YACD,OAAO,MAAM,CAAC,UAAU,KAAK,oCAAW,CAAC,IAAI,IAAI,MAAM,CAAC,UAAU,KAAK,oCAAW,CAAC,0BAA0B,CAAC;QAClH,CAAC;QAED,YAAY,CAAC,QAAgB;YACzB,MAAM,IAAI,GAAG,SAAS,CAAC,QAAQ,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC;YACvE,IAAI,IAAI,IAAI,GAAG,CAAC,IAAI,CAAC,iBAAiB,EAAE,wCAAqB,CAAC,kBAAkB,CAAC,EAAE,CAAC;gBAChF,OAAO,aAAa,EAAE,CAAC;YAC3B,CAAC;YACD,MAAM,KAAK,GAAyB,IAAI,wCAAqB,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC;YAC5E,OAAO,aAAa,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;QAC7C,CAAC;QAED,oBAAoB,CAAC,IAAY;YAC7B,wEAAwE;YACxE,iEAAiE;YACjE,OAAO,aAAa,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC;QACpD,CAAC;KACJ,CAAC;AACN,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-role-set-server
|
|
3
|
+
*
|
|
4
|
+
* Small helpers to read Method input arguments out of a `Variant[]` safely.
|
|
5
|
+
*/
|
|
6
|
+
import type { UserConfigurationMask } from "node-opcua-types";
|
|
7
|
+
import type { Variant } from "node-opcua-variant";
|
|
8
|
+
/** The string value of an argument, or `null` if absent / not a string. */
|
|
9
|
+
export declare function asString(v: Variant | undefined): string | null;
|
|
10
|
+
/** The boolean value of an argument (missing / non-boolean → `false`). */
|
|
11
|
+
export declare function asBoolean(v: Variant | undefined): boolean;
|
|
12
|
+
/** The UserConfigurationMask value of an argument (missing / non-numeric → `0`). */
|
|
13
|
+
export declare function asMask(v: Variant | undefined): UserConfigurationMask;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.asString = asString;
|
|
4
|
+
exports.asBoolean = asBoolean;
|
|
5
|
+
exports.asMask = asMask;
|
|
6
|
+
/** The string value of an argument, or `null` if absent / not a string. */
|
|
7
|
+
function asString(v) {
|
|
8
|
+
return v && typeof v.value === "string" ? v.value : null;
|
|
9
|
+
}
|
|
10
|
+
/** The boolean value of an argument (missing / non-boolean → `false`). */
|
|
11
|
+
function asBoolean(v) {
|
|
12
|
+
return !!v && v.value === true;
|
|
13
|
+
}
|
|
14
|
+
/** The UserConfigurationMask value of an argument (missing / non-numeric → `0`). */
|
|
15
|
+
function asMask(v) {
|
|
16
|
+
return (v && typeof v.value === "number" ? v.value : 0);
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=variant_args.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"variant_args.js","sourceRoot":"","sources":["../source/variant_args.ts"],"names":[],"mappings":";;AASA,4BAEC;AAGD,8BAEC;AAGD,wBAEC;AAbD,2EAA2E;AAC3E,SAAgB,QAAQ,CAAC,CAAsB;IAC3C,OAAO,CAAC,IAAI,OAAO,CAAC,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;AAC7D,CAAC;AAED,0EAA0E;AAC1E,SAAgB,SAAS,CAAC,CAAsB;IAC5C,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,KAAK,IAAI,CAAC;AACnC,CAAC;AAED,oFAAoF;AACpF,SAAgB,MAAM,CAAC,CAAsB;IACzC,OAAO,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAA0B,CAAC;AACrF,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-opcua-role-set-server",
|
|
3
|
+
"version": "2.174.0",
|
|
4
|
+
"description": "pure nodejs OPCUA SDK - server-side role-set management (OPC 10000-18)",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "tsc -b",
|
|
7
|
+
"lint": "eslint source/**/*.ts",
|
|
8
|
+
"clean": "npx rimraf -g node_modules dist *.tsbuildinfo",
|
|
9
|
+
"test": "mocha",
|
|
10
|
+
"test:check": "tsc --noEmit -p test/tsconfig.json"
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"devEngines": {
|
|
15
|
+
"node": ">=18.x",
|
|
16
|
+
"npm": ">=10.x"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"node-opcua-address-space": "2.174.0",
|
|
20
|
+
"node-opcua-address-space-base": "2.174.0",
|
|
21
|
+
"node-opcua-constants": "2.174.0",
|
|
22
|
+
"node-opcua-data-model": "2.174.0",
|
|
23
|
+
"node-opcua-nodeid": "2.174.0",
|
|
24
|
+
"node-opcua-role-set-common": "2.174.0",
|
|
25
|
+
"node-opcua-service-call": "2.174.0",
|
|
26
|
+
"node-opcua-status-code": "2.173.0",
|
|
27
|
+
"node-opcua-types": "2.174.0",
|
|
28
|
+
"node-opcua-variant": "2.174.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"node-opcua-leak-detector": "2.172.0",
|
|
32
|
+
"node-opcua-nodesets": "2.174.0"
|
|
33
|
+
},
|
|
34
|
+
"author": "Etienne Rossignon",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git://github.com/node-opcua/node-opcua.git"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"OPCUA",
|
|
42
|
+
"opcua",
|
|
43
|
+
"m2m",
|
|
44
|
+
"iot",
|
|
45
|
+
"opc ua",
|
|
46
|
+
"internet of things"
|
|
47
|
+
],
|
|
48
|
+
"homepage": "http://node-opcua.github.io/",
|
|
49
|
+
"gitHead": "064029878247adf2fa5d14a8124e1930431e2428",
|
|
50
|
+
"files": [
|
|
51
|
+
"dist",
|
|
52
|
+
"source"
|
|
53
|
+
]
|
|
54
|
+
}
|
package/source/audit.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-role-set-server
|
|
3
|
+
*
|
|
4
|
+
* Shared helper to raise an audit method event (a subtype of
|
|
5
|
+
* AuditUpdateMethodEventType) on the Server Object.
|
|
6
|
+
*/
|
|
7
|
+
import type { UAObject } from "node-opcua-address-space";
|
|
8
|
+
import { NodeId } from "node-opcua-nodeid";
|
|
9
|
+
import type { Variant } from "node-opcua-variant";
|
|
10
|
+
import { VariantArrayType } from "node-opcua-variant";
|
|
11
|
+
|
|
12
|
+
export interface AuditMethodEventFields {
|
|
13
|
+
/** The Node the audited operation acted on (Role / UserManagement Object). */
|
|
14
|
+
sourceNode: NodeId;
|
|
15
|
+
/** A short label, e.g. "Method/AddIdentity". */
|
|
16
|
+
sourceName: string;
|
|
17
|
+
/** The audited Method NodeId (omit for events not raised from a single Method call). */
|
|
18
|
+
methodId?: NodeId;
|
|
19
|
+
/** The Session user who invoked the Method. */
|
|
20
|
+
clientUserId: string;
|
|
21
|
+
/** TRUE when the operation succeeded. */
|
|
22
|
+
status: boolean;
|
|
23
|
+
/** Human-readable message (must NOT contain secrets such as passwords). */
|
|
24
|
+
message: string;
|
|
25
|
+
/**
|
|
26
|
+
* The Method input arguments — include ONLY when they carry no secret
|
|
27
|
+
* (e.g. an IdentityMappingRule). Omit for password-bearing Methods.
|
|
28
|
+
*/
|
|
29
|
+
inputArguments?: Variant[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Raise `eventType` on the given Server Object with the standard
|
|
34
|
+
* AuditUpdateMethodEventType fields. No-op if there is no Server Object.
|
|
35
|
+
*/
|
|
36
|
+
export function raiseAuditMethodEvent(serverObject: UAObject | undefined, eventType: string, fields: AuditMethodEventFields): void {
|
|
37
|
+
if (!serverObject) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
serverObject.raiseEvent(eventType, {
|
|
41
|
+
actionTimeStamp: { dataType: "DateTime", value: new Date() },
|
|
42
|
+
status: { dataType: "Boolean", value: fields.status },
|
|
43
|
+
serverId: { dataType: "String", value: "" },
|
|
44
|
+
clientAuditEntryId: { dataType: "String", value: "" },
|
|
45
|
+
clientUserId: { dataType: "String", value: fields.clientUserId },
|
|
46
|
+
sourceNode: { dataType: "NodeId", value: fields.sourceNode },
|
|
47
|
+
sourceName: { dataType: "String", value: fields.sourceName },
|
|
48
|
+
methodId: { dataType: "NodeId", value: fields.methodId ?? NodeId.nullNodeId },
|
|
49
|
+
severity: { dataType: "UInt16", value: 10 },
|
|
50
|
+
message: { dataType: "LocalizedText", value: fields.message },
|
|
51
|
+
inputArguments: { dataType: "Variant", arrayType: VariantArrayType.Array, value: fields.inputArguments ?? [] }
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-role-set-server
|
|
3
|
+
*
|
|
4
|
+
* Method handlers for the RoleType application/endpoint restriction Methods
|
|
5
|
+
* (OPC 10000-18 §4.4.7-10): AddApplication / RemoveApplication / AddEndpoint /
|
|
6
|
+
* RemoveEndpoint, backed by an {@link IRoleRestrictionStore}.
|
|
7
|
+
*
|
|
8
|
+
* All require the SecurityAdmin Role and an encrypted channel, and raise a
|
|
9
|
+
* RoleMappingRuleChangedAuditEventType (§4.5, reusing the same audit hook as the
|
|
10
|
+
* identity Methods).
|
|
11
|
+
*/
|
|
12
|
+
import type { ISessionContext, UAMethod } from "node-opcua-address-space-base";
|
|
13
|
+
import type { NodeId } from "node-opcua-nodeid";
|
|
14
|
+
import type { EndpointCriteria, IRoleRestrictionStore } 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 { EndpointType } from "node-opcua-types";
|
|
18
|
+
import type { Variant } from "node-opcua-variant";
|
|
19
|
+
import type { RoleMappingRuleChangedAudit } from "./bind_role_methods.js";
|
|
20
|
+
import { checkEncryptedChannel, checkSecurityAdminAccess } from "./security_checks.js";
|
|
21
|
+
import { asString } from "./variant_args.js";
|
|
22
|
+
|
|
23
|
+
export interface BindRestrictionMethodsOptions {
|
|
24
|
+
restrictionStore: IRoleRestrictionStore;
|
|
25
|
+
/** Called after every successful mutation so the caller can persist / refresh. */
|
|
26
|
+
onMutation?: () => Promise<void>;
|
|
27
|
+
/** Called after an authorized attempt to raise an audit event. */
|
|
28
|
+
onAudit?: (audit: RoleMappingRuleChangedAudit) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function endpointFromArg(v: Variant | undefined): EndpointCriteria | null {
|
|
32
|
+
if (!v || !(v.value instanceof EndpointType)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const e = v.value;
|
|
36
|
+
return {
|
|
37
|
+
endpointUrl: e.endpointUrl ?? undefined,
|
|
38
|
+
securityMode: e.securityMode,
|
|
39
|
+
securityPolicyUri: e.securityPolicyUri ?? undefined,
|
|
40
|
+
transportProfileUri: e.transportProfileUri ?? undefined
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a handler that, after the SecurityAdmin + encrypted-channel checks,
|
|
46
|
+
* applies `mutate` to the Role's restrictions, persists, and audits.
|
|
47
|
+
*/
|
|
48
|
+
function makeRestrictionHandler(
|
|
49
|
+
options: BindRestrictionMethodsOptions,
|
|
50
|
+
method: RoleMappingRuleChangedAudit["method"],
|
|
51
|
+
mutate: (roleNodeId: NodeId, inputArguments: Variant[]) => StatusCode | null
|
|
52
|
+
) {
|
|
53
|
+
const { onMutation, onAudit } = options;
|
|
54
|
+
return async function (this: UAMethod, inputArguments: Variant[], context: ISessionContext): Promise<CallMethodResultOptions> {
|
|
55
|
+
const insecure = checkEncryptedChannel(context);
|
|
56
|
+
if (insecure) return insecure;
|
|
57
|
+
const denied = checkSecurityAdminAccess(context);
|
|
58
|
+
if (denied) return denied;
|
|
59
|
+
|
|
60
|
+
const roleNode = this.parent;
|
|
61
|
+
if (!roleNode) {
|
|
62
|
+
return { statusCode: StatusCodes.BadInternalError };
|
|
63
|
+
}
|
|
64
|
+
const statusCode = mutate(roleNode.nodeId, inputArguments);
|
|
65
|
+
if (statusCode === null) {
|
|
66
|
+
return { statusCode: StatusCodes.BadInvalidArgument };
|
|
67
|
+
}
|
|
68
|
+
if (statusCode === StatusCodes.Good && onMutation) {
|
|
69
|
+
await onMutation();
|
|
70
|
+
}
|
|
71
|
+
onAudit?.({
|
|
72
|
+
method,
|
|
73
|
+
roleNodeId: roleNode.nodeId,
|
|
74
|
+
methodNodeId: this.nodeId,
|
|
75
|
+
userName: context.getUserName(),
|
|
76
|
+
inputArguments,
|
|
77
|
+
statusCode
|
|
78
|
+
});
|
|
79
|
+
return { statusCode };
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** AddApplication (§4.4.7). */
|
|
84
|
+
export function makeAddApplicationHandler(options: BindRestrictionMethodsOptions) {
|
|
85
|
+
return makeRestrictionHandler(options, "AddApplication", (roleNodeId, args) => {
|
|
86
|
+
const uri = asString(args[0]);
|
|
87
|
+
if (uri === null) return null;
|
|
88
|
+
return options.restrictionStore.addApplication(roleNodeId, uri) ? StatusCodes.Good : StatusCodes.BadAlreadyExists;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** RemoveApplication (§4.4.8). */
|
|
93
|
+
export function makeRemoveApplicationHandler(options: BindRestrictionMethodsOptions) {
|
|
94
|
+
return makeRestrictionHandler(options, "RemoveApplication", (roleNodeId, args) => {
|
|
95
|
+
const uri = asString(args[0]);
|
|
96
|
+
if (uri === null) return null;
|
|
97
|
+
return options.restrictionStore.removeApplication(roleNodeId, uri) ? StatusCodes.Good : StatusCodes.BadNotFound;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** AddEndpoint (§4.4.9). */
|
|
102
|
+
export function makeAddEndpointHandler(options: BindRestrictionMethodsOptions) {
|
|
103
|
+
return makeRestrictionHandler(options, "AddEndpoint", (roleNodeId, args) => {
|
|
104
|
+
const endpoint = endpointFromArg(args[0]);
|
|
105
|
+
if (endpoint === null) return null;
|
|
106
|
+
return options.restrictionStore.addEndpoint(roleNodeId, endpoint) ? StatusCodes.Good : StatusCodes.BadAlreadyExists;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** RemoveEndpoint (§4.4.10). */
|
|
111
|
+
export function makeRemoveEndpointHandler(options: BindRestrictionMethodsOptions) {
|
|
112
|
+
return makeRestrictionHandler(options, "RemoveEndpoint", (roleNodeId, args) => {
|
|
113
|
+
const endpoint = endpointFromArg(args[0]);
|
|
114
|
+
if (endpoint === null) return null;
|
|
115
|
+
return options.restrictionStore.removeEndpoint(roleNodeId, endpoint) ? StatusCodes.Good : StatusCodes.BadNotFound;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-role-set-server
|
|
3
|
+
*
|
|
4
|
+
* Method handlers for RoleType AddIdentity / RemoveIdentity
|
|
5
|
+
* and RoleSetType AddRole / RemoveRole.
|
|
6
|
+
*
|
|
7
|
+
* Each handler follows the OPC UA method binding pattern:
|
|
8
|
+
* - `this` is the UAMethod node
|
|
9
|
+
* - `inputArguments` is Variant[]
|
|
10
|
+
* - `context` is ISessionContext
|
|
11
|
+
* - returns Promise<CallMethodResultOptions>
|
|
12
|
+
*/
|
|
13
|
+
import type { ISessionContext, UAMethod } from "node-opcua-address-space-base";
|
|
14
|
+
import type { NodeId } from "node-opcua-nodeid";
|
|
15
|
+
import { sameNodeId } from "node-opcua-nodeid";
|
|
16
|
+
import { type IIdentityMappingStore, WellKnownRoleIds } from "node-opcua-role-set-common";
|
|
17
|
+
import type { CallMethodResultOptions } from "node-opcua-service-call";
|
|
18
|
+
import { type StatusCode, StatusCodes } from "node-opcua-status-code";
|
|
19
|
+
import { IdentityCriteriaType, IdentityMappingRuleType } from "node-opcua-types";
|
|
20
|
+
import type { Variant } from "node-opcua-variant";
|
|
21
|
+
import { checkEncryptedChannel, checkSecurityAdminAccess } from "./security_checks.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Well-known Roles whose mapping rules a Server shall not allow to be
|
|
25
|
+
* changed (OPC 10000-18 §4.3): Anonymous, AuthenticatedUser and
|
|
26
|
+
* TrustedApplication. (TrustedApplication has no constant in this build and
|
|
27
|
+
* is matched by NodeId when present.)
|
|
28
|
+
*/
|
|
29
|
+
function isImmutableRole(roleId: NodeId): boolean {
|
|
30
|
+
return sameNodeId(roleId, WellKnownRoleIds.Anonymous) || sameNodeId(roleId, WellKnownRoleIds.AuthenticatedUser);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Administrative Roles to which weak (Anonymous / AuthenticatedUser) identity
|
|
35
|
+
* rules must not be added (OPC 10000-18 §4.4.5: a Server should refuse to add
|
|
36
|
+
* an ANONYMOUS_5 rule to Roles with administrator privileges).
|
|
37
|
+
*/
|
|
38
|
+
function isPrivilegedRole(roleId: NodeId): boolean {
|
|
39
|
+
return sameNodeId(roleId, WellKnownRoleIds.SecurityAdmin) || sameNodeId(roleId, WellKnownRoleIds.ConfigureAdmin);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True for weak criteria that must not be granted to administrative Roles. */
|
|
43
|
+
function isWeakCriteria(criteriaType: IdentityCriteriaType): boolean {
|
|
44
|
+
return criteriaType === IdentityCriteriaType.Anonymous || criteriaType === IdentityCriteriaType.AuthenticatedUser;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract the IdentityMappingRuleType from the first input argument.
|
|
49
|
+
*/
|
|
50
|
+
function extractIdentityRule(inputArguments: Variant[]): IdentityMappingRuleType | null {
|
|
51
|
+
if (!inputArguments || inputArguments.length < 1) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const value = inputArguments[0].value;
|
|
55
|
+
if (value instanceof IdentityMappingRuleType) {
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Details of an identity-mapping change, passed to {@link BindRoleMethodsOptions.onAudit}
|
|
63
|
+
* so the caller can raise a `RoleMappingRuleChangedAuditEventType` (OPC 10000-18 §4.5).
|
|
64
|
+
* Raised for every authorized attempt — both successful updates and refusals
|
|
65
|
+
* (e.g. immutable Role, duplicate) — with the resulting `statusCode`.
|
|
66
|
+
*/
|
|
67
|
+
export interface RoleMappingRuleChangedAudit {
|
|
68
|
+
method: "AddIdentity" | "RemoveIdentity" | "AddApplication" | "RemoveApplication" | "AddEndpoint" | "RemoveEndpoint";
|
|
69
|
+
roleNodeId: NodeId;
|
|
70
|
+
methodNodeId: NodeId;
|
|
71
|
+
userName: string;
|
|
72
|
+
inputArguments: Variant[];
|
|
73
|
+
statusCode: StatusCode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface BindRoleMethodsOptions {
|
|
77
|
+
store: IIdentityMappingStore;
|
|
78
|
+
/** Called after every mutation (add/remove) so the caller can persist. */
|
|
79
|
+
onMutation?: () => Promise<void>;
|
|
80
|
+
/** Called after an authorized AddIdentity/RemoveIdentity attempt to raise an audit event. */
|
|
81
|
+
onAudit?: (audit: RoleMappingRuleChangedAudit) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create an AddIdentity method handler bound to the given store.
|
|
86
|
+
* The handler extracts the role NodeId from its parent Role object.
|
|
87
|
+
*/
|
|
88
|
+
export function makeAddIdentityHandler(options: BindRoleMethodsOptions) {
|
|
89
|
+
const { store, onMutation, onAudit } = options;
|
|
90
|
+
|
|
91
|
+
return async function _addIdentity(
|
|
92
|
+
this: UAMethod,
|
|
93
|
+
inputArguments: Variant[],
|
|
94
|
+
context: ISessionContext
|
|
95
|
+
): Promise<CallMethodResultOptions> {
|
|
96
|
+
// 1. The SecureChannel must be encrypted
|
|
97
|
+
const insecure = checkEncryptedChannel(context);
|
|
98
|
+
if (insecure) return insecure;
|
|
99
|
+
|
|
100
|
+
// 2. Security check
|
|
101
|
+
const denied = checkSecurityAdminAccess(context);
|
|
102
|
+
if (denied) return denied;
|
|
103
|
+
|
|
104
|
+
// 3. Extract the rule from input
|
|
105
|
+
const rule = extractIdentityRule(inputArguments);
|
|
106
|
+
if (!rule) {
|
|
107
|
+
return { statusCode: StatusCodes.BadInvalidArgument };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 4. The parent of this method is the Role object
|
|
111
|
+
const roleNode = this.parent;
|
|
112
|
+
if (!roleNode) {
|
|
113
|
+
return { statusCode: StatusCodes.BadInternalError };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 5-7. Compute the outcome of the authorized rule-change attempt
|
|
117
|
+
let statusCode: StatusCode;
|
|
118
|
+
if (isImmutableRole(roleNode.nodeId)) {
|
|
119
|
+
// well-known immutable Roles cannot be changed (§4.3)
|
|
120
|
+
statusCode = StatusCodes.BadRequestNotAllowed;
|
|
121
|
+
} else if (isWeakCriteria(rule.criteriaType) && isPrivilegedRole(roleNode.nodeId)) {
|
|
122
|
+
// refuse weak (Anonymous/AuthenticatedUser) rules on administrative Roles (§4.4.5)
|
|
123
|
+
statusCode = StatusCodes.BadRequestNotAllowed;
|
|
124
|
+
} else {
|
|
125
|
+
// duplicate rule is reported as Bad_AlreadyExists
|
|
126
|
+
statusCode = store.addIdentity(roleNode.nodeId, rule) ? StatusCodes.Good : StatusCodes.BadAlreadyExists;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (statusCode === StatusCodes.Good && onMutation) {
|
|
130
|
+
await onMutation();
|
|
131
|
+
}
|
|
132
|
+
onAudit?.({
|
|
133
|
+
method: "AddIdentity",
|
|
134
|
+
roleNodeId: roleNode.nodeId,
|
|
135
|
+
methodNodeId: this.nodeId,
|
|
136
|
+
userName: context.getUserName(),
|
|
137
|
+
inputArguments,
|
|
138
|
+
statusCode
|
|
139
|
+
});
|
|
140
|
+
return { statusCode };
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Create a RemoveIdentity method handler bound to the given store.
|
|
146
|
+
*/
|
|
147
|
+
export function makeRemoveIdentityHandler(options: BindRoleMethodsOptions) {
|
|
148
|
+
const { store, onMutation, onAudit } = options;
|
|
149
|
+
|
|
150
|
+
return async function _removeIdentity(
|
|
151
|
+
this: UAMethod,
|
|
152
|
+
inputArguments: Variant[],
|
|
153
|
+
context: ISessionContext
|
|
154
|
+
): Promise<CallMethodResultOptions> {
|
|
155
|
+
// 1. The SecureChannel must be encrypted
|
|
156
|
+
const insecure = checkEncryptedChannel(context);
|
|
157
|
+
if (insecure) return insecure;
|
|
158
|
+
|
|
159
|
+
// 2. Security check
|
|
160
|
+
const denied = checkSecurityAdminAccess(context);
|
|
161
|
+
if (denied) return denied;
|
|
162
|
+
|
|
163
|
+
// 3. Extract the rule from input
|
|
164
|
+
const rule = extractIdentityRule(inputArguments);
|
|
165
|
+
if (!rule) {
|
|
166
|
+
return { statusCode: StatusCodes.BadInvalidArgument };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 4. The parent of this method is the Role object
|
|
170
|
+
const roleNode = this.parent;
|
|
171
|
+
if (!roleNode) {
|
|
172
|
+
return { statusCode: StatusCodes.BadInternalError };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 5-6. Compute the outcome of the authorized rule-change attempt
|
|
176
|
+
let statusCode: StatusCode;
|
|
177
|
+
if (isImmutableRole(roleNode.nodeId)) {
|
|
178
|
+
statusCode = StatusCodes.BadRequestNotAllowed;
|
|
179
|
+
} else {
|
|
180
|
+
statusCode = store.removeIdentity(roleNode.nodeId, rule) ? StatusCodes.Good : StatusCodes.BadNoMatch;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (statusCode === StatusCodes.Good && onMutation) {
|
|
184
|
+
await onMutation();
|
|
185
|
+
}
|
|
186
|
+
onAudit?.({
|
|
187
|
+
method: "RemoveIdentity",
|
|
188
|
+
roleNodeId: roleNode.nodeId,
|
|
189
|
+
methodNodeId: this.nodeId,
|
|
190
|
+
userName: context.getUserName(),
|
|
191
|
+
inputArguments,
|
|
192
|
+
statusCode
|
|
193
|
+
});
|
|
194
|
+
return { statusCode };
|
|
195
|
+
};
|
|
196
|
+
}
|