Repository navigation
Server Access Control
Milo 1.2.0-RC1, Java 17. API baseline 69cb25087804.
After Server security, every Session on the thermostat carries an authenticated user, but any of those users can still change Setpoint and call AdjustSetpoint. This page shows how to decide what each Session may do, for example letting only operators make those changes, and how to keep sampled read permissions current when your policy changes.
Milo authorizes in three layers. The identity validator described in Server security establishes an Identity for a Session. A RoleMapper maps that identity, and optionally the client's application URI and endpoint, to role NodeIds. Then the access controller checks each service operation against the node's effective attributes for that Session, such as UserAccessLevel and UserExecutable. Authentication success alone does not grant or deny an operation, because the decision comes from those attributes.
Roles and RolePermissions metadata do not change UserAccessLevel, UserWriteMask, UserExecutable, or UserRolePermissions on ordinary UaNode instances, so your application exposes them from its own policy, usually through attribute filters that consult the current Session. Session.getRoleIds() consults the role mapper on each call, so a filter that asks for roles always sees the current mapping.
The default controller uses these attributes.
| Attribute | Default controller use |
|---|---|
AccessRestrictions |
Channel signing and encryption requirements. Browse checks them only when the Browse flag applies. |
AccessLevel, then UserAccessLevel
|
CurrentRead and CurrentWrite for Value reads and writes. |
UserWriteMask |
Non-Value attribute writes. |
UserExecutable |
Method execution. |
UserRolePermissions |
Role permissions for Browse, Call, AddReferences, DeleteReferences, DeleteNodes, and certain protected attributes. Call checks roles only when both the Object and Method expose this attribute, and then requires Call permission on both. |
A role check runs only when the Session has roles and the node exposes user role permissions. Missing metadata is not deny-by-default, so RolePermissions alone is not an access policy. Check every operation your policy intends to restrict, including Method calls and browsing.
On the thermostat, any user should be able to read Temperature and Setpoint, but only operators should write Setpoint or call AdjustSetpoint. That takes a role mapper that recognizes operators and an attribute filter that narrows the effective attributes for everyone else.
The role mapper gives usernames in an application-supplied operators set the Operator well-known role and other usernames the AuthenticatedUser role. Every other identity, including anonymous, gets the Anonymous role, so the anonymous and unknown-user cases are explicit and cannot pick up rights by accident. Without a mapper installed through OpcUaServerConfigBuilder.setRoleMapper(...), Session.getRoleIds() is empty and the default controller performs no role checks.
RoleMapper roleMapper =
identity -> {
if (identity instanceof Identity.UsernameIdentity user) {
if (operators.contains(user.getUsername())) {
return List.of(NodeIds.WellKnownRole_Operator);
}
return List.of(NodeIds.WellKnownRole_AuthenticatedUser);
}
// Anonymous and any other identity get the least-privileged role.
return List.of(NodeIds.WellKnownRole_Anonymous);
};
builder.setRoleMapper(roleMapper);The filter checks the requesting Session's roles. Operators, and internal reads that have no Session, see the node's own attribute values. For everyone else, the filter removes CurrentWrite from UserAccessLevel and reports UserExecutable as false. Because it only removes rights, a change to a node's stored attributes still applies to every Session.
static final class OperatorOnlyFilter implements AttributeFilter {
@Override
public @Nullable Object getAttribute(AttributeFilterContext ctx, AttributeId attributeId) {
Object value = ctx.getAttribute(attributeId);
if (attributeId == AttributeId.UserAccessLevel && value instanceof UByte stored) {
if (isOperatorOrInternal(ctx)) {
return stored;
}
EnumSet<AccessLevel> levels = AccessLevel.fromValue(stored);
levels.remove(AccessLevel.CurrentWrite);
return AccessLevel.toValue(levels);
}
if (attributeId == AttributeId.UserExecutable && !isOperatorOrInternal(ctx)) {
return false;
}
return value;
}
private static boolean isOperatorOrInternal(AttributeFilterContext ctx) {
Optional<Session> session = ctx.getSession();
if (session.isEmpty()) {
return true;
}
List<NodeId> roleIds = session.get().getRoleIds().orElse(List.of());
return roleIds.contains(NodeIds.WellKnownRole_Operator);
}
}Add one filter instance to both nodes when the thermostat namespace creates them. Here setpoint is the Setpoint UaVariableNode and adjustSetpoint the AdjustSetpoint UaMethodNode from Your first server.
var operatorOnly = new OperatorOnlyFilter();
setpoint.getFilterChain().addLast(operatorOnly);
adjustSetpoint.getFilterChain().addLast(operatorOnly);When a user who is not an operator connects, reading Setpoint and Temperature still returns Good. Writing Setpoint returns Bad_UserAccessDenied, and so does calling AdjustSetpoint, because the default controller checks UserAccessLevel for Value writes and UserExecutable for Method calls. An operator's writes and calls succeed as before. Temperature stays Bad_NotWritable for everyone, because its AccessLevel lacks CurrentWrite and the controller checks AccessLevel first.
server.getAccessControlManager() owns the controller, the read cache, and invalidation listeners. The service layer asks the controller before address-space dispatch for Read, Write, Browse, Call, CreateMonitoredItems, DeleteNodes, AddReferences, and DeleteReferences. TransferSubscriptions rechecks read access for transferred data items. Direct address-space calls are below this service authorization boundary, so code that calls the address space directly needs its own checks. The services the controller does not cover are listed under Known limitations.
Install a custom controller through setAccessControllerFactory(...) when the attribute model cannot express your policy. It can wrap DefaultAccessController. Preserve the baseline denials and AccessResult.NODE_UNKNOWN, because unknown means no decision, not a fresh grant.
Authorization filters such as OperatorOnlyFilter run on every attribute read of their node. They must be quick and thread-safe, and must not recursively call the controller whose attribute read they are answering. Evaluate the Session's current identity and endpoint on each call. Session-listener callbacks are asynchronous and arrive too late to be the sole source of truth for identity-change rechecks.
A data MonitoredItem can be created even when CurrentRead is denied. The item then reports Bad_NotReadable or Bad_UserAccessDenied, with no value, through Publish. Creation does fail for insufficient channel security. An event item has no sampled DataValue to carry a denial, so creation rejects a denied event-monitoring request.
Each data item keeps a read-access decision. setValue() gates delivery on that stored decision and does not perform a new permission check, and framework sampling refreshes the decision before a cycle. After access is restored, the next value can be reported even if it equals the value seen before denial.
Read, Write, Call, Browse, and CreateMonitoredItems ask the controller on every request and never use the read-access cache. Because the operator filter affects only writes and Method calls, changing the thermostat's operators set needs no extra step, and the next Write or Call sees the new roles. Read access for sampled MonitoredItems is different, because samplers can cache it.
The default sampling policy checks read access every cycle. ReadAccessPolicy.cached() instead uses a cache keyed by Session object, NodeId, and attribute. Entries have no time-to-live or size limit. Index range and encoding are not part of the key, so rules depending on them need another policy. Only cached-policy samplers and components using getReadAccessCache() require invalidation to see a change. The Sampling configuration example selects the cached policy.
Suppose the thermostat namespace selects the cached policy, and Setpoint starts readable and monitored. While a technician recalibrates the device, Setpoint must become unreadable for every Session. Change the effective attribute, then invalidate the cached decisions for that node. AccessLevel is in org.eclipse.milo.opcua.sdk.core.
setpoint.setUserAccessLevel(AccessLevel.toValue(AccessLevel.NONE));
server.getAccessControlManager().invalidateReadAccess(setpoint.getNodeId());Reads report Bad_UserAccessDenied as soon as the attribute changes. Without invalidation, a cached sampler keeps its earlier grant, and invalidation lets the next framework refresh publish the denial. To restore access, update the effective attribute and invalidate again. Direct Reads become Good at once, but a cached denial persists until invalidation and refresh.
This static change applies to everyone. A per-Session filter should instead invalidate the scope affected by its own inputs, not change a shared attribute to represent one user's permissions. Removing a node and adding another with the same NodeId also requires invalidation.
invalidateReadAccess(ReadAccessScope) accepts scopes created by ReadAccessScope.node(...), nodes(...), namespace(...), or matching(...), and forSession(session) narrows any scope to one Session. The NodeId, Session, and server-wide overloads are shortcuts. A matching predicate runs under the cache write lock, so it must be fast and nonblocking.
The SDK invalidates relevant cached state on Session identity or endpoint changes, subscription transfers, and Session closure. Your application invalidates its own changes to roles, ACLs, device locks, effective attributes, and custom policy inputs.
Invalidation only drops decisions and notifies listeners. It does not wake samplers, cancel in-flight device reads, immediately update all items, or purge queued notifications, so Good values queued before an ordinary revocation can still be delivered. SDK Session identity or endpoint changes and transfers do discard affected queued values before rechecking. Republish messages already sent remain.
Exceptions, missing answers, and unknown nodes can preserve a previous decision, so a retained grant does not guarantee fail-closed revocation. For immediate revocation, coordinate application policy changes, refresh, and delivery. External samplers also need a refresher even while their source is quiet. See Sampling.
Each operation gets its own status, so inspect per-operation results even when the request succeeds.
| Status | Where it appears | Cause |
|---|---|---|
Bad_NotReadable |
Read, or a data MonitoredItem's notifications | The Variable's AccessLevel lacks CurrentRead. |
Bad_UserAccessDenied |
Read, Write, Call, or a data MonitoredItem's notifications | The Session's UserAccessLevel lacks CurrentRead or CurrentWrite, UserExecutable is false, or a role permission check failed. |
Bad_NotWritable |
Write | The Variable's AccessLevel lacks CurrentWrite. |
Bad_SecurityModeInsufficient |
Controller-checked services, including CreateMonitoredItems |
AccessRestrictions require signing or encryption the channel does not provide. |
If a sampled item keeps delivering values after you revoke read access, check whether the sampler uses the cached policy and whether you called invalidateReadAccess for the affected scope.
At this baseline, the default controller does not cover every service.
- HistoryRead, HistoryUpdate, AddNodes, and TranslateBrowsePathsToNodeIds have no controller check. Enforce policy for those operations in your address space. For history, see Historical access.
- Path translation can resolve a NodeId that Browse hides.
- BrowseNext returns the remainder that Browse already filtered and does not recheck access, so permissions revoked between pages do not filter those references.
- Per-event authorization is application work.
The thermostat now decides what each user may do. To let clients find its secured endpoints, and to see what Local Discovery Server registration involves, continue with Discovery.
Related: Server SDK · Server security · Sampling · Data access
User guide for Milo 1.2.0-RC1 at source 69cb25087, 2026-10-12. Home · Getting started · Examples · Troubleshooting · Release notes
- Home
- Start here
- Client guide
- Server guide
- Feature guides
- Reference
- Operations
- Examples
- Release notes
- Contributing