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.
@@ -3,13 +3,31 @@
3
3
  */
4
4
  /** biome-ignore-all lint/style/useLiteralEnumMembers: still needed */
5
5
 
6
- import type { BaseNode, ISessionBase, ISessionContext, UAObject, UAObjectType, UAVariable } from "node-opcua-address-space-base";
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 { AccessRestrictionsFlag, AttributeIds, allPermissions, PermissionFlag } from "node-opcua-data-model";
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 { type NodeId, type NodeIdLike, resolveNodeId, sameNodeId } from "node-opcua-nodeid";
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(rolePermissions: RolePermissionType[] | null, role: NodeId): PermissionFlag {
192
+ function getPermissionForRole(
193
+ rolePermissions: RolePermissionType[] | null,
194
+ role: NodeId,
195
+ unresolved: PermissionFlag
196
+ ): PermissionFlag {
152
197
  if (rolePermissions === null) {
153
- return allPermissions;
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
- export function makeRoles(roleIds: NodeIdLike[] | string | WellKnownRoles): NodeId[] {
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.split(";").map((r) => resolveNodeId(`WellKnownRole_${r}`));
419
+ roleIds = splitRoleList(roleIds);
230
420
  }
231
- return roleIds.map((r) => resolveNodeId(r));
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
- return []; // default context => no Session
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
- return allPermissions;
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
  }
@@ -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
- install_components_as_object_properties(node);
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
- function install_components_as_object_properties(parentObj: BaseNode) {
1840
- if (!parentObj) {
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
- const addressSpace = parentObj.addressSpace;
1845
- const hierarchicalRefs = (parentObj as BaseNodeImpl).findHierarchicalReferences();
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
- const children = hierarchicalRefs.map((r: UAReference) => ReferenceImpl.resolveReferenceNode(addressSpace, r));
1869
+ // ignore reserved names
1870
+ doDebug && debugLog(`Installing property ${name}`, " on ", parentObj.browseName.toString());
1848
1871
 
1849
- for (const child of children) {
1850
- if (!child) {
1851
- continue;
1852
- }
1853
- // assumption: we ignore namespace here .
1854
- const name = lowerFirstLetter(child.browseName.name || "");
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
- if (Object.hasOwn(reservedNames, name)) {
1857
- // ignore reserved names
1858
- if (doDebug) {
1859
- debugLog(chalk.bgWhite.red(`Ignoring reserved keyword ${name}`));
1860
- }
1861
- continue;
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
- // ignore reserved names
1865
- doDebug && debugLog(`Installing property ${name}`, " on ", parentObj.browseName.toString());
1892
+ function install_components_as_object_properties(parentObj: BaseNode) {
1893
+ if (!parentObj) {
1894
+ return;
1895
+ }
1866
1896
 
1867
- const hasProperty = Object.hasOwn(parentObj, name);
1868
- if (
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
- Object.defineProperty(parentObj, name, {
1877
- configurable: true, // set to true, so we can undefine later
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
 
@@ -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
- if (Object.hasOwn(node, "accessLevel")) {
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 ((node as UAVariable).accessLevel !== currentReadFlag) {
839
- xw.writeAttribute("AccessLevel", (node as UAVariable).accessLevel.toString());
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) => ((x?.browseName.name?.toString() || 0) > (y?.browseName.name?.toString() || 0) ? 1 : -1));
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
- return uaVariable.$extensionObject;
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) {