#####################################################################################
#
# 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.
"""
def __init__(self, router, uri, allow_by_default=False):
"""
Ctor.
:param uri: The URI of the role.
:type uri: str
"""
[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
# 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]
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")