node-opcua-address-space 2.175.3 → 2.176.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/dist/source/interfaces/nodeset_loader_options.d.ts +33 -0
- package/dist/source/loader/load_nodeset2.js +91 -12
- package/dist/source/loader/load_nodeset2.js.map +1 -1
- package/dist/source/loader/make_xml_extension_object_parser.js +62 -0
- package/dist/source/loader/make_xml_extension_object_parser.js.map +1 -1
- package/dist/source/session_context.d.ts +90 -4
- package/dist/source/session_context.js +181 -9
- package/dist/source/session_context.js.map +1 -1
- package/dist/src/base_node_impl.js +49 -32
- package/dist/src/base_node_impl.js.map +1 -1
- package/dist/src/base_node_private.js +6 -1
- package/dist/src/base_node_private.js.map +1 -1
- package/dist/src/nodeset_tools/nodeset_to_xml.js +47 -4
- package/dist/src/nodeset_tools/nodeset_to_xml.js.map +1 -1
- package/dist/src/ua_variable_impl_ext_obj.js +6 -1
- package/dist/src/ua_variable_impl_ext_obj.js.map +1 -1
- package/dist/tsconfig_base.tsbuildinfo +1 -1
- package/distHelpers/mock_session.d.ts +68 -1
- package/distHelpers/mock_session.js +58 -0
- package/distHelpers/mock_session.js.map +1 -1
- package/package.json +37 -37
- package/source/interfaces/nodeset_loader_options.ts +34 -0
- package/source/loader/load_nodeset2.ts +105 -13
- package/source/loader/make_xml_extension_object_parser.ts +67 -0
- package/source/session_context.ts +262 -12
- package/src/base_node_impl.ts +54 -37
- package/src/base_node_private.ts +4 -1
- package/src/nodeset_tools/nodeset_to_xml.ts +50 -4
- package/src/ua_variable_impl_ext_obj.ts +6 -1
|
@@ -3,13 +3,31 @@
|
|
|
3
3
|
*/
|
|
4
4
|
/** biome-ignore-all lint/style/useLiteralEnumMembers: still needed */
|
|
5
5
|
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
BaseNode,
|
|
8
|
+
IAddressSpace,
|
|
9
|
+
ISessionBase,
|
|
10
|
+
ISessionContext,
|
|
11
|
+
UAObject,
|
|
12
|
+
UAObjectType,
|
|
13
|
+
UAVariable
|
|
14
|
+
} from "node-opcua-address-space-base";
|
|
7
15
|
import { assert } from "node-opcua-assert";
|
|
16
|
+
import type { UAString } from "node-opcua-basic-types";
|
|
8
17
|
import { ObjectIds } from "node-opcua-constants";
|
|
9
18
|
import { type Certificate, type CertificateInternals, exploreCertificate } from "node-opcua-crypto/web";
|
|
10
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
AccessRestrictionsFlag,
|
|
21
|
+
AttributeIds,
|
|
22
|
+
allPermissions,
|
|
23
|
+
BrowseDirection,
|
|
24
|
+
NodeClass,
|
|
25
|
+
PermissionFlag,
|
|
26
|
+
type QualifiedNameLike,
|
|
27
|
+
type QualifiedNameOptions
|
|
28
|
+
} from "node-opcua-data-model";
|
|
11
29
|
import type { PreciseClock } from "node-opcua-date-time";
|
|
12
|
-
import {
|
|
30
|
+
import { NodeId, type NodeIdLike, resolveNodeId, sameNodeId } from "node-opcua-nodeid";
|
|
13
31
|
import {
|
|
14
32
|
AnonymousIdentityToken,
|
|
15
33
|
MessageSecurityMode,
|
|
@@ -135,11 +153,34 @@ export interface IRoleResolver {
|
|
|
135
153
|
resolveRoles(userIdentityToken: AnyUserIdentityToken, context?: IRoleResolutionContext): NodeId[];
|
|
136
154
|
}
|
|
137
155
|
|
|
156
|
+
/**
|
|
157
|
+
* What to do when a permission cannot be resolved for a Session — either because no Role
|
|
158
|
+
* could be attached to its identity, or because neither the node nor its namespace declares
|
|
159
|
+
* any RolePermissions.
|
|
160
|
+
*
|
|
161
|
+
* - `"allow"` : grant every permission. This is what node-opcua has always done, and what
|
|
162
|
+
* the vast majority of address spaces need, since almost no server declares
|
|
163
|
+
* RolePermissions on its own nodes.
|
|
164
|
+
* - `"deny"` : grant nothing. Fail-closed, for products that drive access entirely from
|
|
165
|
+
* declared policy. Expect to set DefaultRolePermissions on every namespace,
|
|
166
|
+
* otherwise the address space becomes unreadable.
|
|
167
|
+
*
|
|
168
|
+
* Note that this governs *Sessions only*. A SessionContext with no Session at all is an
|
|
169
|
+
* in-process caller (SessionContext.defaultContext, PseudoSession) and stays permissive
|
|
170
|
+
* whatever this is set to — see {@link SessionContext.getPermissions}.
|
|
171
|
+
*/
|
|
172
|
+
export type UnresolvedPermissionPolicy = "allow" | "deny";
|
|
173
|
+
|
|
138
174
|
export interface IServerBase {
|
|
139
175
|
userManager?: IUserManager;
|
|
140
176
|
rolePolicyOverride?: IRolePolicyOverride | null;
|
|
141
177
|
/** Additional role resolvers (identity stores, LDAP, etc.) */
|
|
142
178
|
roleResolvers?: IRoleResolver[];
|
|
179
|
+
/**
|
|
180
|
+
* how to treat a permission that could not be resolved for a Session.
|
|
181
|
+
* @default "allow"
|
|
182
|
+
*/
|
|
183
|
+
unresolvedPermissionPolicy?: UnresolvedPermissionPolicy;
|
|
143
184
|
}
|
|
144
185
|
|
|
145
186
|
export interface SessionContextOptions {
|
|
@@ -148,9 +189,15 @@ export interface SessionContextOptions {
|
|
|
148
189
|
server?: IServerBase /* OPCUAServer*/;
|
|
149
190
|
}
|
|
150
191
|
|
|
151
|
-
function getPermissionForRole(
|
|
192
|
+
function getPermissionForRole(
|
|
193
|
+
rolePermissions: RolePermissionType[] | null,
|
|
194
|
+
role: NodeId,
|
|
195
|
+
unresolved: PermissionFlag
|
|
196
|
+
): PermissionFlag {
|
|
152
197
|
if (rolePermissions === null) {
|
|
153
|
-
|
|
198
|
+
// neither the node nor its namespace declares any policy: nothing to match the
|
|
199
|
+
// Role against, so the answer is the caller's fail-open / fail-closed default
|
|
200
|
+
return unresolved;
|
|
154
201
|
}
|
|
155
202
|
const a = rolePermissions.find((r) => {
|
|
156
203
|
if (!r.roleId) {
|
|
@@ -221,14 +268,157 @@ function getDefaultUserRolePermissionsOnNamespace(
|
|
|
221
268
|
return null;
|
|
222
269
|
}
|
|
223
270
|
|
|
224
|
-
|
|
271
|
+
/**
|
|
272
|
+
* A Role, designated either by its NodeId or by its BrowseName.
|
|
273
|
+
*
|
|
274
|
+
* Roles outside namespace 0 (the GDS Roles, or any Role added with AddRole) have
|
|
275
|
+
* NodeIds that depend on the order in which nodesets were loaded, so naming them is
|
|
276
|
+
* more robust than hard coding `ns=1;i=1661`. Resolving a BrowseName requires an
|
|
277
|
+
* address space, since the RoleSet is where the answer lives.
|
|
278
|
+
*
|
|
279
|
+
* A QualifiedName whose namespaceIndex is left undefined matches in any namespace,
|
|
280
|
+
* as long as the BrowseName is unambiguous.
|
|
281
|
+
*/
|
|
282
|
+
export type RoleIdLike = NodeIdLike | QualifiedNameLike;
|
|
283
|
+
|
|
284
|
+
/** "i=15668", "ns=1;i=1661", "ns=2;s=MyRole", "g=...", "b=..." */
|
|
285
|
+
const nodeIdExpressionRegExp = /^(ns=\d+;)?[isgb]=/;
|
|
286
|
+
/** "1:DiscoveryAdmin" */
|
|
287
|
+
const namespacedBrowseNameRegExp = /^(\d+):(.+)$/;
|
|
288
|
+
/** the left-hand half of a NodeId that a naive split(";") has torn in two */
|
|
289
|
+
const namespacePrefixRegExp = /^ns=\d+$/;
|
|
290
|
+
|
|
291
|
+
const roleSetNodeId = resolveNodeId(ObjectIds.Server_ServerCapabilities_RoleSet);
|
|
292
|
+
const anonymousRoleNodeId = resolveNodeId(WellKnownRoles.Anonymous);
|
|
293
|
+
|
|
294
|
+
function isQualifiedName(role: RoleIdLike): role is QualifiedNameOptions {
|
|
295
|
+
return typeof role === "object" && role !== null && !(role instanceof NodeId) && "name" in role;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* split a semicolon separated list of Roles.
|
|
300
|
+
*
|
|
301
|
+
* NodeId expressions contain a semicolon of their own ("ns=1;i=1661"), so a plain
|
|
302
|
+
* split() would tear them in half: glue the two pieces back together.
|
|
303
|
+
*/
|
|
304
|
+
function splitRoleList(roleIds: string): string[] {
|
|
305
|
+
const roles: string[] = [];
|
|
306
|
+
for (const token of roleIds.split(";")) {
|
|
307
|
+
const role = token.trim();
|
|
308
|
+
if (role.length === 0) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const previous = roles.length > 0 ? roles[roles.length - 1] : undefined;
|
|
312
|
+
if (previous !== undefined && namespacePrefixRegExp.test(previous)) {
|
|
313
|
+
roles[roles.length - 1] = `${previous};${role}`;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
roles.push(role);
|
|
317
|
+
}
|
|
318
|
+
return roles;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** the standard Roles of OPC 10000-3, which live in namespace 0 and need no address space */
|
|
322
|
+
function tryResolveWellKnownRole(browseName: string): NodeId | null {
|
|
323
|
+
const name = browseName.startsWith("WellKnownRole_") ? browseName.slice("WellKnownRole_".length) : browseName;
|
|
324
|
+
try {
|
|
325
|
+
return resolveNodeId(`WellKnownRole_${name}`);
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* look a Role up by BrowseName in the RoleSet (OPC 10000-18 §4.3), which is where
|
|
333
|
+
* both the standard Roles and the ones contributed by companion nodesets are listed.
|
|
334
|
+
*/
|
|
335
|
+
function resolveRoleByBrowseName(
|
|
336
|
+
browseName: UAString | undefined,
|
|
337
|
+
namespaceIndex: number | undefined,
|
|
338
|
+
addressSpace: IAddressSpace | undefined
|
|
339
|
+
): NodeId {
|
|
340
|
+
if (!browseName) {
|
|
341
|
+
throw new Error("makeRoles: a Role given as a QualifiedName must carry a name");
|
|
342
|
+
}
|
|
343
|
+
if (!addressSpace) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`makeRoles: cannot resolve the Role "${browseName}": it is not a standard Role of namespace 0, ` +
|
|
346
|
+
"so an address space must be passed as the second argument of makeRoles()"
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const roleSet = addressSpace.findNode(roleSetNodeId);
|
|
351
|
+
if (!roleSet) {
|
|
352
|
+
throw new Error(`makeRoles: cannot resolve the Role "${browseName}": the address space has no RoleSet`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const candidates = roleSet
|
|
356
|
+
.findReferencesExAsObject("HasComponent", BrowseDirection.Forward)
|
|
357
|
+
.filter((node) => node.nodeClass === NodeClass.Object && node.browseName.name === browseName)
|
|
358
|
+
.filter((node) => namespaceIndex === undefined || node.browseName.namespaceIndex === namespaceIndex);
|
|
359
|
+
|
|
360
|
+
if (candidates.length === 0) {
|
|
361
|
+
const known = roleSet
|
|
362
|
+
.findReferencesExAsObject("HasComponent", BrowseDirection.Forward)
|
|
363
|
+
.filter((node) => node.nodeClass === NodeClass.Object)
|
|
364
|
+
.map((node) => node.browseName.toString())
|
|
365
|
+
.join(", ");
|
|
366
|
+
throw new Error(`makeRoles: cannot find a Role named "${browseName}" in the RoleSet. Known Roles are: ${known}`);
|
|
367
|
+
}
|
|
368
|
+
if (candidates.length > 1) {
|
|
369
|
+
const ambiguous = candidates.map((node) => node.browseName.toString()).join(", ");
|
|
370
|
+
throw new Error(
|
|
371
|
+
`makeRoles: the Role name "${browseName}" is ambiguous (${ambiguous}), please qualify it with its namespace`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
return candidates[0].nodeId;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function resolveRole(role: RoleIdLike, addressSpace: IAddressSpace | undefined): NodeId {
|
|
378
|
+
if (isQualifiedName(role)) {
|
|
379
|
+
return resolveRoleByBrowseName(role.name, role.namespaceIndex, addressSpace);
|
|
380
|
+
}
|
|
381
|
+
if (typeof role !== "string") {
|
|
382
|
+
return resolveNodeId(role);
|
|
383
|
+
}
|
|
384
|
+
if (nodeIdExpressionRegExp.test(role)) {
|
|
385
|
+
return resolveNodeId(role);
|
|
386
|
+
}
|
|
387
|
+
const namespacedBrowseName = namespacedBrowseNameRegExp.exec(role);
|
|
388
|
+
if (namespacedBrowseName) {
|
|
389
|
+
return resolveRoleByBrowseName(namespacedBrowseName[2], parseInt(namespacedBrowseName[1], 10), addressSpace);
|
|
390
|
+
}
|
|
391
|
+
// a bare name: the standard Roles resolve without an address space, the others need one
|
|
392
|
+
return tryResolveWellKnownRole(role) ?? resolveRoleByBrowseName(role, undefined, addressSpace);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* build the list of Role NodeIds a user is granted, from any of the accepted spellings:
|
|
397
|
+
*
|
|
398
|
+
* ```ts
|
|
399
|
+
* makeRoles(WellKnownRoles.Observer); // a well known Role
|
|
400
|
+
* makeRoles("Observer;Operator"); // semicolon separated names
|
|
401
|
+
* makeRoles("ns=1;i=1661"); // an explicit NodeId
|
|
402
|
+
* makeRoles(["Observer", "ns=1;i=1661"]); // mixed
|
|
403
|
+
* makeRoles("1:DiscoveryAdmin", addressSpace); // BrowseName, namespace index
|
|
404
|
+
* makeRoles("DiscoveryAdmin", addressSpace); // BrowseName, any namespace
|
|
405
|
+
* makeRoles([{ name: "DiscoveryAdmin" }], addressSpace); // idem, as a QualifiedName
|
|
406
|
+
* // when the namespace is known by its URI rather than by its index:
|
|
407
|
+
* makeRoles([{ namespaceIndex: addressSpace.getNamespaceIndex(gdsUri), name: "DiscoveryAdmin" }], addressSpace);
|
|
408
|
+
* ```
|
|
409
|
+
*
|
|
410
|
+
* @param roleIds the Roles to resolve
|
|
411
|
+
* @param addressSpace required only to resolve a Role by BrowseName, since that
|
|
412
|
+
* lookup goes through the RoleSet
|
|
413
|
+
*/
|
|
414
|
+
export function makeRoles(roleIds: RoleIdLike[] | string | WellKnownRoles, addressSpace?: IAddressSpace): NodeId[] {
|
|
225
415
|
if (typeof roleIds === "number") {
|
|
226
416
|
roleIds = [roleIds];
|
|
227
417
|
}
|
|
228
418
|
if (typeof roleIds === "string") {
|
|
229
|
-
roleIds = roleIds
|
|
419
|
+
roleIds = splitRoleList(roleIds);
|
|
230
420
|
}
|
|
231
|
-
return roleIds.map((
|
|
421
|
+
return roleIds.map((role) => resolveRole(role, addressSpace));
|
|
232
422
|
}
|
|
233
423
|
|
|
234
424
|
export class SessionContext implements ISessionContext {
|
|
@@ -318,12 +508,17 @@ export class SessionContext implements ISessionContext {
|
|
|
318
508
|
*/
|
|
319
509
|
public getCurrentUserRoles(): NodeId[] {
|
|
320
510
|
if (!this.session) {
|
|
321
|
-
|
|
511
|
+
// no Session: an in-process caller. getPermissions grants it everything —
|
|
512
|
+
// an empty list here does NOT mean "no rights", see unresolvedPermissions.
|
|
513
|
+
return [];
|
|
322
514
|
}
|
|
323
515
|
|
|
324
516
|
assert(this.session != null, "expecting a session");
|
|
325
517
|
const userIdentityToken = this.session.userIdentityToken;
|
|
326
518
|
if (!userIdentityToken) {
|
|
519
|
+
// a Session that was never activated. Distinct from the case above: this one
|
|
520
|
+
// is remote, and how the empty list is then interpreted depends on
|
|
521
|
+
// IServerBase.unresolvedPermissionPolicy.
|
|
327
522
|
return [];
|
|
328
523
|
}
|
|
329
524
|
|
|
@@ -387,16 +582,71 @@ export class SessionContext implements ISessionContext {
|
|
|
387
582
|
}
|
|
388
583
|
return node.rolePermissions;
|
|
389
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* true when this context carries no Session at all.
|
|
587
|
+
*
|
|
588
|
+
* That is an in-process caller — SessionContext.defaultContext, or a PseudoSession
|
|
589
|
+
* driving the address space from inside the server — not a remote one. Such a caller
|
|
590
|
+
* has already passed whatever authorization its own entry point applies, and the
|
|
591
|
+
* address space is not the place to second-guess it, so it is always granted every
|
|
592
|
+
* permission. This is deliberate, and distinct from a remote Session whose identity
|
|
593
|
+
* merely failed to resolve to a Role: that case is governed by
|
|
594
|
+
* IServerBase.unresolvedPermissionPolicy.
|
|
595
|
+
*/
|
|
596
|
+
private get isInProcessCaller(): boolean {
|
|
597
|
+
return !this.session;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** what an unresolved permission means for this context: everything, or nothing */
|
|
601
|
+
private get unresolvedPermissions(): PermissionFlag {
|
|
602
|
+
if (this.isInProcessCaller) {
|
|
603
|
+
return allPermissions;
|
|
604
|
+
}
|
|
605
|
+
return this.server?.unresolvedPermissionPolicy === "deny" ? PermissionFlag.None : allPermissions;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* the Roles to evaluate permissions against: the ones the user resolved to, plus the
|
|
610
|
+
* Anonymous Role, which every Session stands on.
|
|
611
|
+
*
|
|
612
|
+
* Opc.Ua.NodeSet2.xml names exactly five Roles across its 854 RolePermission entries —
|
|
613
|
+
* Anonymous, SecurityAdmin, ConfigureAdmin and the two SecurityKeyServer ones — and
|
|
614
|
+
* never AuthenticatedUser, Observer, Operator, Engineer or Supervisor. Its 195 Anonymous
|
|
615
|
+
* entries (Browse, Browse|Read, Browse|Call) are therefore not a privilege reserved for
|
|
616
|
+
* unauthenticated Sessions; they are the floor granted to everyone. Read literally, an
|
|
617
|
+
* authenticated user would be unable to browse the Server Object at all, and would have
|
|
618
|
+
* strictly less access than an anonymous one.
|
|
619
|
+
*
|
|
620
|
+
* Adding the Anonymous Role here can only widen a permission set, never narrow it, and
|
|
621
|
+
* only ever to what an unauthenticated Session already has — so it cannot grant anything
|
|
622
|
+
* an attacker could not obtain by simply not authenticating. It leaves
|
|
623
|
+
* getCurrentUserRoles() alone: the identity a Session reports stays truthful, only the
|
|
624
|
+
* permission evaluation gains the baseline.
|
|
625
|
+
*/
|
|
626
|
+
private getRolesForPermissionEvaluation(roles: NodeId[]): NodeId[] {
|
|
627
|
+
if (roles.find((role) => sameNodeId(role, anonymousRoleNodeId))) {
|
|
628
|
+
return roles;
|
|
629
|
+
}
|
|
630
|
+
return [...roles, anonymousRoleNodeId];
|
|
631
|
+
}
|
|
632
|
+
|
|
390
633
|
public getPermissions(node: BaseNode): PermissionFlag {
|
|
391
634
|
const applicableRolePermissions = this.getApplicableRolePermissions(node);
|
|
392
635
|
|
|
393
636
|
const roles = this.getCurrentUserRoles();
|
|
394
637
|
if (roles.length === 0) {
|
|
395
|
-
|
|
638
|
+
// Two very different situations land here:
|
|
639
|
+
// - no Session at all: an in-process caller, always trusted (see isInProcessCaller)
|
|
640
|
+
// - a Session that resolved to no Role, either because it carries no
|
|
641
|
+
// UserIdentityToken (it was never activated) or because its user matched
|
|
642
|
+
// nothing. Permissive by default, for backwards compatibility, but a server
|
|
643
|
+
// can set unresolvedPermissionPolicy: "deny" to fail closed instead.
|
|
644
|
+
return this.unresolvedPermissions;
|
|
396
645
|
}
|
|
646
|
+
const unresolved = this.unresolvedPermissions;
|
|
397
647
|
let orFlags: PermissionFlag = 0;
|
|
398
|
-
for (const role of roles) {
|
|
399
|
-
orFlags = orFlags | getPermissionForRole(applicableRolePermissions, role);
|
|
648
|
+
for (const role of this.getRolesForPermissionEvaluation(roles)) {
|
|
649
|
+
orFlags = orFlags | getPermissionForRole(applicableRolePermissions, role, unresolved);
|
|
400
650
|
}
|
|
401
651
|
return orFlags;
|
|
402
652
|
}
|
package/src/base_node_impl.ts
CHANGED
|
@@ -970,9 +970,14 @@ extends TypedEventEmitter<T> implements BaseNode<T> {
|
|
|
970
970
|
|
|
971
971
|
install_components_as_object_properties(this);
|
|
972
972
|
|
|
973
|
+
// Only this node is new, so only this node has to appear on the parent.
|
|
974
|
+
// Re-walking every child the parent already has made building a folder
|
|
975
|
+
// of N children cost O(N^2) - measured at 7.2s for 3000 children, of
|
|
976
|
+
// which 4.1s was spent re-installing properties that were already there.
|
|
977
|
+
const self = this as BaseNode;
|
|
973
978
|
function install_extra_properties_on_parent(ref: UAReference): void {
|
|
974
979
|
const node = ReferenceImpl.resolveReferenceNode(addressSpace, ref) as BaseNode;
|
|
975
|
-
|
|
980
|
+
install_child_as_object_property(node, self);
|
|
976
981
|
}
|
|
977
982
|
|
|
978
983
|
// make sure parent have extra properties updated
|
|
@@ -1836,52 +1841,64 @@ const reservedNames = {
|
|
|
1836
1841
|
* install hierarchical references as javascript properties
|
|
1837
1842
|
* Components/Properties/Organizes
|
|
1838
1843
|
*/
|
|
1839
|
-
|
|
1840
|
-
|
|
1844
|
+
/**
|
|
1845
|
+
* Expose a single child of `parentObj` as a JavaScript property on it.
|
|
1846
|
+
*
|
|
1847
|
+
* Split out of {@link install_components_as_object_properties} so that adding
|
|
1848
|
+
* one child does not have to re-walk every child the parent already has: that
|
|
1849
|
+
* made building a large folder quadratic. See
|
|
1850
|
+
* {@link BaseNodeImpl.install_extra_properties}.
|
|
1851
|
+
*/
|
|
1852
|
+
function install_child_as_object_property(parentObj: BaseNode | null, child: BaseNode | null): void {
|
|
1853
|
+
// the parent may be unresolvable while a nodeset is still loading, which is
|
|
1854
|
+
// why the whole-parent form guarded it too
|
|
1855
|
+
if (!parentObj || !child) {
|
|
1841
1856
|
return;
|
|
1842
1857
|
}
|
|
1858
|
+
// assumption: we ignore namespace here .
|
|
1859
|
+
const name = lowerFirstLetter(child.browseName.name || "");
|
|
1843
1860
|
|
|
1844
|
-
|
|
1845
|
-
|
|
1861
|
+
if (Object.hasOwn(reservedNames, name)) {
|
|
1862
|
+
// ignore reserved names
|
|
1863
|
+
if (doDebug) {
|
|
1864
|
+
debugLog(chalk.bgWhite.red(`Ignoring reserved keyword ${name}`));
|
|
1865
|
+
}
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1846
1868
|
|
|
1847
|
-
|
|
1869
|
+
// ignore reserved names
|
|
1870
|
+
doDebug && debugLog(`Installing property ${name}`, " on ", parentObj.browseName.toString());
|
|
1848
1871
|
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1872
|
+
const hasProperty = Object.hasOwn(parentObj, name);
|
|
1873
|
+
if (
|
|
1874
|
+
hasProperty &&
|
|
1875
|
+
(parentObj as unknown as Record<string, unknown>)[name] !== null &&
|
|
1876
|
+
(parentObj as unknown as Record<string, unknown>)[name] !== undefined
|
|
1877
|
+
) {
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1855
1880
|
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1881
|
+
Object.defineProperty(parentObj, name, {
|
|
1882
|
+
configurable: true, // set to true, so we can undefine later
|
|
1883
|
+
enumerable: true,
|
|
1884
|
+
// xx writable: false,
|
|
1885
|
+
get() {
|
|
1886
|
+
return child;
|
|
1862
1887
|
}
|
|
1888
|
+
// value: child
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1863
1891
|
|
|
1864
|
-
|
|
1865
|
-
|
|
1892
|
+
function install_components_as_object_properties(parentObj: BaseNode) {
|
|
1893
|
+
if (!parentObj) {
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1866
1896
|
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
hasProperty &&
|
|
1870
|
-
(parentObj as unknown as Record<string, unknown>)[name] !== null &&
|
|
1871
|
-
(parentObj as unknown as Record<string, unknown>)[name] !== undefined
|
|
1872
|
-
) {
|
|
1873
|
-
continue;
|
|
1874
|
-
}
|
|
1897
|
+
const addressSpace = parentObj.addressSpace;
|
|
1898
|
+
const hierarchicalRefs = (parentObj as BaseNodeImpl).findHierarchicalReferences();
|
|
1875
1899
|
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
enumerable: true,
|
|
1879
|
-
// xx writable: false,
|
|
1880
|
-
get() {
|
|
1881
|
-
return child;
|
|
1882
|
-
}
|
|
1883
|
-
// value: child
|
|
1884
|
-
});
|
|
1900
|
+
for (const reference of hierarchicalRefs) {
|
|
1901
|
+
install_child_as_object_property(parentObj, ReferenceImpl.resolveReferenceNode(addressSpace, reference) as BaseNode);
|
|
1885
1902
|
}
|
|
1886
1903
|
}
|
|
1887
1904
|
|
package/src/base_node_private.ts
CHANGED
|
@@ -1107,8 +1107,10 @@ export function _clone<T extends UAObject | UAVariable | UAMethod, O>(
|
|
|
1107
1107
|
}
|
|
1108
1108
|
|
|
1109
1109
|
function _add(_childByNameMap: HierarchicalIndexMap, reference: UAReferenceWithNodeRef) {
|
|
1110
|
-
assert(reference.node); // const targetNode = ReferenceImpl.resolveReferenceNode(addressSpace, reference);
|
|
1111
1110
|
const targetNode = reference.node;
|
|
1111
|
+
// a reference may point to a node that is not (yet) in the address space:
|
|
1112
|
+
// such a dangling reference simply cannot be indexed by browse name.
|
|
1113
|
+
if (!targetNode) return;
|
|
1112
1114
|
const hash = targetNode.browseName?.name || "";
|
|
1113
1115
|
const existing = _childByNameMap.get(hash);
|
|
1114
1116
|
if (existing) {
|
|
@@ -1130,6 +1132,7 @@ function sameRef(a: UAReference, b: UAReference) {
|
|
|
1130
1132
|
}
|
|
1131
1133
|
function _remove(_childByNameMap: HierarchicalIndexMap, reference: UAReferenceWithNodeRef) {
|
|
1132
1134
|
const target = reference.node;
|
|
1135
|
+
if (!target) return;
|
|
1133
1136
|
const hash = target.browseName.name || "";
|
|
1134
1137
|
const existing = _childByNameMap.get(hash);
|
|
1135
1138
|
if (Array.isArray(existing)) {
|
|
@@ -833,11 +833,29 @@ function dumpCommonAttributes(xw: XmlWriter, node: BaseNode) {
|
|
|
833
833
|
xw.writeAttribute("IsAbstract", (node as any).isAbstract ? "true" : "false");
|
|
834
834
|
}
|
|
835
835
|
}
|
|
836
|
-
|
|
836
|
+
// AccessLevel and UserAccessLevel exist in the XSD on UAVariable only. Gate on the node
|
|
837
|
+
// class rather than on the presence of the property: UAVariableTypeImpl happens not to own
|
|
838
|
+
// an accessLevel today, but it does own a `historizing` (see #1550), so a property-presence
|
|
839
|
+
// guard is one refactor away from emitting an attribute the schema rejects.
|
|
840
|
+
if (node.nodeClass === NodeClass.Variable) {
|
|
841
|
+
const variable = node as UAVariable;
|
|
837
842
|
// CurrentRead is by default
|
|
838
|
-
if (
|
|
839
|
-
xw.writeAttribute("AccessLevel",
|
|
843
|
+
if (variable.accessLevel !== currentReadFlag) {
|
|
844
|
+
xw.writeAttribute("AccessLevel", variable.accessLevel.toString());
|
|
840
845
|
}
|
|
846
|
+
// UserAccessLevel is implicitly accessLevel when omitted (see convertUserAccessLevel in the loader),
|
|
847
|
+
// so it only needs to be written down when it further restricts accessLevel.
|
|
848
|
+
if (variable.userAccessLevel !== undefined && variable.userAccessLevel !== variable.accessLevel) {
|
|
849
|
+
xw.writeAttribute("UserAccessLevel", variable.userAccessLevel.toString());
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
// access policy: undefined means "inherit from the namespace", an empty rolePermissions array
|
|
853
|
+
// means "this node deliberately grants nothing".
|
|
854
|
+
if (node.accessRestrictions !== undefined) {
|
|
855
|
+
xw.writeAttribute("AccessRestrictions", node.accessRestrictions.toString());
|
|
856
|
+
}
|
|
857
|
+
if (node.rolePermissions !== undefined && node.rolePermissions.length === 0) {
|
|
858
|
+
xw.writeAttribute("HasNoPermissions", "true");
|
|
841
859
|
}
|
|
842
860
|
if (Object.hasOwn(node, "minimumSamplingInterval")) {
|
|
843
861
|
const minimumSamplingInterval = (node as UAVariable).minimumSamplingInterval;
|
|
@@ -845,12 +863,38 @@ function dumpCommonAttributes(xw: XmlWriter, node: BaseNode) {
|
|
|
845
863
|
xw.writeAttribute("MinimumSamplingInterval", minimumSamplingInterval);
|
|
846
864
|
}
|
|
847
865
|
}
|
|
866
|
+
// Historizing exists in the XSD on UAVariable only: UAVariableType is restricted to
|
|
867
|
+
// DataType/ValueRank/ArrayDimensions. UAVariableTypeImpl nevertheless owns a `historizing`
|
|
868
|
+
// property, so the node class must be tested here rather than the presence of the property.
|
|
869
|
+
// false being the default, the attribute is only emitted when it is set.
|
|
870
|
+
if (node.nodeClass === NodeClass.Variable && (node as UAVariable).historizing) {
|
|
871
|
+
xw.writeAttribute("Historizing", "true");
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// the UANode XSD sequence is DisplayName, Description, Category, Documentation, References,
|
|
876
|
+
// RolePermissions, Extensions: RolePermissions must therefore come after References.
|
|
877
|
+
function _dumpRolePermissions(xw: XmlWriter, node: BaseNode) {
|
|
878
|
+
const rolePermissions = node.rolePermissions;
|
|
879
|
+
if (!rolePermissions || rolePermissions.length === 0) {
|
|
880
|
+
// the empty case is carried by the HasNoPermissions attribute
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
xw.startElement("RolePermissions");
|
|
884
|
+
for (const rolePermission of rolePermissions) {
|
|
885
|
+
xw.startElement("RolePermission");
|
|
886
|
+
xw.writeAttribute("Permissions", rolePermission.permissions.toString());
|
|
887
|
+
xw.text(n(xw, rolePermission.roleId));
|
|
888
|
+
xw.endElement();
|
|
889
|
+
}
|
|
890
|
+
xw.endElement();
|
|
848
891
|
}
|
|
849
892
|
|
|
850
893
|
function dumpCommonElements(xw: XmlWriter, node: BaseNode) {
|
|
851
894
|
_dumpDisplayName(xw, node);
|
|
852
895
|
_dumpDescription(xw, node);
|
|
853
896
|
_dumpReferences(xw, node);
|
|
897
|
+
_dumpRolePermissions(xw, node);
|
|
854
898
|
}
|
|
855
899
|
|
|
856
900
|
function coerceInt64ToInt32(int64: Int64): number {
|
|
@@ -1142,7 +1186,9 @@ function _dumpUAObject(xw: XmlWriter, node: UAObject) {
|
|
|
1142
1186
|
function dumpElementInFolder(xw: XmlWriter, node: BaseNodeImpl) {
|
|
1143
1187
|
const aggregates = node
|
|
1144
1188
|
.getFolderElements()
|
|
1145
|
-
.sort((x: BaseNode, y: BaseNode) =>
|
|
1189
|
+
.sort((x: BaseNode, y: BaseNode) =>
|
|
1190
|
+
(x?.browseName.name?.toString() || 0) > (y?.browseName.name?.toString() || 0) ? 1 : -1
|
|
1191
|
+
);
|
|
1146
1192
|
for (const aggregate of aggregates.sort(sortByNodeId)) {
|
|
1147
1193
|
// do not export node that do not belong to our namespace
|
|
1148
1194
|
if (node.nodeId.namespace !== aggregate.nodeId.namespace) {
|
|
@@ -573,7 +573,12 @@ export function _bindExtensionObject(
|
|
|
573
573
|
uaVariable.$extensionObject?.schema.name
|
|
574
574
|
);
|
|
575
575
|
}
|
|
576
|
-
|
|
576
|
+
// the variable may have been bound at load time with createMissingProp:false ( the loader only
|
|
577
|
+
// binds what the nodeset declares ): a caller explicitly asking for the missing child
|
|
578
|
+
// properties must still get them installed on the existing extension object.
|
|
579
|
+
if (!options.createMissingProp) {
|
|
580
|
+
return uaVariable.$extensionObject;
|
|
581
|
+
}
|
|
577
582
|
}
|
|
578
583
|
|
|
579
584
|
if (uaVariable.dataTypeObj.isAbstract) {
|