Source code for crossbar.router.role

#####################################################################################
#
#  Copyright (c) typedef int GmbH
#  SPDX-License-Identifier: EUPL-1.2
#
#####################################################################################

from collections.abc import Mapping
from pprint import pformat

from autobahn.util import hltype, hlval
from autobahn.wamp.exception import ApplicationError
from autobahn.wamp.uri import Pattern, convert_starred_uri
from pytrie import StringTrie
from twisted.python.failure import Failure
from txaio import make_logger

__all__ = ("RouterRole", "RouterTrustedRole", "RouterRoleStaticAuth", "RouterRoleDynamicAuth", "RouterRoleLMDBAuth")


class RouterPermissions(object):
    __slots__ = (
        "uri",
        "match",
        "call",
        "register",
        "publish",
        "subscribe",
        "disclose_caller",
        "disclose_publisher",
        "cache",
        "validate",
    )

    def __init__(
        self,
        uri,
        match,
        call=False,
        register=False,
        publish=False,
        subscribe=False,
        disclose_caller=False,
        disclose_publisher=False,
        cache=True,
        validate=None,
    ):
        """

        :param uri: The URI to match.
        """
        assert uri is None or isinstance(uri, str)
        assert match is None or match in ["exact", "prefix", "wildcard"]
        assert isinstance(call, bool)
        assert isinstance(register, bool)
        assert isinstance(publish, bool)
        assert isinstance(subscribe, bool)
        assert isinstance(disclose_caller, bool)
        assert isinstance(disclose_publisher, bool)
        assert isinstance(cache, bool)
        assert validate is None or isinstance(validate, dict)

        self.uri = uri
        self.match = match
        self.call = call
        self.register = register
        self.publish = publish
        self.subscribe = subscribe
        self.disclose_caller = disclose_caller
        self.disclose_publisher = disclose_publisher
        self.cache = cache
        self.validate = validate

    def __repr__(self):
        return 'RouterPermissions(uri="{}", match="{}", call={}, register={}, publish={}, subscribe={}, disclose_caller={}, disclose_publisher={}, cache={}, validate={})'.format(
            self.uri,
            self.match,
            self.call,
            self.register,
            self.publish,
            self.subscribe,
            self.disclose_caller,
            self.disclose_publisher,
            self.cache,
            self.validate,
        )

    def to_dict(self):
        return {
            "uri": self.uri,
            "match": self.match,
            "allow": {
                "call": self.call,
                "register": self.register,
                "publish": self.publish,
                "subscribe": self.subscribe,
            },
            "disclose": {"caller": self.disclose_caller, "publisher": self.disclose_publisher},
            "validate": self.validate,
            "cache": self.cache,
        }

    @staticmethod
    def from_dict(obj):
        assert isinstance(obj, dict)

        uri = obj.get("uri", None)

        # support "starred" URIs:
        if "match" in obj:
            # when a match policy is explicitly configured, the starred URI
            # conversion logic is skipped! we want to preserve the higher
            # expressiveness of regular WAMP URIs plus explicit match policy
            match = obj["match"]
        else:
            # when no explicit match policy is selected, we assume the use
            # of starred URIs and convert to regular URI + detected match policy
            uri, match = convert_starred_uri(uri)

        allow = obj.get("allow", {})
        assert isinstance(allow, dict)
        allow_call = allow.get("call", False)
        allow_register = allow.get("register", False)
        allow_publish = allow.get("publish", False)
        allow_subscribe = allow.get("subscribe", False)

        disclose = obj.get("disclose", {})
        assert isinstance(disclose, dict)
        disclose_caller = disclose.get("caller", False)
        disclose_publisher = disclose.get("publisher", False)

        cache = obj.get("cache", False)

        validate = obj.get("validate", None)
        if validate:
            for k in validate:
                if k not in [
                    "extra",
                    "event",
                    "call",
                    "call_progress",
                    "event_result",
                    "call_result",
                    "call_result_progress",
                    "call_error",
                ]:
                    raise RuntimeError('invalid key "{}" in role-permissions configuration'.format(k))
                if (k == "extra" and not isinstance(validate[k], Mapping)) or (
                    k != "extra" and not isinstance(validate[k], str)
                ):
                    print(type(k), type(validate[k]), k, validate[k])
                    raise RuntimeError(
                        'invalid value type "{}" for key "{}" in role-permissions configuration'.format(
                            type(validate[k]), k
                        )
                    )

        return RouterPermissions(
            uri,
            match,
            call=allow_call,
            register=allow_register,
            publish=allow_publish,
            subscribe=allow_subscribe,
            disclose_caller=disclose_caller,
            disclose_publisher=disclose_publisher,
            cache=cache,
            validate=validate,
        )


[docs] class RouterRole(object): """ Base class for router roles. """
[docs] log = make_logger()
def __init__(self, router, uri, allow_by_default=False): """ Ctor. :param uri: The URI of the role. :type uri: str """
[docs] self.router = router
[docs] self.uri = uri
[docs] self.allow_by_default = allow_by_default
[docs] def authorize(self, session, uri, action, options): """ Authorize a session connected under this role to perform the given action on the given URI. :param session: The WAMP session that requests the action. :type session: Instance of :class:`autobahn.wamp.protocol.ApplicationSession` :param uri: The URI on which to perform the action. :type uri: str :param action: The action to be performed. :type action: str :return: bool -- Flag indicating whether session is authorized or not. """ self.log.debug("CrossbarRouterRole.authorize {uri} {action}", uri=uri, action=action) return self.allow_by_default
[docs] class RouterTrustedRole(RouterRole): """ A router role that is trusted to do anything. This is used e.g. for the service session run internally run by a router. """
[docs] def authorize(self, session, uri, action, options): self.log.debug( "CrossbarRouterTrustedRole.authorize {myuri} {uri} {action} {options}", myuri=self.uri, uri=uri, action=action, options=options, ) return True
[docs] class RouterRoleStaticAuth(RouterRole): """ A role on a router realm that is authorized using a static configuration. """ def __init__(self, router, uri, permissions=None, default_permissions=None): """ :param router: The router this role is defined on. :type router: obj :param uri: The URI of the role. :type uri: unicode :param permissions: A permissions configuration, e.g. a list of permission dicts like `{'uri': 'com.example.*', 'call': True}` :type permissions: list of dict :param default_permissions: The default permissions to apply when no other configured permission matches. The default permissions when not explicitly set is to deny all actions on all URIs! :type default_permissions: dict """ RouterRole.__init__(self, router, uri) assert permissions is None or isinstance(permissions, list) if permissions: for p in permissions: assert isinstance(p, dict) assert default_permissions is None or isinstance(default_permissions, dict) # default permissions (used when nothing else is matching) # note: default permissions have their matching URI and match policy set to None! if default_permissions: self._default = RouterPermissions.from_dict(default_permissions) else: self._default = RouterPermissions( None, None, call=False, register=False, publish=False, subscribe=False, disclose_caller=False, disclose_publisher=False, cache=True, ) # Trie of explicitly configured permissions
[docs] self._permissions = StringTrie()
[docs] self._wild_permissions = StringTrie()
# for "wildcard" URIs, there will be a ".." in them somewhere, # and so we want to match on the biggest prefix # (i.e. everything to the left of the first "..") for obj in permissions or []: perms = RouterPermissions.from_dict(obj) if ".." in perms.uri: trunc = perms.uri[: perms.uri.index("..")] self._wild_permissions[trunc] = perms else: self._permissions[perms.uri] = perms
[docs] def authorize(self, session, uri, action, options): """ Authorize a session connected under this role to perform the given action on the given URI. :param session: The WAMP session that requests the action. :type session: Instance of :class:`autobahn.wamp.protocol.ApplicationSession` :param uri: The URI on which to perform the action. :type uri: str :param action: The action to be performed. :type action: str :return: bool -- Flag indicating whether session is authorized or not. """ try: # longest prefix match of the URI to be authorized against our Trie # of configured URIs for permissions permissions = self._permissions.longest_prefix_value(uri) # if there is a _prefix_ matching URI, check that this is actually the # match policy on the permission (otherwise, apply default permissions)! if permissions.match != "prefix" and uri != permissions.uri: permissions = self._default except KeyError: # workaround because of https://bitbucket.org/gsakkis/pytrie/issues/4/string-keys-of-zero-length-are-not permissions = self._permissions.get("", self._default) # if we found a non-"exact" match, there might be a better one in the wildcards if permissions.match != "exact": try: wildperm = self._wild_permissions.longest_prefix_value(uri) Pattern(wildperm.uri, Pattern.URI_TARGET_ENDPOINT).match(uri) except (KeyError, Exception): # match() raises Exception on no match wildperm = None if wildperm is not None: permissions = wildperm # we now have some permissions, either from matching something # or via self._default if action == "publish": authorization = { "allow": permissions.publish, "disclose": permissions.disclose_publisher, "cache": permissions.cache, } elif action == "subscribe": authorization = {"allow": permissions.subscribe, "cache": permissions.cache} elif action == "call": authorization = { "allow": permissions.call, "disclose": permissions.disclose_caller, "cache": permissions.cache, } elif action == "register": authorization = {"allow": permissions.register, "cache": permissions.cache} else: # should not arrive here raise Exception("logic error") # if the action is allowed, add any application payload validation configuration if authorization["allow"]: authorization["validate"] = permissions.validate self.log.debug( '{func} uri="{uri}", action="{action}", options={options} => authorization=\n{authorization}', func=hltype(self.authorize), uri=hlval(uri), action=hlval(action), options=options, authorization=pformat(authorization), ) return authorization
[docs] class RouterRoleDynamicAuth(RouterRole): """ A role on a router realm that is authorized by calling (via WAMP RPC) an authorizer function provided by the app. """ def __init__(self, router, uri, authorizer): """ :param router: The router to which to add the role :type router: instance of ``crossbar.router.router.Router`` :param id: The URI of the role. :type id: unicode :param authorizer: The dynamic authorizer configuration. :type authorizer: dict """ RouterRole.__init__(self, router, uri) # the URI (identifying name) of the authorizer
[docs] self._uri = uri
# the dynamic authorizer configuration # { # "name": "app", # "authorizer": "com.example.auth" # }
[docs] self._authorizer = authorizer
# the session from which to call the dynamic authorizer: this is # the default service session on the realm
[docs] self._session = router._realm.session
[docs] def authorize(self, session, uri, action, options): """ Authorize a session connected under this role to perform the given action on the given URI. :param session: The WAMP session that requests the action. :type session: Instance of :class:`autobahn.wamp.protocol.ApplicationSession` :param uri: The URI on which to perform the action. :type uri: str :param action: The action to be performed. :type action: str :param options: :type options: :return: bool -- Flag indicating whether session is authorized or not. """ session_details = getattr(session, "_session_details", None) if session_details is None: # this happens for "embedded" sessions -- perhaps we # should have a better way to detect this -- also # session._transport should be a RouterApplicationSession details = { "session": session._session_id, "authid": session._authid, "authrole": session._authrole, "authmethod": session._authmethod, "authprovider": session._authprovider, "authextra": session._authextra, "transport": { "type": "stdio", # or maybe "embedded"? }, } else: _td = session._transport.transport_details.marshal() if session._transport.transport_details else None details = { "session": session_details.session, "authid": session_details.authid, "authrole": session_details.authrole, "authmethod": session_details.authmethod, "authprovider": session_details.authprovider, "authextra": session_details.authextra, "transport": _td, } self.log.debug( "CrossbarRouterRoleDynamicAuth.authorize {uri} {action} {details}", uri=uri, action=action, details=details ) d = self._session.call(self._authorizer, details, uri, action, options) # we could do backwards-compatibility for clients that didn't # yet add the 5th "options" argument to their authorizers like # so: def maybe_call_old_way(result): if isinstance(result, Failure): if isinstance(result.value, ApplicationError): if "takes exactly 4 arguments" in str(result.value): self.log.warn( "legacy authorizer '{auth}'; should take 5 arguments. Calling with 4.", auth=self._authorizer, ) return self._session.call(self._authorizer, session_details, uri, action) return result d.addBoth(maybe_call_old_way) def sanity_check(authorization): """ Ensure the return-value we got from the user-supplied method makes sense """ if isinstance(authorization, dict): # check keys for key in authorization.keys(): if key not in ["allow", "cache", "disclose", "validate"]: return Failure( ValueError( "Authorizer returned unknown key '{key}'".format( key=key, ) ) ) # must have "allow" key if "allow" not in authorization: return Failure(ValueError("Authorizer must have 'allow' in returned dict")) # check bool-valued keys for key in ["allow", "cache", "disclose"]: if key in authorization: value = authorization[key] if not isinstance(value, bool): return Failure(ValueError("Authorizer must have bool for '{}'".format(key))) # check dict-valued keys for key in ["validate"]: if key in authorization: value = authorization[key] if value is not None and not isinstance(value, Mapping): return Failure( ValueError("Authorizer must have dict for '{}' (if present and not null)".format(key)) ) return authorization elif isinstance(authorization, bool): return authorization return Failure( ValueError( "Authorizer returned unknown type '{name}'".format( name=type(authorization).__name__, ) ) ) d.addCallback(sanity_check) return d
[docs] class RouterRoleLMDBAuth(RouterRole): """ A role on a router realm that is authorized from a node LMDB embedded database. """ def __init__(self, router, uri, store): """ :param uri: The URI of the role. :type uri: unicode """ RouterRole.__init__(self, router, uri)
[docs] self._store = store
[docs] def authorize(self, session, uri, action, options): """ Authorize a session connected under this role to perform the given action on the given URI. :param session: The WAMP session that requests the action. :type session: Instance of :class:`autobahn.wamp.protocol.ApplicationSession` :param uri: The URI on which to perform the action. :type uri: unicode :param action: The action to be performed. :type action: unicode :returns: The authorization :rtype: dict """ # FIXME: for the realm the router (self._router) is working for, # and for the role URI on that realm, lookup the authorization # in the respective node LMDB embedded database. returning a # Deferred that fires when the data was loaded from the database. # we expect being able to do millions of lookups per second, so this # should not be a bottleneck. raise Exception("not implemented yet")