Skip to content

Access control & roles

Access control and authentication

o6.AccessControl maps to open62541's UA_AccessControl plugin. Subclass it to authenticate sessions or restrict individual operations, then pass the instance to o6.Server. Authorization callbacks receive an o6.Session proxy. Its context is the object returned by activateSession; the proxy also provides session attributes, assigned roles, and close().

import o6
from o6.ns import ns0

class PasswordAccessControl(o6.AccessControl):
    def __init__(self):
        super().__init__(anonymous=True, username=True)

    def activateSession(self, endpoint, remoteCertificate, session, userIdentityToken):
        if isinstance(userIdentityToken, ns0.datatypes.AnonymousIdentityToken):
            return {"username": None}
        if (
            isinstance(userIdentityToken, ns0.datatypes.UserNameIdentityToken)
            and userIdentityToken.userName == "user1"
            and userIdentityToken.password == b"password"
        ):
            return {"username": userIdentityToken.userName}
        raise o6.StatusCodeError(o6.StatusCode.BAD_USER_ACCESS_DENIED)

server = o6.Server(
    accessControl=PasswordAccessControl(),
    allowNonePolicyPassword=True,
)

Warning

The hook names are lowerCamelCase — activateSession, closeSession, allowBrowseNode, and so on. A method named activate_session does not override anything: it is never called, the permissive/anonymous-only base implementation runs instead, and nothing warns you. If authentication seems to be ignored, check the spelling first. (Older parameter names such as session_id are still adapted automatically; only the method name matters.)

Rejecting a session is raise o6.StatusCodeError(...); the client sees the status code from its connect() call. Returning normally accepts it, and whatever you return becomes session.context.

The remaining hooks are authorization rather than authentication, and all have permissive defaults: closeSession, getUserRightsMask, getUserAccessLevel, getUserExecutable, getUserExecutableOnObject, allowAddNode, allowAddReference, allowDeleteNode, allowDeleteReference, allowBrowseNode, allowCreateSubscription, allowTransferSubscription, allowHistoryUpdate, and allowHistoryDelete.

allowNonePolicyPassword is deliberately separate from access control. It permits password tokens on an unencrypted endpoint and should normally only be enabled for local tests. Access control and this transport setting cannot be replaced after the server starts.

Inherited authorization hooks use native C defaults. Only hooks overridden by the concrete subclass cross into Python, so a plugin that overrides only activateSession and closeSession does not acquire the GIL for routine reads, writes, browses, or method calls.

Access-control subclasses using the earlier (session_id, session_context, ...) hook signatures continue to work. New code should use the Session form.

The Session proxy

Session is a resolving proxy rather than a retained native pointer. Every operation validates its NodeId against the server, so using it after the remote session closes raises BadSessionIdInvalid safely.

session.id                     # NodeId of the session
session.context                # whatever activateSession returned
session.set("shift", "night")  # server-side session attributes
session.get("shift")
session.delete("shift")        # a later get() raises BadNotFound
session.roles                  # tuple[Role, ...]
session.roles = (o6.roles.engineer,)
session.close()                # terminate the session

Attribute keys are QualifiedName values or plain strings, which are promoted to namespace 0. Note that closing a session does not lock the client out — a client with automatic reconnection simply activates a new one.

Role-based access control

Permissions are o6.Permission flags and may be combined with |. The well-known OPC UA roles are available from o6.roles. Node permissions live behind the _permissions member of a node handle:

temperature._permissions = {
    o6.roles.observer: o6.Permission.BROWSE | o6.Permission.READ,
    o6.roles.operator: (
        o6.Permission.BROWSE | o6.Permission.READ | o6.Permission.WRITE
    ),
}

temperature._permissions.grant(
    o6.roles.engineer,
    o6.Permission.READ | o6.Permission.WRITE,
    recursive=True,
)

Warning

The leading underscore is part of the name. Every public helper on a node handle carries one, because unprefixed attribute access is reserved for browsing to child nodes by BrowseName. Assigning to node.permissions silently creates an ordinary Python attribute and changes no permissions at all.

The object behind _permissions supports the full set of operations, each of which also takes recursive=True to apply to the whole subtree:

temperature._permissions.get()                                    # dict[Role, Permission]
temperature._permissions.set({o6.roles.observer: o6.Permission.BROWSE})
temperature._permissions.grant(role, o6.Permission.READ, overwrite=False)
temperature._permissions.revoke(role, o6.Permission.WRITE)
temperature._permissions.clear()

Roles can be registered and resolved through server.roles:

maintenance = server.roles.add(
    o6.Role(
        "Maintenance",
        identities=(
            ns0.datatypes.IdentityMappingRuleType(
                criteriaType=ns0.datatypes.IdentityCriteriaType.USER_NAME,
                criteria="maintainer",
            ),
        ),
    )
)

add returns the role as the server stored it, with its assigned NodeId filled in. server.roles[key] looks a role up by NodeId, QualifiedName, or plain name; server.roles.update(role) and server.roles.remove(role) modify the registry; and iterating server.roles yields every role, including the standard ones the server creates itself.

Namespace defaults apply when a node has no explicit role permissions:

server.ns.set_default_permissions(
    "urn:example:machines",
    {o6.roles.observer: o6.Permission.BROWSE | o6.Permission.READ},
)
server.ns.get_default_permissions("urn:example:machines")

The namespace may be given as a URI or as a numeric index.

Anonymous sessions remain permissive for compatibility. Enable RBAC checks for them explicitly:

server = o6.Server(rbacForAnonymous=True)

Authentication may explicitly assign roles. Assignment is applied immediately after activation, after the server has evaluated the role identity mappings.

def activateSession(self, endpoint, remoteCertificate, session, userIdentityToken):
    user = authenticate(userIdentityToken)
    return o6.SessionActivation(context=user, roles=(maintenance,))