From e35218523f95b5d0d1bab240b60613b834da26ad Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Wed, 2 Oct 2024 19:55:16 +0200 Subject: [PATCH 01/44] add Authz contextvar --- asab/contextvars.py | 1 + 1 file changed, 1 insertion(+) diff --git a/asab/contextvars.py b/asab/contextvars.py index 18babb99..5920c498 100644 --- a/asab/contextvars.py +++ b/asab/contextvars.py @@ -1,3 +1,4 @@ import contextvars Tenant = contextvars.ContextVar("Tenant") +Authz = contextvars.ContextVar("Authz") From 69e1cb207e8a887647ba5d0ba696413a45a16333 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Wed, 2 Oct 2024 20:00:51 +0200 Subject: [PATCH 02/44] add Authorization object --- asab/web/auth/__init__.py | 2 ++ asab/web/auth/authz.py | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 asab/web/auth/authz.py diff --git a/asab/web/auth/__init__.py b/asab/web/auth/__init__.py index 0784d282..e06a00c1 100644 --- a/asab/web/auth/__init__.py +++ b/asab/web/auth/__init__.py @@ -1,8 +1,10 @@ from .decorator import require, noauth from .service import AuthService +from .authz import Authorization __all__ = ( "AuthService", + "Authorization", "require", "noauth", ) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py new file mode 100644 index 00000000..5aa9ede6 --- /dev/null +++ b/asab/web/auth/authz.py @@ -0,0 +1,25 @@ +import typing + + +class Authorization: + """ + Contains authentication and authorization details, provides methods for checking access control + """ + def __init__(self, auth_service, userinfo: dict, tenant: typing.Optional[str] = None): + self.AuthService = auth_service + self.Userinfo = userinfo + self.CredentialsId = userinfo.get("sub") + self.Username = userinfo.get("preferred_username") or userinfo.get("username") + self.Email = userinfo.get("email") + self.Phone = userinfo.get("phone") + + self.Tenant = tenant + + def has_superuser_access(self): + return self.AuthService.has_superuser_access(self.Userinfo) + + def has_resource_access(self, resource_id: str | typing.Iterable[str]): + if isinstance(resource_id, str): + return self.AuthService.has_resource_access(self.Userinfo, self.Tenant, {resource_id}) + else: + return self.AuthService.has_resource_access(self.Userinfo, self.Tenant, resource_id) From 702afec1300537920cbd4c775b1d8987b558e6d5 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Wed, 2 Oct 2024 20:27:43 +0200 Subject: [PATCH 03/44] move rbac functions to authz --- asab/web/auth/authz.py | 59 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 5aa9ede6..680ad709 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -1,11 +1,14 @@ import typing +SUPERUSER_RES_ID = "authz:superuser" + + class Authorization: """ Contains authentication and authorization details, provides methods for checking access control """ - def __init__(self, auth_service, userinfo: dict, tenant: typing.Optional[str] = None): + def __init__(self, auth_service, userinfo: dict, tenant: typing.Union[str, None]): self.AuthService = auth_service self.Userinfo = userinfo self.CredentialsId = userinfo.get("sub") @@ -16,10 +19,58 @@ def __init__(self, auth_service, userinfo: dict, tenant: typing.Optional[str] = self.Tenant = tenant def has_superuser_access(self): - return self.AuthService.has_superuser_access(self.Userinfo) + if not self.AuthService.is_enabled(): + return True + return has_superuser_access(self.Userinfo) def has_resource_access(self, resource_id: str | typing.Iterable[str]): + if not self.AuthService.is_enabled(): + return True if isinstance(resource_id, str): - return self.AuthService.has_resource_access(self.Userinfo, self.Tenant, {resource_id}) + return has_resource_access(self.Userinfo, {resource_id}, tenant=self.Tenant) else: - return self.AuthService.has_resource_access(self.Userinfo, self.Tenant, resource_id) + return has_resource_access(self.Userinfo, resource_id, tenant=self.Tenant) + + +def has_superuser_access(user_info: typing.Mapping) -> bool: + """ + Check if the superuser resource is present in the authorized resource list. + """ + return SUPERUSER_RES_ID in get_authorized_resources(user_info, tenant=None) + + +def has_resource_access( + user_info: typing.Mapping, + required_resources: typing.Iterable, + tenant: typing.Union[str, None], +) -> bool: + """ + Check if the requested resources or the superuser resource are present in the authorized resource list. + """ + if has_superuser_access(user_info): + return True + + authorized_resources = get_authorized_resources(user_info, tenant) + for resource in required_resources: + if resource not in authorized_resources: + return False + + return True + + +def has_tenant_access(user_info: typing.Mapping, tenant: str) -> bool: + """ + Check if the request is authorized to access a tenant. + If the request has superuser access, tenant access is always implicitly granted. + """ + if tenant == "*": + raise ValueError("Invalid tenant name: '*'") + if has_superuser_access(user_info): + return True + if tenant in user_info.get("resources", {}): + return True + return False + + +def get_authorized_resources(user_info: typing.Mapping, tenant: typing.Union[str, None]): + return set(user_info.get("resources", {}).get(tenant if tenant is not None else "*", [])) From 08e151f5f4e1eddf80e0e40c87035e6978bb8537 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Wed, 2 Oct 2024 20:28:31 +0200 Subject: [PATCH 04/44] refactor auth service, use Authz context var --- asab/web/auth/decorator.py | 12 +- asab/web/auth/service.py | 360 ++++++++++++++++++------------------- 2 files changed, 178 insertions(+), 194 deletions(-) diff --git a/asab/web/auth/decorator.py b/asab/web/auth/decorator.py index 709d6fc9..bcdd282a 100644 --- a/asab/web/auth/decorator.py +++ b/asab/web/auth/decorator.py @@ -3,6 +3,7 @@ import inspect from ...exceptions import AccessDeniedError +from ...contextvars import Authz # @@ -32,14 +33,11 @@ def decorator_require(handler): @functools.wraps(handler) async def wrapper(*args, **kwargs): - request = args[-1] - - if not hasattr(request, "has_resource_access"): - raise Exception( - "Cannot check resource access. Make sure that AuthService is installed and that " - "the handler method does not use both the '@noauth' and the '@require' decorators at once.") + authz = Authz.get() + if authz is None: + raise AccessDeniedError() - if not request.has_resource_access(*resources): + if not authz.has_resource_access(resources): raise AccessDeniedError() return await handler(*args, **kwargs) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 18ad506b..d1bc5b88 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -19,7 +19,8 @@ from ...exceptions import NotAuthenticatedError, AccessDeniedError from ...api.discovery import NotDiscoveredError from ...utils import string_to_boolean -from ...contextvars import Tenant +from ...contextvars import Tenant, Authz +from .authz import Authorization, has_tenant_access try: import jwcrypto.jwk @@ -102,6 +103,7 @@ class AuthService(Service): _PUBLIC_KEYS_URL_DEFAULT = "http://localhost:3081/.well-known/jwks.json" + def __init__(self, app, service_name="asab.AuthService"): super().__init__(app, service_name) self.PublicKeysUrl = Config.get("auth", "public_keys_url") or None @@ -141,6 +143,7 @@ def __init__(self, app, service_name="asab.AuthService"): if self.Mode == AuthMode.ENABLED: self.App.TaskService.schedule(self._fetch_public_keys_if_needed()) + def _prepare_mock_user_info(self): # Load custom user info mock_user_info_path = Config.get("auth", "mock_user_info_path") @@ -220,55 +223,6 @@ async def get_userinfo_from_id_token(self, bearer_token): raise NotAuthenticatedError() - def get_authorized_tenant(self, request) -> typing.Optional[str]: - """ - Get the request's authorized tenant. - """ - if hasattr(request, "_AuthorizedTenants"): - for tenant in request._AuthorizedTenants: - # Return the first authorized tenant - return tenant - return None - - - def has_superuser_access(self, authorized_resources: typing.Iterable) -> bool: - """ - Check if the superuser resource is present in the authorized resource list. - """ - if self.Mode == AuthMode.DISABLED: - return True - return SUPERUSER_RESOURCE in authorized_resources - - - def has_resource_access(self, authorized_resources: typing.Iterable, required_resources: typing.Iterable) -> bool: - """ - Check if the requested resources or the superuser resource are present in the authorized resource list. - """ - if self.Mode == AuthMode.DISABLED: - return True - if self.has_superuser_access(authorized_resources): - return True - for resource in required_resources: - if resource not in authorized_resources: - return False - return True - - - def has_tenant_access( - self, authorized_resources: typing.Iterable, authorized_tenants: typing.Iterable, tenant: str - ) -> bool: - """ - Check if the request is authorized to access a tenant. - If the request has superuser access, tenant access is always implicitly granted. - """ - if self.Mode == AuthMode.DISABLED: - return True - if self.has_superuser_access(authorized_resources): - return True - if tenant in authorized_tenants: - return True - return False - async def _fetch_public_keys_if_needed(self, *args, **kwargs): """ Check if public keys have been fetched from the authorization server and fetch them if not yet. @@ -294,6 +248,7 @@ async def _fetch_public_keys_if_needed(self, *args, **kwargs): # Public keys have been fetched recently return + async def fetch_keys(session): try: async with session.get(self.PublicKeysUrl) as response: @@ -357,7 +312,7 @@ async def fetch_keys(session): L.debug("Public key loaded.", struct_data={"url": self.PublicKeysUrl}) - def _authenticate_request(self, handler): + def _authorize_request(self, handler): """ Authenticate the request by the JWT ID token in the Authorization header. Extract the token claims into request attributes so that they can be used for authorization checks. @@ -365,42 +320,42 @@ def _authenticate_request(self, handler): @functools.wraps(handler) async def wrapper(*args, **kwargs): request = args[-1] - if self.Mode == AuthMode.DISABLED: - user_info = None - elif self.Mode == AuthMode.MOCK: - user_info = self.MockUserInfo - else: - # Extract user info from the request Authorization header - bearer_token = _get_bearer_token(request) - user_info = await self.get_userinfo_from_id_token(bearer_token) + user_info = await self.extract_user_info_from_request(request) + + # Authorize tenant from context + tenant = Tenant.get() + if tenant is not None and self.Mode != AuthMode.DISABLED: + if not has_tenant_access(user_info, tenant): + L.warning("Tenant not authorized.", struct_data={ + "tenant": tenant, "sub": request._UserInfo.get("sub")}) + raise AccessDeniedError() # Add userinfo, tenants and global resources to the request - if self.Mode != AuthMode.DISABLED: - assert user_info is not None - request._UserInfo = user_info - resource_dict = request._UserInfo["resources"] - request._AuthorizedResources = frozenset(resource_dict.get("*", [])) - request._AuthorizedTenants = frozenset(t for t in resource_dict.keys() if t != "*") + if not self.is_enabled(): + response = await handler(*args, **kwargs) else: - request._UserInfo = None - request._AuthorizedResources = None - request._AuthorizedTenants = None + auth = Authorization(self, user_info, tenant) + auth_ctx = Authz.set(auth) + try: + response = await handler(*args, **kwargs) + finally: + Authz.reset(auth_ctx) - # Add access control methods to the request - def has_resource_access(*required_resources: list) -> bool: - return self.has_resource_access(request._AuthorizedResources, required_resources) - request.has_resource_access = has_resource_access + return await handler(*args, **kwargs) - def has_tenant_access(tenant: str) -> bool: - return self.has_tenant_access(request._AuthorizedResources, request._AuthorizedTenants, tenant) - request.has_tenant_access = has_tenant_access + return wrapper - def has_superuser_access() -> bool: - return self.has_superuser_access(request._AuthorizedResources) - request.has_superuser_access = has_superuser_access - return await handler(*args, **kwargs) - return wrapper + async def extract_user_info_from_request(self, request): + if not self.is_enabled(): + user_info = None + elif self.Mode == AuthMode.MOCK: + user_info = self.MockUserInfo + else: + # Extract user info from the request Authorization header + bearer_token = _get_bearer_token(request) + user_info = await self.get_userinfo_from_id_token(bearer_token) + return user_info async def _wrap_handlers(self, aiohttp_app): @@ -449,120 +404,29 @@ def _wrap_handler(self, route): handler = route.handler # Apply the decorators in reverse order (the last applied wrapper affects the request first) + + # 3) Pass authorization attributes to handler method if "resources" in args: handler = _add_resources(handler) if "user_info" in args: handler = _add_user_info(handler) if "tenant" in args: - # TODO: Deprecate tenant ID in path and query, always use X-Tenant header instead. - if tenant_in_path: - handler = self._add_tenant_from_path(handler) - else: - handler = self._add_tenant_from_query(handler) - - handler = self._set_tenant_context_from_header(handler) - - handler = self._authenticate_request(handler) - route._handler = handler - - - def _authorize_tenant_request(self, request, tenant): - """ - Check access to requested tenant and add tenant resources to the request - """ - # Check if tenant access is authorized - if not request.has_tenant_access(tenant): - L.warning("Tenant not authorized.", struct_data={"tenant": tenant, "sub": request._UserInfo.get("sub")}) - raise AccessDeniedError() - - # Extend globally granted resources with tenant-granted resources - request._AuthorizedResources = set(request._AuthorizedResources.union( - request._UserInfo["resources"].get(tenant, []))) - - - def _set_tenant_context_from_header(self, handler): - """ - Extract tenant from request path and authorize it - """ - - @functools.wraps(handler) - async def wrapper(*args, **kwargs): - request = args[-1] - - if request.headers.get("Upgrade") == "websocket": - # Get tenant from Sec-Websocket-Protocol header for websocket requests - x = request.headers.get("Sec-Websocket-Protocol", "") - for i in x.split(", "): - i = i.strip() - if i.startswith("tenant_"): - tenant = i[7:] - break - else: - tenant = None - else: - # Get tenant from X-Tenant header for HTTP requests - tenant = request.headers.get("X-Tenant") - - if tenant is not None and self.Mode != AuthMode.DISABLED: - assert len(tenant) < 128 # Limit tenant name length to 128 characters to maintain sanity - self._authorize_tenant_request(request, tenant) - - tenant_ctx = Tenant.set(tenant) - try: - response = await handler(*args, **kwargs) - finally: - Tenant.reset(tenant_ctx) - return response - - return wrapper - - - - def _add_tenant_from_path(self, handler): - """ - Extract tenant from request path and authorize it - """ - - @functools.wraps(handler) - async def wrapper(*args, **kwargs): - request = args[-1] - tenant_from_header = Tenant.get(None) - tenant = request.match_info["tenant"] - if tenant_from_header and tenant != tenant_from_header: - L.warning("Tenant in path differs from tenant in X-Tenant header.", struct_data={ - "path": tenant, "header": tenant_from_header}) - - if self.Mode != AuthMode.DISABLED: - self._authorize_tenant_request(request, tenant) - - return await handler(*args, tenant=tenant, **kwargs) - - return wrapper - - - def _add_tenant_from_query(self, handler): - """ - Extract tenant from request query and authorize it - """ - - @functools.wraps(handler) - async def wrapper(*args, **kwargs): - request = args[-1] - tenant_from_header = Tenant.get(None) - if "tenant" not in request.query: - return await handler(*args, tenant=tenant_from_header, **kwargs) + handler = _add_tenant(handler) - tenant = request.query["tenant"] - if tenant_from_header and tenant != tenant_from_header: - L.warning("Tenant in query differs from tenant in X-Tenant header.", struct_data={ - "query": tenant, "header": tenant_from_header}) + # 2) Authenticate and authorize request, authorize tenant from context, set Authorization context + handler = self._authorize_request(handler) - if self.Mode != AuthMode.DISABLED: - self._authorize_tenant_request(request, tenant) + # 1.5) Set tenant context from obsolete locations + # TODO: Deprecate tenant ID in path and query, always use X-Tenant header instead. + if tenant_in_path: + handler = _set_tenant_context_from_url_path(handler) + else: + handler = _set_tenant_context_from_url_query(handler) - return await handler(*args, tenant=tenant, **kwargs) + # 1) Set tenant context + handler = _set_tenant_context_from_request_header(handler) - return wrapper + route._handler = handler def _get_id_token_claims(bearer_token: str, auth_server_public_key): @@ -648,7 +512,8 @@ def _add_user_info(handler): @functools.wraps(handler) async def wrapper(*args, **kwargs): request = args[-1] - return await handler(*args, user_info=request._UserInfo, **kwargs) + authz = Authz.get() + return await handler(*args, user_info=authz.UserInfo if authz is not None else None, **kwargs) return wrapper @@ -659,5 +524,126 @@ def _add_resources(handler): @functools.wraps(handler) async def wrapper(*args, **kwargs): request = args[-1] - return await handler(*args, resources=request._AuthorizedResources, **kwargs) + authz = Authz.get() + return await handler(*args, resources=authz.AuthorizedResources if authz is not None else None, **kwargs) + return wrapper + + +def _add_tenant(handler): + """ + Add tenant to the handler arguments + """ + @functools.wraps(handler) + async def wrapper(*args, **kwargs): + request = args[-1] + return await handler(*args, tenant=Tenant.get(), **kwargs) + return wrapper + + +def _set_tenant_context_from_request_header(handler): + """ + Extract tenant from request header (X-Tenant or Sec-Websocket-Protocol) and add it to context + """ + def get_tenant_from_header(request) -> str: + if request.headers.get("Upgrade") == "websocket": + # Get tenant from Sec-Websocket-Protocol header for websocket requests + protocols = request.headers.get("Sec-Websocket-Protocol", "") + for protocol in protocols.split(", "): + protocol = protocol.strip() + if protocol.startswith("tenant_"): + return protocol[7:] + else: + return None + else: + # Get tenant from X-Tenant header for HTTP requests + return request.headers.get("X-Tenant") + + @functools.wraps(handler) + async def wrapper(*args, **kwargs): + request = args[-1] + tenant = get_tenant_from_header(request) + + if tenant is None: + response = await handler(*args, **kwargs) + else: + assert len(tenant) < 128 # Limit tenant name length to 128 characters to maintain sanity + tenant_ctx = Tenant.set(tenant) + try: + response = await handler(*args, **kwargs) + finally: + Tenant.reset(tenant_ctx) + + return response + + return wrapper + + +def _set_tenant_context_from_url_query(handler): + """ + Extract tenant from request query and add it to context + """ + @functools.wraps(handler) + async def wrapper(*args, **kwargs): + request = args[-1] + header_tenant = Tenant.get() + tenant = request.query.get("tenant") + + if tenant is None: + # No tenant in query + response = await handler(*args, **kwargs) + elif header_tenant is not None: + # Tenant from header must not be overwritten by a different tenant in query! + if tenant != header_tenant: + L.error("Tenant from URL query does not match tenant from header.", struct_data={ + "header_tenant": header_tenant, "query_tenant": tenant}) + raise AccessDeniedError() + # Tenant in query matches tenant in header + response = await handler(*args, **kwargs) + else: + # No tenant in header, only in query + assert len(tenant) < 128 # Limit tenant name length to 128 characters to maintain sanity + tenant_ctx = Tenant.set(tenant) + try: + response = await handler(*args, **kwargs) + finally: + Tenant.reset(tenant_ctx) + + return response + return wrapper + + +def _set_tenant_context_from_url_path(handler): + """ + Extract tenant from request URL path and add it to context + """ + @functools.wraps(handler) + async def wrapper(*args, **kwargs): + request = args[-1] + header_tenant = Tenant.get() + tenant = request.match_info.get("tenant") + + if header_tenant is not None: + # Tenant from header must not be overwritten by a different tenant in path! + if tenant != header_tenant: + L.error("Tenant from URL path does not match tenant from header.", struct_data={ + "header_tenant": header_tenant, "path_tenant": tenant}) + raise AccessDeniedError() + # Tenant in path matches tenant in header + response = await handler(*args, **kwargs) + else: + # No tenant in header, only in path + assert len(tenant) < 128 # Limit tenant name length to 128 characters to maintain sanity + tenant_ctx = Tenant.set(tenant) + try: + response = await handler(*args, **kwargs) + finally: + Tenant.reset(tenant_ctx) + + return response + + return wrapper + + +def get_authorized_resources(user_info: typing.Mapping, tenant: typing.Union[str, None]): + return set(user_info.get("resources", {}).get(tenant if tenant is not None else "*", [])) From a932ab830c634df417a10ca498e3f7232faf46b4 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 3 Oct 2024 09:23:24 +0200 Subject: [PATCH 05/44] lint and debug --- asab/web/auth/authz.py | 3 +++ asab/web/auth/service.py | 25 +++++++++---------------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 680ad709..0bcd98f0 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -31,6 +31,9 @@ def has_resource_access(self, resource_id: str | typing.Iterable[str]): else: return has_resource_access(self.Userinfo, resource_id, tenant=self.Tenant) + def authorized_resources(self): + return get_authorized_resources(self.Userinfo, self.Tenant) + def has_superuser_access(user_info: typing.Mapping) -> bool: """ diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index d1bc5b88..c0210b7a 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -341,7 +341,7 @@ async def wrapper(*args, **kwargs): finally: Authz.reset(auth_ctx) - return await handler(*args, **kwargs) + return response return wrapper @@ -407,17 +407,17 @@ def _wrap_handler(self, route): # 3) Pass authorization attributes to handler method if "resources" in args: - handler = _add_resources(handler) + handler = _pass_resources(handler) if "user_info" in args: - handler = _add_user_info(handler) + handler = _pass_user_info(handler) if "tenant" in args: - handler = _add_tenant(handler) + handler = _pass_tenant(handler) # 2) Authenticate and authorize request, authorize tenant from context, set Authorization context handler = self._authorize_request(handler) # 1.5) Set tenant context from obsolete locations - # TODO: Deprecate tenant ID in path and query, always use X-Tenant header instead. + # TODO: Deprecated. Remove tenant extraction from path and query, always use request headers instead. if tenant_in_path: handler = _set_tenant_context_from_url_path(handler) else: @@ -505,37 +505,34 @@ def _get_bearer_token(request): return token_value -def _add_user_info(handler): +def _pass_user_info(handler): """ Add user info to the handler arguments """ @functools.wraps(handler) async def wrapper(*args, **kwargs): - request = args[-1] authz = Authz.get() return await handler(*args, user_info=authz.UserInfo if authz is not None else None, **kwargs) return wrapper -def _add_resources(handler): +def _pass_resources(handler): """ Add resources to the handler arguments """ @functools.wraps(handler) async def wrapper(*args, **kwargs): - request = args[-1] authz = Authz.get() - return await handler(*args, resources=authz.AuthorizedResources if authz is not None else None, **kwargs) + return await handler(*args, resources=authz.authorized_resources() if authz is not None else None, **kwargs) return wrapper -def _add_tenant(handler): +def _pass_tenant(handler): """ Add tenant to the handler arguments """ @functools.wraps(handler) async def wrapper(*args, **kwargs): - request = args[-1] return await handler(*args, tenant=Tenant.get(), **kwargs) return wrapper @@ -643,7 +640,3 @@ async def wrapper(*args, **kwargs): return response return wrapper - - -def get_authorized_resources(user_info: typing.Mapping, tenant: typing.Union[str, None]): - return set(user_info.get("resources", {}).get(tenant if tenant is not None else "*", [])) From b27fb766801b2f57393932abb9ac31626cdcc3fd Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 3 Oct 2024 17:44:01 +0200 Subject: [PATCH 06/44] make Authorization printable, debug --- asab/web/auth/authz.py | 20 +++++++++++++++----- asab/web/auth/service.py | 8 ++++---- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 0bcd98f0..1860b6c8 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -10,29 +10,39 @@ class Authorization: """ def __init__(self, auth_service, userinfo: dict, tenant: typing.Union[str, None]): self.AuthService = auth_service - self.Userinfo = userinfo + self.UserInfo = userinfo self.CredentialsId = userinfo.get("sub") self.Username = userinfo.get("preferred_username") or userinfo.get("username") self.Email = userinfo.get("email") self.Phone = userinfo.get("phone") + self.Issuer = self.UserInfo.get("iss") # Who issued the authorization + self.AuthorizedParty = self.UserInfo.get("azp") # What party (application) is authorized + self.Tenant = tenant + def __repr__(self): + return "".format( + "SUPERUSER, " if self.has_superuser_access() else "", + self.CredentialsId, + self.AuthorizedParty, + ) + def has_superuser_access(self): if not self.AuthService.is_enabled(): return True - return has_superuser_access(self.Userinfo) + return has_superuser_access(self.UserInfo) def has_resource_access(self, resource_id: str | typing.Iterable[str]): if not self.AuthService.is_enabled(): return True if isinstance(resource_id, str): - return has_resource_access(self.Userinfo, {resource_id}, tenant=self.Tenant) + return has_resource_access(self.UserInfo, {resource_id}, tenant=self.Tenant) else: - return has_resource_access(self.Userinfo, resource_id, tenant=self.Tenant) + return has_resource_access(self.UserInfo, resource_id, tenant=self.Tenant) def authorized_resources(self): - return get_authorized_resources(self.Userinfo, self.Tenant) + return get_authorized_resources(self.UserInfo, self.Tenant) def has_superuser_access(user_info: typing.Mapping) -> bool: diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index c0210b7a..04b1dcc3 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -323,7 +323,7 @@ async def wrapper(*args, **kwargs): user_info = await self.extract_user_info_from_request(request) # Authorize tenant from context - tenant = Tenant.get() + tenant = Tenant.get(None) if tenant is not None and self.Mode != AuthMode.DISABLED: if not has_tenant_access(user_info, tenant): L.warning("Tenant not authorized.", struct_data={ @@ -533,7 +533,7 @@ def _pass_tenant(handler): """ @functools.wraps(handler) async def wrapper(*args, **kwargs): - return await handler(*args, tenant=Tenant.get(), **kwargs) + return await handler(*args, tenant=Tenant.get(None), **kwargs) return wrapper @@ -582,7 +582,7 @@ def _set_tenant_context_from_url_query(handler): @functools.wraps(handler) async def wrapper(*args, **kwargs): request = args[-1] - header_tenant = Tenant.get() + header_tenant = Tenant.get(None) tenant = request.query.get("tenant") if tenant is None: @@ -617,7 +617,7 @@ def _set_tenant_context_from_url_path(handler): @functools.wraps(handler) async def wrapper(*args, **kwargs): request = args[-1] - header_tenant = Tenant.get() + header_tenant = Tenant.get(None) tenant = request.match_info.get("tenant") if header_tenant is not None: From b047b6da4cb62ec6d85eece8de723be2a2e33fe1 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 3 Oct 2024 17:47:51 +0200 Subject: [PATCH 07/44] add typing --- asab/web/auth/authz.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 1860b6c8..bf993a27 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -28,12 +28,12 @@ def __repr__(self): self.AuthorizedParty, ) - def has_superuser_access(self): + def has_superuser_access(self) -> bool: if not self.AuthService.is_enabled(): return True return has_superuser_access(self.UserInfo) - def has_resource_access(self, resource_id: str | typing.Iterable[str]): + def has_resource_access(self, resource_id: str | typing.Iterable[str]) -> bool: if not self.AuthService.is_enabled(): return True if isinstance(resource_id, str): @@ -41,7 +41,7 @@ def has_resource_access(self, resource_id: str | typing.Iterable[str]): else: return has_resource_access(self.UserInfo, resource_id, tenant=self.Tenant) - def authorized_resources(self): + def authorized_resources(self) -> typing.Set[str]: return get_authorized_resources(self.UserInfo, self.Tenant) @@ -54,7 +54,7 @@ def has_superuser_access(user_info: typing.Mapping) -> bool: def has_resource_access( user_info: typing.Mapping, - required_resources: typing.Iterable, + resource_ids: typing.Iterable, tenant: typing.Union[str, None], ) -> bool: """ @@ -64,7 +64,7 @@ def has_resource_access( return True authorized_resources = get_authorized_resources(user_info, tenant) - for resource in required_resources: + for resource in resource_ids: if resource not in authorized_resources: return False @@ -85,5 +85,11 @@ def has_tenant_access(user_info: typing.Mapping, tenant: str) -> bool: return False -def get_authorized_resources(user_info: typing.Mapping, tenant: typing.Union[str, None]): +def get_authorized_resources(user_info: typing.Mapping, tenant: typing.Union[str, None]) -> typing.Set[str]: + """ + Extract resources authorized within given tenant (or globally, if tenant is None). + :param user_info: + :param tenant: + :return: + """ return set(user_info.get("resources", {}).get(tenant if tenant is not None else "*", [])) From 547528bddacc6c5f782dd0685fb473b487e75579 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 3 Oct 2024 18:20:05 +0200 Subject: [PATCH 08/44] rename is_superuser --- asab/web/auth/authz.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index bf993a27..14d10d92 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -23,15 +23,15 @@ def __init__(self, auth_service, userinfo: dict, tenant: typing.Union[str, None] def __repr__(self): return "".format( - "SUPERUSER, " if self.has_superuser_access() else "", + "SUPERUSER, " if self.is_superuser() else "", self.CredentialsId, self.AuthorizedParty, ) - def has_superuser_access(self) -> bool: + def is_superuser(self) -> bool: if not self.AuthService.is_enabled(): return True - return has_superuser_access(self.UserInfo) + return is_superuser(self.UserInfo) def has_resource_access(self, resource_id: str | typing.Iterable[str]) -> bool: if not self.AuthService.is_enabled(): @@ -45,7 +45,7 @@ def authorized_resources(self) -> typing.Set[str]: return get_authorized_resources(self.UserInfo, self.Tenant) -def has_superuser_access(user_info: typing.Mapping) -> bool: +def is_superuser(user_info: typing.Mapping) -> bool: """ Check if the superuser resource is present in the authorized resource list. """ @@ -60,7 +60,7 @@ def has_resource_access( """ Check if the requested resources or the superuser resource are present in the authorized resource list. """ - if has_superuser_access(user_info): + if is_superuser(user_info): return True authorized_resources = get_authorized_resources(user_info, tenant) @@ -78,7 +78,7 @@ def has_tenant_access(user_info: typing.Mapping, tenant: str) -> bool: """ if tenant == "*": raise ValueError("Invalid tenant name: '*'") - if has_superuser_access(user_info): + if is_superuser(user_info): return True if tenant in user_info.get("resources", {}): return True From 995e7170c979cc8d483ac1da042d0d2bf626b9bc Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 3 Oct 2024 18:24:38 +0200 Subject: [PATCH 09/44] flake8 --- asab/web/auth/service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 04b1dcc3..96debef6 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -6,7 +6,6 @@ import json import logging import os.path -import typing import time import enum From e298f6483b571776540ea6a4449e3b08fbbbbffd Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Mon, 7 Oct 2024 14:58:50 +0200 Subject: [PATCH 10/44] add comments with types --- asab/contextvars.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/asab/contextvars.py b/asab/contextvars.py index 5920c498..ff909e27 100644 --- a/asab/contextvars.py +++ b/asab/contextvars.py @@ -1,4 +1,7 @@ import contextvars +# Contains tenant ID string Tenant = contextvars.ContextVar("Tenant") + +# Contains asab.web.auth.Authorization object Authz = contextvars.ContextVar("Authz") From 4c23e3bb9d3045e942045328a89c4c8ba29f3674 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Mon, 7 Oct 2024 14:59:41 +0200 Subject: [PATCH 11/44] add docstrings --- asab/web/auth/authz.py | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 14d10d92..5f876f53 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -1,4 +1,9 @@ import typing +import logging + +from asab.exceptions import AccessDeniedError + +L = logging.getLogger(__name__) SUPERUSER_RES_ID = "authz:superuser" @@ -10,7 +15,12 @@ class Authorization: """ def __init__(self, auth_service, userinfo: dict, tenant: typing.Union[str, None]): self.AuthService = auth_service - self.UserInfo = userinfo + if self.AuthService.is_enabled() and userinfo is None: + L.error("Userinfo is mandatory when AuthService is enabled.") + raise AccessDeniedError() + self.UserInfo = userinfo or {} + self.Tenant = tenant + self.CredentialsId = userinfo.get("sub") self.Username = userinfo.get("preferred_username") or userinfo.get("username") self.Email = userinfo.get("email") @@ -19,8 +29,6 @@ def __init__(self, auth_service, userinfo: dict, tenant: typing.Union[str, None] self.Issuer = self.UserInfo.get("iss") # Who issued the authorization self.AuthorizedParty = self.UserInfo.get("azp") # What party (application) is authorized - self.Tenant = tenant - def __repr__(self): return "".format( "SUPERUSER, " if self.is_superuser() else "", @@ -29,11 +37,22 @@ def __repr__(self): ) def is_superuser(self) -> bool: + """ + Check whether the agent is a superuser. + + :return: Is the agent a superuser? + """ if not self.AuthService.is_enabled(): return True return is_superuser(self.UserInfo) - def has_resource_access(self, resource_id: str | typing.Iterable[str]) -> bool: + def has_resource_access(self, resource_id: typing.Union[str, typing.Iterable[str]]) -> bool: + """ + Check whether the agent is authorized to access a resource or multiple resources. + + :param resource_id: Resource ID or a list of resource IDs whose authorization is required. + :return: Is resource access authorized? + """ if not self.AuthService.is_enabled(): return True if isinstance(resource_id, str): @@ -42,6 +61,14 @@ def has_resource_access(self, resource_id: str | typing.Iterable[str]) -> bool: return has_resource_access(self.UserInfo, resource_id, tenant=self.Tenant) def authorized_resources(self) -> typing.Set[str]: + """ + Return the set of EXPLICITLY authorized resources. (Use carefully with superusers.) + + NOTE: If possible, use methods has_resource_access(resource_id) and is_superuser() instead of inspecting + the set of resources. + + :return: Set of authorized resources. + """ return get_authorized_resources(self.UserInfo, self.Tenant) @@ -73,8 +100,8 @@ def has_resource_access( def has_tenant_access(user_info: typing.Mapping, tenant: str) -> bool: """ - Check if the request is authorized to access a tenant. - If the request has superuser access, tenant access is always implicitly granted. + Check the agent's userinfo to see if they are authorized to access a tenant. + If the agent has superuser access, tenant access is always implicitly granted. """ if tenant == "*": raise ValueError("Invalid tenant name: '*'") @@ -88,8 +115,9 @@ def has_tenant_access(user_info: typing.Mapping, tenant: str) -> bool: def get_authorized_resources(user_info: typing.Mapping, tenant: typing.Union[str, None]) -> typing.Set[str]: """ Extract resources authorized within given tenant (or globally, if tenant is None). + :param user_info: :param tenant: - :return: + :return: Set of authorized resources. """ return set(user_info.get("resources", {}).get(tenant if tenant is not None else "*", [])) From 283ae16c183a1c1b772c0d1535b4c692791c4bb0 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Mon, 7 Oct 2024 15:00:38 +0200 Subject: [PATCH 12/44] delete deprecated asab.web.authz module --- asab/web/authz/__init__.py | 17 --- asab/web/authz/decorator.py | 117 --------------------- asab/web/authz/middleware.py | 19 ---- asab/web/authz/service.py | 198 ----------------------------------- 4 files changed, 351 deletions(-) delete mode 100644 asab/web/authz/__init__.py delete mode 100644 asab/web/authz/decorator.py delete mode 100644 asab/web/authz/middleware.py delete mode 100644 asab/web/authz/service.py diff --git a/asab/web/authz/__init__.py b/asab/web/authz/__init__.py deleted file mode 100644 index 2046488f..00000000 --- a/asab/web/authz/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -OBSOLETE MODULE, to be deleted after January 2024 - -Use 'asab.web.auth' instead - -""" - -from .decorator import required, userinfo_handler -from .middleware import authz_middleware_factory -from .service import AuthzService - -__all__ = ( - "required", - "userinfo_handler", - "authz_middleware_factory", - "AuthzService" -) diff --git a/asab/web/authz/decorator.py b/asab/web/authz/decorator.py deleted file mode 100644 index 2a97d803..00000000 --- a/asab/web/authz/decorator.py +++ /dev/null @@ -1,117 +0,0 @@ -import re -import logging -import functools - -import aiohttp.web - -# - -L = logging.getLogger(__name__) - -# - - -def required(*resources): - ''' - OBSOLETE - - Checks that user authorized with access token in - Authorization header has access to a given tenant space - using SeaCat Auth RBAC authorization. - - It uses a cache to limit the number of HTTP checks. - - Example of use: - - @asab.web.tenant.tenant_handler - @asab.web.authz.required("tenant:access") - async def endpoint(self, request, *, tenant): - ... - ''' - - def decorator_required(func): - - @functools.wraps(func) - async def wrapper(*args, **kargs): - request = args[-1] - - # Obtain authz service from the request - authz_service = request.AuthzService - - if not request.AuthzService.is_ready(): - L.error("Cannot authorize request - AuthzService is not ready.") - raise aiohttp.web.HTTPUnauthorized() - - bearer_token = _get_bearer_token(request) - - # For resistancy against security attacks - if bearer_token is None: - raise aiohttp.web.HTTPUnauthorized() - - if await authz_service.authorize( - resources=resources, - bearer_token=bearer_token, - tenant=getattr(request, "Tenant", None), - ): - return await func(*args, **kargs) - - # Be defensive - raise aiohttp.web.HTTPUnauthorized() - - return wrapper - - return decorator_required - - -def userinfo_handler(func): - """ - Fetches userinfo and passes the response dict to the decorated function. - - It uses a cache to limit the number of HTTP checks. - - Example of use: - - @asab.web.tenant.tenant_handler - @asab.web.authz.userinfo - async def endpoint(self, request, *, tenant, userinfo): - ... - """ - - @functools.wraps(func) - async def wrapper(*args, **kargs): - request = args[-1] - - # Obtain authz service from the request - authz_service = request.AuthzService - - bearer_token = _get_bearer_token(request) - - # Fail if no access token is found in the request - if bearer_token is None: - L.warning("Access token has not been provided in the request - unauthorized.") - raise aiohttp.web.HTTPUnauthorized() - - userinfo_data = authz_service.userinfo(bearer_token=bearer_token) - if userinfo_data is not None: - return await func(*args, userinfo=userinfo_data, **kargs) - - # Be defensive - L.warning("Failure to get userinfo - unauthorized.") - raise aiohttp.web.HTTPUnauthorized() - - return wrapper - - -def _get_bearer_token(request): - authorization_header_rg = re.compile(r"^\s*Bearer ([A-Za-z0-9\-\.\+_~/=]*)") - - authorization_value = request.headers.get(aiohttp.hdrs.AUTHORIZATION, None) - bearer_token = None - - # Obtain access token from the authorization header - if authorization_value is not None: - authorization_match = authorization_header_rg.match(authorization_value) - if authorization_match is not None: - bearer_token = authorization_match.group(1) - - return bearer_token diff --git a/asab/web/authz/middleware.py b/asab/web/authz/middleware.py deleted file mode 100644 index bf03a269..00000000 --- a/asab/web/authz/middleware.py +++ /dev/null @@ -1,19 +0,0 @@ -import aiohttp.web - - -def authz_middleware_factory(app, svc): - """ - OBSOLETE - - Ensures that AuthzService is part of the request. - :param app: application object - :param svc: AuthzService - :return: handler(request) - """ - - @aiohttp.web.middleware - async def authz_middleware(request, handler): - request.AuthzService = svc - return await handler(request) - - return authz_middleware diff --git a/asab/web/authz/service.py b/asab/web/authz/service.py deleted file mode 100644 index 5efb0505..00000000 --- a/asab/web/authz/service.py +++ /dev/null @@ -1,198 +0,0 @@ -import base64 -import binascii -import json -import logging -import aiohttp -import aiohttp.client_exceptions - -import asab -import asab.exceptions - -try: - import jwcrypto.jwk - import jwcrypto.jwt - import jwcrypto.jws -except ModuleNotFoundError: - jwcrypto = None - -# - -L = logging.getLogger(__name__) - -# - - -asab.Config.add_defaults({ - "authz": { - "public_keys_url": "", - "_disable_token_verification": "no", - "_disable_rbac": "no", - } -}) - - -class AuthzService(asab.Service): - """ - OBSOLETE, use 'asab.web.auth' instead - """ - - def __init__(self, app, service_name="asab.AuthzService"): - - asab.LogObsolete.warning( - "Module 'asab.web.authz' is deprecated, please use 'asab.web.auth' instead.", - struct_data={"eol": "2024-01-31"}) - - super().__init__(app, service_name) - self.RBACDisabled = asab.Config.getboolean("authz", "_disable_rbac") - self._TokenVerificationDisabled = asab.Config.getboolean("authz", "_disable_token_verification") - if jwcrypto is None and not self._TokenVerificationDisabled: - raise ModuleNotFoundError( - "You are trying to use asab.web.authz without 'jwcrypto' installed. " - "Please run 'pip install jwcrypto' " - "or install asab with 'authz' optional dependency.") - self.PublicKeysUrl = asab.Config.get("authz", "public_keys_url") - if len(self.PublicKeysUrl) == 0 and not self._TokenVerificationDisabled: - raise ValueError("No public_keys_url provided in [authz] config section.") - self.AuthServerPublicKey = None # TODO: Support multiple public keys - # TODO: Fetch public keys if validation fails (instead of periodic fetch) - self.App.PubSub.subscribe("Application.tick/30!", self._fetch_public_keys_if_needed) - - - async def initialize(self, app): - await self._fetch_public_keys_if_needed() - - - def is_ready(self): - if self._TokenVerificationDisabled is True: - return True - elif self.AuthServerPublicKey is not None: - return True - return False - - - async def authorize(self, resources, bearer_token, tenant=None): - # Use userinfo to make RBAC check - userinfo = self.userinfo(bearer_token) - - # Fail if userinfo cannot be fetched or resources are missing - if userinfo is None: - return False - user_resources = userinfo.get("resources") - if user_resources is None: - return False - - # Allow superuser to pass any check - if "authz:superuser" in frozenset(user_resources.get("*", [])): - return True - - if tenant is None: - # Check only global resources if no tenant is specified - tenant = "*" - if tenant not in user_resources: - # Tenant section is not present: The check has failed - return False - - # Make sure all the required resources are accessible - tenant_user_resources = frozenset(user_resources[tenant]) - for resource in resources: - if resource == "tenant:access": - # Tenant section is present: User has tenant access - continue - if resource not in tenant_user_resources: - return False - - return True - - - def userinfo(self, bearer_token): - if not self.is_ready(): - L.error("AuthzService is not ready: No public keys loaded yet.") - return None - - if self._TokenVerificationDisabled: - return _get_id_token_claims_without_verification(bearer_token) - else: - return _get_id_token_claims(bearer_token, self.AuthServerPublicKey) - - - async def _fetch_public_keys_if_needed(self, *args, **kwargs): - if self.is_ready(): - return - - async with aiohttp.ClientSession() as session: - try: - async with session.get(self.PublicKeysUrl) as response: - if response.status != 200: - L.error("HTTP error while loading public keys.", struct_data={ - "status": response.status, - "url": self.PublicKeysUrl, - "text": await response.text(), - }) - return - try: - data = await response.json() - except json.JSONDecodeError: - L.error("JSON decoding error while loading public keys.", struct_data={ - "url": self.PublicKeysUrl, - "data": data, - }) - return - try: - key_data = data["keys"].pop() - except (IndexError, KeyError): - L.error("Error while loading public keys: No public keys in server response.", struct_data={ - "url": self.PublicKeysUrl, - "data": data, - }) - return - try: - public_key = jwcrypto.jwk.JWK(**key_data) - except Exception as e: - L.error("JWK decoding error while loading public keys: {}.".format(e), struct_data={ - "url": self.PublicKeysUrl, - "data": data, - }) - return - except aiohttp.client_exceptions.ClientConnectorError as e: - L.error("Connection error while loading public keys: {}".format(e), struct_data={ - "url": self.PublicKeysUrl, - }) - return - - self.AuthServerPublicKey = public_key - L.log(asab.LOG_NOTICE, "Public key loaded.", struct_data={"url": self.PublicKeysUrl}) - - -def _get_id_token_claims(bearer_token: str, auth_server_public_key): - assert jwcrypto is not None - try: - token = jwcrypto.jwt.JWT(jwt=bearer_token, key=auth_server_public_key) - except jwcrypto.jwt.JWTExpired: - raise asab.exceptions.NotAuthenticatedError("ID token expired.") - except jwcrypto.jws.InvalidJWSSignature: - raise asab.exceptions.NotAuthenticatedError("Invalid ID token signature.") - except ValueError as e: - raise asab.exceptions.NotAuthenticatedError("Authentication failed: {}".format(e)) - - try: - token_claims = json.loads(token.claims) - except ValueError: - raise asab.exceptions.NotAuthenticatedError("Cannot parse ID token claims.") - - return token_claims - - -def _get_id_token_claims_without_verification(bearer_token: str): - try: - header, payload, signature = bearer_token.split(".") - except IndexError: - raise asab.exceptions.NotAuthenticatedError("Cannot parse ID token: Wrong number of '.'.") - - try: - claims = json.loads(base64.b64decode(payload.encode("utf-8"))) - except binascii.Error: - raise asab.exceptions.NotAuthenticatedError("Cannot parse ID token: Payload is not base 64.") - except json.JSONDecodeError: - raise asab.exceptions.NotAuthenticatedError("Cannot parse ID token: Payload cannot be parsed as JSON.") - - return claims From 066d2bcdb776e9ca3d115d1f6dfc468194ea71d8 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Mon, 7 Oct 2024 15:41:55 +0200 Subject: [PATCH 13/44] handle userinfo none --- asab/web/auth/authz.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 5f876f53..978183af 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -21,10 +21,10 @@ def __init__(self, auth_service, userinfo: dict, tenant: typing.Union[str, None] self.UserInfo = userinfo or {} self.Tenant = tenant - self.CredentialsId = userinfo.get("sub") - self.Username = userinfo.get("preferred_username") or userinfo.get("username") - self.Email = userinfo.get("email") - self.Phone = userinfo.get("phone") + self.CredentialsId = self.UserInfo.get("sub") + self.Username = self.UserInfo.get("preferred_username") or self.UserInfo.get("username") + self.Email = self.UserInfo.get("email") + self.Phone = self.UserInfo.get("phone") self.Issuer = self.UserInfo.get("iss") # Who issued the authorization self.AuthorizedParty = self.UserInfo.get("azp") # What party (application) is authorized From 713182e9cc8654b924958528989fe8ebe905b786 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Mon, 7 Oct 2024 15:44:18 +0200 Subject: [PATCH 14/44] more cleanup, debug authservice disabled, deprecate resources and userinfo args --- asab/web/auth/service.py | 99 ++++++++++++++++++++++++++-------------- 1 file changed, 64 insertions(+), 35 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 96debef6..70ddfb28 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -8,6 +8,7 @@ import os.path import time import enum +import typing import aiohttp import aiohttp.web @@ -17,6 +18,7 @@ from ...config import Config from ...exceptions import NotAuthenticatedError, AccessDeniedError from ...api.discovery import NotDiscoveredError +from ...library.service import LogObsolete from ...utils import string_to_boolean from ...contextvars import Tenant, Authz from .authz import Authorization, has_tenant_access @@ -311,52 +313,55 @@ async def fetch_keys(session): L.debug("Public key loaded.", struct_data={"url": self.PublicKeysUrl}) + async def extract_user_info_from_request(self, request): + if not self.is_enabled(): + user_info = None + elif self.Mode == AuthMode.MOCK: + user_info = self.MockUserInfo + else: + # Extract user info from the request Authorization header + bearer_token = _get_bearer_token(request) + user_info = await self.get_userinfo_from_id_token(bearer_token) + return user_info + + + def authorize_tenant(self, tenant: typing.Union[str, None], user_info: typing.Dict): + if tenant is not None and self.Mode != AuthMode.DISABLED: + if not has_tenant_access(user_info, tenant): + L.warning("Tenant not authorized.", struct_data={ + "tenant": tenant, "sub": user_info.get("sub")}) + raise AccessDeniedError() + return tenant + + def _authorize_request(self, handler): """ Authenticate the request by the JWT ID token in the Authorization header. - Extract the token claims into request attributes so that they can be used for authorization checks. + Extract the token claims into Authorization context so that they can be used for authorization checks. """ @functools.wraps(handler) async def wrapper(*args, **kwargs): request = args[-1] + # Extract auth data from request headers user_info = await self.extract_user_info_from_request(request) - # Authorize tenant from context + # Authorize tenant context tenant = Tenant.get(None) - if tenant is not None and self.Mode != AuthMode.DISABLED: - if not has_tenant_access(user_info, tenant): - L.warning("Tenant not authorized.", struct_data={ - "tenant": tenant, "sub": request._UserInfo.get("sub")}) - raise AccessDeniedError() - - # Add userinfo, tenants and global resources to the request - if not self.is_enabled(): + tenant = self.authorize_tenant(tenant, user_info) + + # Create Authorization context + auth = Authorization(self, user_info, tenant) + auth_ctx = Authz.set(auth) + try: response = await handler(*args, **kwargs) - else: - auth = Authorization(self, user_info, tenant) - auth_ctx = Authz.set(auth) - try: - response = await handler(*args, **kwargs) - finally: - Authz.reset(auth_ctx) + finally: + Authz.reset(auth_ctx) return response return wrapper - async def extract_user_info_from_request(self, request): - if not self.is_enabled(): - user_info = None - elif self.Mode == AuthMode.MOCK: - user_info = self.MockUserInfo - else: - # Extract user info from the request Authorization header - bearer_token = _get_bearer_token(request) - user_info = await self.get_userinfo_from_id_token(bearer_token) - return user_info - - async def _wrap_handlers(self, aiohttp_app): """ Inspect all registered handlers and wrap them in decorators according to their parameters. @@ -402,27 +407,40 @@ def _wrap_handler(self, route): # Extract the whole handler for wrapping handler = route.handler - # Apply the decorators in reverse order (the last applied wrapper affects the request first) + # Apply the decorators IN REVERSE ORDER (the last applied wrapper affects the request first) # 3) Pass authorization attributes to handler method if "resources" in args: + LogObsolete.warning( + "The 'resources' argument is deprecated. " + "Use the access-checking methods of asab.contextvars.Authz instead.", + struct_data={"handler": handler.__qualname__, "eol": "2025-03-01"}, + ) handler = _pass_resources(handler) if "user_info" in args: + LogObsolete.warning( + "The 'user_info' argument is deprecated. " + "Use the Authorization object in asab.contextvars.Authz instead.", + struct_data={"handler": handler.__qualname__, "eol": "2025-03-01"}, + ) handler = _pass_user_info(handler) if "tenant" in args: handler = _pass_tenant(handler) + if "authz" in args: + handler = _pass_authz(handler) # 2) Authenticate and authorize request, authorize tenant from context, set Authorization context handler = self._authorize_request(handler) - # 1.5) Set tenant context from obsolete locations - # TODO: Deprecated. Remove tenant extraction from path and query, always use request headers instead. + # 1.5) Set tenant context from obsolete locations (no authorization yet) + # TODO: Deprecated. Ignore tenant in path and query, always use request headers instead. if tenant_in_path: handler = _set_tenant_context_from_url_path(handler) else: handler = _set_tenant_context_from_url_query(handler) - # 1) Set tenant context + # 1) Set tenant context (no authorization yet) + # TODO: This should be eventually done by TenantService handler = _set_tenant_context_from_request_header(handler) route._handler = handler @@ -510,7 +528,7 @@ def _pass_user_info(handler): """ @functools.wraps(handler) async def wrapper(*args, **kwargs): - authz = Authz.get() + authz = Authz.get(None) return await handler(*args, user_info=authz.UserInfo if authz is not None else None, **kwargs) return wrapper @@ -521,7 +539,7 @@ def _pass_resources(handler): """ @functools.wraps(handler) async def wrapper(*args, **kwargs): - authz = Authz.get() + authz = Authz.get(None) return await handler(*args, resources=authz.authorized_resources() if authz is not None else None, **kwargs) return wrapper @@ -536,6 +554,17 @@ async def wrapper(*args, **kwargs): return wrapper +def _pass_authz(handler): + """ + Add Auhorization object to the handler arguments + """ + @functools.wraps(handler) + async def wrapper(*args, **kwargs): + authz = Authz.get(None) + return await handler(*args, authz=authz, **kwargs) + return wrapper + + def _set_tenant_context_from_request_header(handler): """ Extract tenant from request header (X-Tenant or Sec-Websocket-Protocol) and add it to context From a58730e061613b17e18b33b30b2b2fce7a0e587a Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Mon, 7 Oct 2024 17:30:09 +0200 Subject: [PATCH 15/44] explaining comments --- asab/web/auth/authz.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 978183af..884851cd 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -29,6 +29,7 @@ def __init__(self, auth_service, userinfo: dict, tenant: typing.Union[str, None] self.Issuer = self.UserInfo.get("iss") # Who issued the authorization self.AuthorizedParty = self.UserInfo.get("azp") # What party (application) is authorized + def __repr__(self): return "".format( "SUPERUSER, " if self.is_superuser() else "", @@ -36,6 +37,7 @@ def __repr__(self): self.AuthorizedParty, ) + def is_superuser(self) -> bool: """ Check whether the agent is a superuser. @@ -43,9 +45,12 @@ def is_superuser(self) -> bool: :return: Is the agent a superuser? """ if not self.AuthService.is_enabled(): + # Authorization is disabled = everything is allowed return True + return is_superuser(self.UserInfo) + def has_resource_access(self, resource_id: typing.Union[str, typing.Iterable[str]]) -> bool: """ Check whether the agent is authorized to access a resource or multiple resources. @@ -54,12 +59,15 @@ def has_resource_access(self, resource_id: typing.Union[str, typing.Iterable[str :return: Is resource access authorized? """ if not self.AuthService.is_enabled(): + # Authorization is disabled = everything is allowed return True + if isinstance(resource_id, str): return has_resource_access(self.UserInfo, {resource_id}, tenant=self.Tenant) else: return has_resource_access(self.UserInfo, resource_id, tenant=self.Tenant) + def authorized_resources(self) -> typing.Set[str]: """ Return the set of EXPLICITLY authorized resources. (Use carefully with superusers.) @@ -69,6 +77,10 @@ def authorized_resources(self) -> typing.Set[str]: :return: Set of authorized resources. """ + if not self.AuthService.is_enabled(): + # Authorization is disabled = authorized resources are unknown + return set() + return get_authorized_resources(self.UserInfo, self.Tenant) From 32faa10365ecf1007504b353a3cc413e5dc0bbd5 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Mon, 7 Oct 2024 17:32:10 +0200 Subject: [PATCH 16/44] return None to prevent smooth flow --- asab/web/auth/authz.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 884851cd..41bfcbf3 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -68,7 +68,7 @@ def has_resource_access(self, resource_id: typing.Union[str, typing.Iterable[str return has_resource_access(self.UserInfo, resource_id, tenant=self.Tenant) - def authorized_resources(self) -> typing.Set[str]: + def authorized_resources(self) -> typing.Optional[typing.Set[str]]: """ Return the set of EXPLICITLY authorized resources. (Use carefully with superusers.) @@ -79,7 +79,7 @@ def authorized_resources(self) -> typing.Set[str]: """ if not self.AuthService.is_enabled(): # Authorization is disabled = authorized resources are unknown - return set() + return None return get_authorized_resources(self.UserInfo, self.Tenant) From 5c89a1df0c8e017ee1b62a62978adb0fe2087ca7 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Mon, 7 Oct 2024 17:49:24 +0200 Subject: [PATCH 17/44] remove redundant return value --- asab/web/auth/service.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 70ddfb28..21f110cd 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -331,7 +331,6 @@ def authorize_tenant(self, tenant: typing.Union[str, None], user_info: typing.Di L.warning("Tenant not authorized.", struct_data={ "tenant": tenant, "sub": user_info.get("sub")}) raise AccessDeniedError() - return tenant def _authorize_request(self, handler): @@ -347,7 +346,7 @@ async def wrapper(*args, **kwargs): # Authorize tenant context tenant = Tenant.get(None) - tenant = self.authorize_tenant(tenant, user_info) + self.authorize_tenant(tenant, user_info) # Create Authorization context auth = Authorization(self, user_info, tenant) From fd1fda27cfb45130a5899a194d150852a8c1d002 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Mon, 7 Oct 2024 19:21:19 +0200 Subject: [PATCH 18/44] require-methods --- asab/web/auth/authz.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 41bfcbf3..920e0791 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -6,7 +6,7 @@ L = logging.getLogger(__name__) -SUPERUSER_RES_ID = "authz:superuser" +SUPERUSER_RESOURCE_ID = "authz:superuser" class Authorization: @@ -38,6 +38,16 @@ def __repr__(self): ) + def require_superuser(self): + if not self.is_superuser(): + raise AccessDeniedError() + + + def require_resource_access(self, resource_id: typing.Union[str, typing.Iterable[str]]): + if not self.has_resource_access(resource_id): + raise AccessDeniedError() + + def is_superuser(self) -> bool: """ Check whether the agent is a superuser. @@ -88,7 +98,7 @@ def is_superuser(user_info: typing.Mapping) -> bool: """ Check if the superuser resource is present in the authorized resource list. """ - return SUPERUSER_RES_ID in get_authorized_resources(user_info, tenant=None) + return SUPERUSER_RESOURCE_ID in get_authorized_resources(user_info, tenant=None) def has_resource_access( From 9ef2a3016b29df2b528af9a2cc85e04aaa02a651 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Mon, 7 Oct 2024 19:23:04 +0200 Subject: [PATCH 19/44] add todo --- asab/web/auth/service.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 21f110cd..f776ec63 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -349,6 +349,9 @@ async def wrapper(*args, **kwargs): self.authorize_tenant(tenant, user_info) # Create Authorization context + # TODO: Authorization lifecycle management + # - "cache"" objects under (bearer_token, tenant) key + # - clean them up after they expire auth = Authorization(self, user_info, tenant) auth_ctx = Authz.set(auth) try: From f9f4046b341236697d8a9937fd8f40bbd2a280ff Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Tue, 8 Oct 2024 14:01:47 +0200 Subject: [PATCH 20/44] use tenant from context; add expiration and validity; update docstrings --- asab/web/auth/authz.py | 89 ++++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 33 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 920e0791..9411fded 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -1,7 +1,9 @@ +import datetime import typing import logging -from asab.exceptions import AccessDeniedError +from ...exceptions import AccessDeniedError +from ...contextvars import Tenant L = logging.getLogger(__name__) @@ -13,19 +15,19 @@ class Authorization: """ Contains authentication and authorization details, provides methods for checking access control """ - def __init__(self, auth_service, userinfo: dict, tenant: typing.Union[str, None]): + def __init__(self, auth_service, userinfo: dict): self.AuthService = auth_service if self.AuthService.is_enabled() and userinfo is None: L.error("Userinfo is mandatory when AuthService is enabled.") raise AccessDeniedError() self.UserInfo = userinfo or {} - self.Tenant = tenant self.CredentialsId = self.UserInfo.get("sub") self.Username = self.UserInfo.get("preferred_username") or self.UserInfo.get("username") self.Email = self.UserInfo.get("email") self.Phone = self.UserInfo.get("phone") + self.Expiration = datetime.datetime.fromtimestamp(int(self.UserInfo.get("exp")), datetime.timezone.utc) self.Issuer = self.UserInfo.get("iss") # Who issued the authorization self.AuthorizedParty = self.UserInfo.get("azp") # What party (application) is authorized @@ -38,14 +40,12 @@ def __repr__(self): ) - def require_superuser(self): - if not self.is_superuser(): - raise AccessDeniedError() - - - def require_resource_access(self, resource_id: typing.Union[str, typing.Iterable[str]]): - if not self.has_resource_access(resource_id): - raise AccessDeniedError() + def is_valid(self) -> bool: + """ + Check if the authorization is not expired. + :return: Authorization validity + """ + return datetime.datetime.now(datetime.timezone.utc) > self.Expiration def is_superuser(self) -> bool: @@ -58,24 +58,44 @@ def is_superuser(self) -> bool: # Authorization is disabled = everything is allowed return True + if not self.is_valid(): + return False + return is_superuser(self.UserInfo) - def has_resource_access(self, resource_id: typing.Union[str, typing.Iterable[str]]) -> bool: + def has_resource_access(self, *resources: typing.Iterable[str]) -> bool: """ - Check whether the agent is authorized to access a resource or multiple resources. + Check whether the agent is authorized to access requested resources. - :param resource_id: Resource ID or a list of resource IDs whose authorization is required. + :param resources: List of resource IDs whose authorization is requested. :return: Is resource access authorized? """ if not self.AuthService.is_enabled(): # Authorization is disabled = everything is allowed return True - if isinstance(resource_id, str): - return has_resource_access(self.UserInfo, {resource_id}, tenant=self.Tenant) - else: - return has_resource_access(self.UserInfo, resource_id, tenant=self.Tenant) + if not self.is_valid(): + return False + + return has_resource_access(self.UserInfo, resources, tenant=Tenant.get(None)) + + + def require_superuser(self): + """ + Assert that the agent has superuser access. + """ + if not self.is_superuser(): + raise AccessDeniedError() + + + def require_resource_access(self, *resources: typing.Iterable[str]): + """ + Assert that the agent is authorized to access the required resources. + :param resources: List of resource IDs whose authorization is required. + """ + if not self.has_resource_access(*resources): + raise AccessDeniedError() def authorized_resources(self) -> typing.Optional[typing.Set[str]]: @@ -88,58 +108,61 @@ def authorized_resources(self) -> typing.Optional[typing.Set[str]]: :return: Set of authorized resources. """ if not self.AuthService.is_enabled(): - # Authorization is disabled = authorized resources are unknown + # Authorization is disabled = authorized resources are undefined + return None + + if not self.is_valid(): return None - return get_authorized_resources(self.UserInfo, self.Tenant) + return get_authorized_resources(self.UserInfo, Tenant.get(None)) -def is_superuser(user_info: typing.Mapping) -> bool: +def is_superuser(userinfo: typing.Mapping) -> bool: """ Check if the superuser resource is present in the authorized resource list. """ - return SUPERUSER_RESOURCE_ID in get_authorized_resources(user_info, tenant=None) + return SUPERUSER_RESOURCE_ID in get_authorized_resources(userinfo, tenant=None) def has_resource_access( - user_info: typing.Mapping, - resource_ids: typing.Iterable, + userinfo: typing.Mapping, + resources: typing.Iterable, tenant: typing.Union[str, None], ) -> bool: """ Check if the requested resources or the superuser resource are present in the authorized resource list. """ - if is_superuser(user_info): + if is_superuser(userinfo): return True - authorized_resources = get_authorized_resources(user_info, tenant) - for resource in resource_ids: + authorized_resources = get_authorized_resources(userinfo, tenant) + for resource in resources: if resource not in authorized_resources: return False return True -def has_tenant_access(user_info: typing.Mapping, tenant: str) -> bool: +def has_tenant_access(userinfo: typing.Mapping, tenant: str) -> bool: """ Check the agent's userinfo to see if they are authorized to access a tenant. If the agent has superuser access, tenant access is always implicitly granted. """ if tenant == "*": raise ValueError("Invalid tenant name: '*'") - if is_superuser(user_info): + if is_superuser(userinfo): return True - if tenant in user_info.get("resources", {}): + if tenant in userinfo.get("resources", {}): return True return False -def get_authorized_resources(user_info: typing.Mapping, tenant: typing.Union[str, None]) -> typing.Set[str]: +def get_authorized_resources(userinfo: typing.Mapping, tenant: typing.Union[str, None]) -> typing.Set[str]: """ Extract resources authorized within given tenant (or globally, if tenant is None). - :param user_info: + :param userinfo: :param tenant: :return: Set of authorized resources. """ - return set(user_info.get("resources", {}).get(tenant if tenant is not None else "*", [])) + return set(userinfo.get("resources", {}).get(tenant if tenant is not None else "*", [])) From 868cba913e064cb978f87fad6d5081c1d03f89f0 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Tue, 8 Oct 2024 14:24:43 +0200 Subject: [PATCH 21/44] always use tenant from the context --- asab/web/auth/authz.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 9411fded..716479a1 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -81,6 +81,26 @@ def has_resource_access(self, *resources: typing.Iterable[str]) -> bool: return has_resource_access(self.UserInfo, resources, tenant=Tenant.get(None)) + def has_tenant_access(self) -> bool: + """ + Check whether the agent has access to the tenant in context. + + :return: Is tenant access authorized? + """ + tenant = Tenant.get(None) + if tenant is None: + raise ValueError("No tenant in context nor in argument.") + + if not self.AuthService.is_enabled(): + # Authorization is disabled = everything is allowed + return True + + if not self.is_valid(): + return False + + return has_tenant_access(self.UserInfo, tenant) + + def require_superuser(self): """ Assert that the agent has superuser access. @@ -92,12 +112,21 @@ def require_superuser(self): def require_resource_access(self, *resources: typing.Iterable[str]): """ Assert that the agent is authorized to access the required resources. + :param resources: List of resource IDs whose authorization is required. """ if not self.has_resource_access(*resources): raise AccessDeniedError() + def require_tenant_access(self): + """ + Assert that the agent is authorized to access the tenant in the context. + """ + if not self.has_tenant_access(): + raise AccessDeniedError() + + def authorized_resources(self) -> typing.Optional[typing.Set[str]]: """ Return the set of EXPLICITLY authorized resources. (Use carefully with superusers.) From 9bf8b3adfdb166395a18336a2ca7b0ba7f5b288d Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Tue, 8 Oct 2024 14:35:15 +0200 Subject: [PATCH 22/44] Authorization factory --- asab/web/auth/service.py | 53 +++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index f776ec63..da07028f 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -144,6 +144,8 @@ def __init__(self, app, service_name="asab.AuthService"): if self.Mode == AuthMode.ENABLED: self.App.TaskService.schedule(self._fetch_public_keys_if_needed()) + self.Authorizations: typing.Dict[typing.Tuple[str, str], Authorization] = {} + def _prepare_mock_user_info(self): # Load custom user info @@ -325,12 +327,33 @@ async def extract_user_info_from_request(self, request): return user_info - def authorize_tenant(self, tenant: typing.Union[str, None], user_info: typing.Dict): - if tenant is not None and self.Mode != AuthMode.DISABLED: - if not has_tenant_access(user_info, tenant): - L.warning("Tenant not authorized.", struct_data={ - "tenant": tenant, "sub": user_info.get("sub")}) + async def build_authorization(self, id_token: typing.Union[str, None]) -> typing.Optional[Authorization]: + """ + Build authorization from ID token string and tenant context. + :param id_token: Base64-encoded JWToken from Authorization header + :return: Valid asab.web.auth.Authorization object + """ + if self.Mode == AuthMode.DISABLED: + return None + elif self.Mode == AuthMode.MOCK: + return Authorization(self, self.MockUserInfo) + elif self.Mode == AuthMode.ENABLED and id_token is None: + raise AccessDeniedError() + + # Try if the object already exists + authz = self.Authorizations.get(id_token) + if authz is not None: + if not authz.is_valid(): raise AccessDeniedError() + return authz + + # Create a new Authorization object and store it + userinfo = await self.get_userinfo_from_id_token(id_token) + authz = Authorization(self, userinfo) + # TODO: Authorization lifecycle management + # - clean them up after they expire + self.Authorizations[id_token] = authz + return authz def _authorize_request(self, handler): @@ -341,19 +364,21 @@ def _authorize_request(self, handler): @functools.wraps(handler) async def wrapper(*args, **kwargs): request = args[-1] - # Extract auth data from request headers - user_info = await self.extract_user_info_from_request(request) + + if self.Mode == AuthMode.ENABLED: + bearer_token = _get_bearer_token(request) + else: + bearer_token = None + + # Create Authorization context + authz = await self.build_authorization(bearer_token) # Authorize tenant context tenant = Tenant.get(None) - self.authorize_tenant(tenant, user_info) + if tenant is not None: + authz.require_tenant_access() - # Create Authorization context - # TODO: Authorization lifecycle management - # - "cache"" objects under (bearer_token, tenant) key - # - clean them up after they expire - auth = Authorization(self, user_info, tenant) - auth_ctx = Authz.set(auth) + auth_ctx = Authz.set(authz) try: response = await handler(*args, **kwargs) finally: From 91d7d08579d1ffbe405beba354a22d6337de10a6 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Tue, 8 Oct 2024 15:01:14 +0200 Subject: [PATCH 23/44] fix validity; extend repr; log authz errors --- asab/web/auth/authz.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 716479a1..b323a20e 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -27,16 +27,19 @@ def __init__(self, auth_service, userinfo: dict): self.Email = self.UserInfo.get("email") self.Phone = self.UserInfo.get("phone") - self.Expiration = datetime.datetime.fromtimestamp(int(self.UserInfo.get("exp")), datetime.timezone.utc) self.Issuer = self.UserInfo.get("iss") # Who issued the authorization self.AuthorizedParty = self.UserInfo.get("azp") # What party (application) is authorized + self.IssuedAt = datetime.datetime.fromtimestamp(int(self.UserInfo.get("iat")), datetime.timezone.utc) + self.Expiration = datetime.datetime.fromtimestamp(int(self.UserInfo.get("exp")), datetime.timezone.utc) def __repr__(self): - return "".format( - "SUPERUSER, " if self.is_superuser() else "", + return "".format( + "SUPERUSER, " if self.has_superuser_access() else "", self.CredentialsId, self.AuthorizedParty, + self.IssuedAt.isoformat(), + self.Expiration.isoformat(), ) @@ -45,10 +48,10 @@ def is_valid(self) -> bool: Check if the authorization is not expired. :return: Authorization validity """ - return datetime.datetime.now(datetime.timezone.utc) > self.Expiration + return datetime.datetime.now(datetime.timezone.utc) < self.Expiration - def is_superuser(self) -> bool: + def has_superuser_access(self) -> bool: """ Check whether the agent is a superuser. @@ -101,11 +104,13 @@ def has_tenant_access(self) -> bool: return has_tenant_access(self.UserInfo, tenant) - def require_superuser(self): + def require_superuser_access(self): """ Assert that the agent has superuser access. """ - if not self.is_superuser(): + if not self.has_superuser_access(): + L.warning("Superuser authorization required.", struct_data={ + "cid": self.CredentialsId, "azp": self.AuthorizedParty}) raise AccessDeniedError() @@ -116,6 +121,8 @@ def require_resource_access(self, *resources: typing.Iterable[str]): :param resources: List of resource IDs whose authorization is required. """ if not self.has_resource_access(*resources): + L.warning("Resource authorization required.", struct_data={ + "resource": resources, "cid": self.CredentialsId, "azp": self.AuthorizedParty}) raise AccessDeniedError() @@ -124,6 +131,8 @@ def require_tenant_access(self): Assert that the agent is authorized to access the tenant in the context. """ if not self.has_tenant_access(): + L.warning("Tenant authorization required.", struct_data={ + "tenant": Tenant.get(), "cid": self.CredentialsId, "azp": self.AuthorizedParty}) raise AccessDeniedError() From 1dc62c3d19f4bdf2ef524616f905447f60429a12 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Tue, 8 Oct 2024 15:01:52 +0200 Subject: [PATCH 24/44] use ready method --- asab/web/auth/decorator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/asab/web/auth/decorator.py b/asab/web/auth/decorator.py index bcdd282a..366a1212 100644 --- a/asab/web/auth/decorator.py +++ b/asab/web/auth/decorator.py @@ -37,8 +37,7 @@ async def wrapper(*args, **kwargs): if authz is None: raise AccessDeniedError() - if not authz.has_resource_access(resources): - raise AccessDeniedError() + authz.require_resource_access(*resources) return await handler(*args, **kwargs) From 18772441db535d0cfea9ee148582feb9a96339d8 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Tue, 8 Oct 2024 15:41:23 +0200 Subject: [PATCH 25/44] no Authorization when AuthService is disabled --- asab/web/auth/authz.py | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index b323a20e..5347aa5c 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -13,13 +13,14 @@ class Authorization: """ - Contains authentication and authorization details, provides methods for checking access control + Contains authentication and authorization details, provides methods for checking access control. + + Requires that AuthService is initialized and enabled. """ def __init__(self, auth_service, userinfo: dict): self.AuthService = auth_service - if self.AuthService.is_enabled() and userinfo is None: - L.error("Userinfo is mandatory when AuthService is enabled.") - raise AccessDeniedError() + if not self.AuthService.is_enabled(): + raise ValueError("Cannot create Authorization when AuthService is disabled.") self.UserInfo = userinfo or {} self.CredentialsId = self.UserInfo.get("sub") @@ -29,8 +30,10 @@ def __init__(self, auth_service, userinfo: dict): self.Issuer = self.UserInfo.get("iss") # Who issued the authorization self.AuthorizedParty = self.UserInfo.get("azp") # What party (application) is authorized - self.IssuedAt = datetime.datetime.fromtimestamp(int(self.UserInfo.get("iat")), datetime.timezone.utc) - self.Expiration = datetime.datetime.fromtimestamp(int(self.UserInfo.get("exp")), datetime.timezone.utc) + self.IssuedAt = datetime.datetime.fromtimestamp(int(self.UserInfo["iat"]), datetime.timezone.utc) + self.Expiration = datetime.datetime.fromtimestamp(int(self.UserInfo["exp"]), datetime.timezone.utc) + + print(self) def __repr__(self): @@ -57,10 +60,6 @@ def has_superuser_access(self) -> bool: :return: Is the agent a superuser? """ - if not self.AuthService.is_enabled(): - # Authorization is disabled = everything is allowed - return True - if not self.is_valid(): return False @@ -74,10 +73,6 @@ def has_resource_access(self, *resources: typing.Iterable[str]) -> bool: :param resources: List of resource IDs whose authorization is requested. :return: Is resource access authorized? """ - if not self.AuthService.is_enabled(): - # Authorization is disabled = everything is allowed - return True - if not self.is_valid(): return False @@ -94,10 +89,6 @@ def has_tenant_access(self) -> bool: if tenant is None: raise ValueError("No tenant in context nor in argument.") - if not self.AuthService.is_enabled(): - # Authorization is disabled = everything is allowed - return True - if not self.is_valid(): return False @@ -145,10 +136,6 @@ def authorized_resources(self) -> typing.Optional[typing.Set[str]]: :return: Set of authorized resources. """ - if not self.AuthService.is_enabled(): - # Authorization is disabled = authorized resources are undefined - return None - if not self.is_valid(): return None From 55443255576eef36799b79455782a4a5068d7ca2 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Tue, 8 Oct 2024 16:17:11 +0200 Subject: [PATCH 26/44] authorization refactoring --- asab/web/auth/service.py | 157 ++++++++++++++++++++++----------------- 1 file changed, 88 insertions(+), 69 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index da07028f..fcae1880 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -145,6 +145,7 @@ def __init__(self, app, service_name="asab.AuthService"): self.App.TaskService.schedule(self._fetch_public_keys_if_needed()) self.Authorizations: typing.Dict[typing.Tuple[str, str], Authorization] = {} + self.App.PubSub.subscribe("Application.housekeeping!", self.delete_expired_authorizations) def _prepare_mock_user_info(self): @@ -201,29 +202,67 @@ def is_ready(self): return True - async def get_userinfo_from_id_token(self, bearer_token): + async def build_authorization(self, id_token: str) -> Authorization: """ - Parse the bearer ID token and extract user info. + Build authorization from ID token string and tenant context. + + :param id_token: Base64-encoded JWToken from Authorization header + :return: Valid asab.web.auth.Authorization object """ - if not self.is_ready(): - # Try to load the public keys again - if not self.TrustedPublicKeys["keys"]: - await self._fetch_public_keys_if_needed() - if not self.is_ready(): - L.error("Cannot authenticate request: Failed to load authorization server's public keys.") - raise aiohttp.web.HTTPUnauthorized() + if not self.is_enabled(): + raise ValueError("Cannot build Authorization when AuthService is disabled.") - try: - return _get_id_token_claims(bearer_token, self.TrustedPublicKeys) - except (jwcrypto.jws.InvalidJWSSignature, jwcrypto.jwt.JWTMissingKey): - # Authz server keys may have changed. Try to reload them. - await self._fetch_public_keys_if_needed() + # Try if the object already exists + authz = self.Authorizations.get(id_token) + if authz is not None: + if not authz.is_valid(): + L.warning("Authorization has expired.", struct_data={ + "cid": authz.CredentialsId, "exp": authz.Expiration.isoformat()}) + del self.Authorizations[id_token] + raise AccessDeniedError() + return authz + # Create a new Authorization object and store it + if self.Mode == AuthMode.MOCK: + assert id_token == "MOCK" + authz = Authorization(self, self.MockUserInfo) + else: + userinfo = await self._get_userinfo_from_id_token(id_token) + authz = Authorization(self, userinfo) + + self.Authorizations[id_token] = authz + return authz + + + async def delete_expired_authorizations(self): + expired = [] + for key, authz in self.Authorizations.items(): + if not authz.is_valid(): + expired.append(key) + for key in expired: + del self.Authorizations[key] + + + def bearer_token_from_request(self, request): + """ + Validate the Authorizetion header and extract the Bearer token value + """ + if self.Mode == AuthMode.MOCK: + return "MOCK" + + authorization_header = request.headers.get(aiohttp.hdrs.AUTHORIZATION) + if authorization_header is None: + L.warning("No Authorization header.") + raise aiohttp.web.HTTPUnauthorized() try: - return _get_id_token_claims(bearer_token, self.TrustedPublicKeys) - except (jwcrypto.jws.InvalidJWSSignature, jwcrypto.jwt.JWTMissingKey) as e: - L.error("Cannot authenticate request: {}".format(str(e))) - raise NotAuthenticatedError() + auth_type, token_value = authorization_header.split(" ", 1) + except ValueError: + L.warning("Cannot parse Authorization header.") + raise aiohttp.web.HTTPBadRequest() + if auth_type != "Bearer": + L.warning("Unsupported Authorization header type: {!r}".format(auth_type)) + raise aiohttp.web.HTTPUnauthorized() + return token_value async def _fetch_public_keys_if_needed(self, *args, **kwargs): @@ -315,47 +354,6 @@ async def fetch_keys(session): L.debug("Public key loaded.", struct_data={"url": self.PublicKeysUrl}) - async def extract_user_info_from_request(self, request): - if not self.is_enabled(): - user_info = None - elif self.Mode == AuthMode.MOCK: - user_info = self.MockUserInfo - else: - # Extract user info from the request Authorization header - bearer_token = _get_bearer_token(request) - user_info = await self.get_userinfo_from_id_token(bearer_token) - return user_info - - - async def build_authorization(self, id_token: typing.Union[str, None]) -> typing.Optional[Authorization]: - """ - Build authorization from ID token string and tenant context. - :param id_token: Base64-encoded JWToken from Authorization header - :return: Valid asab.web.auth.Authorization object - """ - if self.Mode == AuthMode.DISABLED: - return None - elif self.Mode == AuthMode.MOCK: - return Authorization(self, self.MockUserInfo) - elif self.Mode == AuthMode.ENABLED and id_token is None: - raise AccessDeniedError() - - # Try if the object already exists - authz = self.Authorizations.get(id_token) - if authz is not None: - if not authz.is_valid(): - raise AccessDeniedError() - return authz - - # Create a new Authorization object and store it - userinfo = await self.get_userinfo_from_id_token(id_token) - authz = Authorization(self, userinfo) - # TODO: Authorization lifecycle management - # - clean them up after they expire - self.Authorizations[id_token] = authz - return authz - - def _authorize_request(self, handler): """ Authenticate the request by the JWT ID token in the Authorization header. @@ -365,12 +363,10 @@ def _authorize_request(self, handler): async def wrapper(*args, **kwargs): request = args[-1] - if self.Mode == AuthMode.ENABLED: - bearer_token = _get_bearer_token(request) - else: - bearer_token = None + if not self.is_enabled(): + return await handler(*args, **kwargs) - # Create Authorization context + bearer_token = self.bearer_token_from_request(request) authz = await self.build_authorization(bearer_token) # Authorize tenant context @@ -378,13 +374,11 @@ async def wrapper(*args, **kwargs): if tenant is not None: authz.require_tenant_access() - auth_ctx = Authz.set(authz) + authz_ctx = Authz.set(authz) try: - response = await handler(*args, **kwargs) + return await handler(*args, **kwargs) finally: - Authz.reset(auth_ctx) - - return response + Authz.reset(authz_ctx) return wrapper @@ -473,6 +467,31 @@ def _wrap_handler(self, route): route._handler = handler + async def _get_userinfo_from_id_token(self, bearer_token): + """ + Parse the bearer ID token and extract user info. + """ + if not self.is_ready(): + # Try to load the public keys again + if not self.TrustedPublicKeys["keys"]: + await self._fetch_public_keys_if_needed() + if not self.is_ready(): + L.error("Cannot authenticate request: Failed to load authorization server's public keys.") + raise aiohttp.web.HTTPUnauthorized() + + try: + return _get_id_token_claims(bearer_token, self.TrustedPublicKeys) + except (jwcrypto.jws.InvalidJWSSignature, jwcrypto.jwt.JWTMissingKey): + # Authz server keys may have changed. Try to reload them. + await self._fetch_public_keys_if_needed() + + try: + return _get_id_token_claims(bearer_token, self.TrustedPublicKeys) + except (jwcrypto.jws.InvalidJWSSignature, jwcrypto.jwt.JWTMissingKey) as e: + L.error("Cannot authenticate request: {}".format(str(e))) + raise NotAuthenticatedError() + + def _get_id_token_claims(bearer_token: str, auth_server_public_key): """ Parse and validate JWT ID token and extract the claims (user info) From e84052c6554358ee45e1f71dd40ab1a1bea5c60a Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Tue, 8 Oct 2024 17:16:07 +0200 Subject: [PATCH 27/44] update auth example --- examples/web-auth.py | 171 +++++++++++++++++++++++++++++++------------ 1 file changed, 126 insertions(+), 45 deletions(-) diff --git a/examples/web-auth.py b/examples/web-auth.py index cb06fd10..95e07585 100644 --- a/examples/web-auth.py +++ b/examples/web-auth.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import typing +import secrets import asab.web.rest import asab.web.auth import asab.contextvars @@ -29,7 +30,11 @@ } -class MyApplication(asab.Application): +class NotesApplication(asab.Application): + """ + Web application with a simple CRUD REST API for notes management. + Demonstrates the usage of the authorization module (asab.web.auth). + """ def __init__(self): super().__init__() @@ -50,73 +55,149 @@ def __init__(self): self.AuthService.install(self.WebContainer) # Add routes - self.WebContainer.WebApp.router.add_get("/noauth", self.noauth) - self.WebContainer.WebApp.router.add_get("/authn", self.authn) - self.WebContainer.WebApp.router.add_get("/authz", self.authz) + self.WebContainer.WebApp.router.add_get("/", self.info) + self.WebContainer.WebApp.router.add_get("/note", self.list_notes) + self.WebContainer.WebApp.router.add_post("/note", self.create_note) + self.WebContainer.WebApp.router.add_get("/note/{note_id}", self.read_note) + self.WebContainer.WebApp.router.add_put("/note/{note_id}", self.edit_note) + self.WebContainer.WebApp.router.add_delete("/note/{note_id}", self.delete_note) + + # Notes storage + self.Notes: typing.Dict[str, typing.Dict[str, typing.Dict[str, str]]] = { + # Add a few notes so that the storage is not empty + "default": { # Tenant ID + "12345678": { # Note ID + "_id": "12345678", + "created_by": "that-one-developer:)", + "content": "This is an example note in tenant 'default'!", + } + }, + "brothers-workspace": { + "abcdefgh": { + "_id": "abcdefgh", + "created_by": "that-one-developer:)", + "content": "This is another example note, this time in tenant 'brothers-workspace'!", + } + }, + } @asab.web.auth.noauth - async def noauth(self, request): + async def info(self, request): """ - NO AUTH - - no authentication or authorization required - - `tenant`, `user_info`, `resources` params not allowed + Show application info. + + No authentication or authorization required, but also no user details are available. """ + + # Tenant context is not set for endpoint with @asab.web.auth.noauth decorator. + # `asab.contextvars.Tenant.get()` will throw LookupError + # Same with `asab.contextvars.Authz.get()` + data = { - "tenant": "NOT AVAILABLE", - "resources": "NOT AVAILABLE", - "user_info": "NOT AVAILABLE", + "message": "This app stores notes. Call GET /note to see stored notes.", } return asab.web.rest.json_response(request, data) - async def authn( - self, - request, - *, - user_info: typing.Optional[dict], - resources: typing.Optional[frozenset], - ): + async def list_notes(self, request): """ - AUTHENTICATION REQUIRED - - request must be authenticated - - if there is a tenant ID in the X-Tenant header, the request must be authorized to access that tenant - - returns 401 if authentication not successful + Show notes stored in the current tenant. + + Authentication required. """ tenant = asab.contextvars.Tenant.get() + authz = asab.contextvars.Authz.get() + + notes = self.Notes.get(tenant, {}) data = { - "tenant": tenant, - "resources": list(resources) if resources else None, - "user_info": user_info, + "count": len(notes), # Anybody can see how many notes are there } + if authz.has_resource_access("note:read"): + # Seeing the actual notes requires authorized access to "note:read" + data["data"] = notes + return asab.web.rest.json_response(request, data) - @asab.web.auth.require("web-auth:access") - async def authz( - self, - request, - *, - user_info: typing.Optional[dict], - resources: typing.Optional[frozenset] - ): + @asab.web.auth.require("note:read") + async def read_note(self, request): + """ + Find note by ID in the current tenant and return it. + + Authentication and authorization of "note:read" required. + """ + tenant = asab.contextvars.Tenant.get() + + note_id = request.match_info["note_id"] + if tenant in self.Notes and note_id in self.Notes[tenant]: + return asab.web.rest.json_response(request, self.Notes[tenant][note_id]) + else: + return asab.web.rest.json_response(request, {"result": "NOT-FOUND"}, status=404) + + + @asab.web.rest.json_schema_handler( + {"type": "string"} + ) + @asab.web.auth.require("note:edit") + async def create_note(self, request, *, json_data): """ - AUTHORIZATION REQUIRED - - this endpoint is a protected resource - - request must be authenticated and authorized to access this resource - - if there is a tenant ID in the X-Tenant header, the request must be authorized to access the resource within that tenant - - returns 401 if authentication not successful - - returns 403 if authorization not successful + Create a note in the current tenant. + + Authentication and authorization of "note:edit" required. """ tenant = asab.contextvars.Tenant.get() - data = { - "tenant": tenant, - "resources": list(resources) if resources else None, - "user_info": user_info, + authz = asab.contextvars.Authz.get() + + if not tenant in self.Notes: + self.Notes[tenant] = {} + note_id = secrets.token_urlsafe(8) + self.Notes[tenant][note_id] = { + "_id": note_id, + "created_by": authz.CredentialsId, + "content": json_data, } - return asab.web.rest.json_response(request, data) + return asab.web.rest.json_response(request, {"_id": note_id}, status=201) + + + @asab.web.rest.json_schema_handler( + {"type": "string"} + ) + @asab.web.auth.require("note:edit") + async def edit_note(self, request, *, json_data): + """ + Update an existing note in the current tenant. + + Authentication and authorization of "note:edit" required. + """ + tenant = asab.contextvars.Tenant.get() + authz = asab.contextvars.Authz.get() + + if tenant in self.Notes and note_id in self.Notes[tenant]: + self.Notes[tenant][note_id]["content"] = json_data + return asab.web.rest.json_response(request, {"result": "OK"}) + else: + return asab.web.rest.json_response(request, {"result": "NOT-FOUND"}, status=404) + + + @asab.web.auth.require("note:delete") + async def delete_note(self, request): + """ + Find note by ID in the current tenant and delete it. + + Authentication and authorization of "note:delete" required. + """ + tenant = asab.contextvars.Tenant.get() + authz = asab.contextvars.Authz.get() + + note_id = request.match_info["note_id"] + if tenant in self.Notes and note_id in self.Notes[tenant]: + del self.Notes[tenant][note_id] + return asab.web.rest.json_response(request, {"result": "OK"}) + else: + return asab.web.rest.json_response(request, {"result": "NOT-FOUND"}, status=404) if __name__ == "__main__": - app = MyApplication() + app = NotesApplication() app.run() From 58c60319362e288f0b0a35feec377ef860dc1249 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Wed, 9 Oct 2024 09:24:36 +0200 Subject: [PATCH 28/44] Authorization validate method --- asab/web/auth/authz.py | 27 +++++++++++---------------- asab/web/auth/service.py | 26 +++++++++++++++++--------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index 5347aa5c..adaec935 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -46,12 +46,14 @@ def __repr__(self): ) - def is_valid(self) -> bool: + def validate(self): """ - Check if the authorization is not expired. - :return: Authorization validity + Ensure that the authorization is not expired. """ - return datetime.datetime.now(datetime.timezone.utc) < self.Expiration + if datetime.datetime.now(datetime.timezone.utc) > self.Expiration: + L.warning("Authorization expired.", struct_data={ + "cid": self.CredentialsId, "azp": self.AuthorizedParty, "exp": self.Expiration.isoformat()}) + raise AccessDeniedError() def has_superuser_access(self) -> bool: @@ -60,9 +62,7 @@ def has_superuser_access(self) -> bool: :return: Is the agent a superuser? """ - if not self.is_valid(): - return False - + self.validate() return is_superuser(self.UserInfo) @@ -73,9 +73,7 @@ def has_resource_access(self, *resources: typing.Iterable[str]) -> bool: :param resources: List of resource IDs whose authorization is requested. :return: Is resource access authorized? """ - if not self.is_valid(): - return False - + self.validate() return has_resource_access(self.UserInfo, resources, tenant=Tenant.get(None)) @@ -85,13 +83,12 @@ def has_tenant_access(self) -> bool: :return: Is tenant access authorized? """ + self.validate() + tenant = Tenant.get(None) if tenant is None: raise ValueError("No tenant in context nor in argument.") - if not self.is_valid(): - return False - return has_tenant_access(self.UserInfo, tenant) @@ -136,9 +133,7 @@ def authorized_resources(self) -> typing.Optional[typing.Set[str]]: :return: Set of authorized resources. """ - if not self.is_valid(): - return None - + self.validate() return get_authorized_resources(self.UserInfo, Tenant.get(None)) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index fcae1880..e1b5bd60 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -21,7 +21,7 @@ from ...library.service import LogObsolete from ...utils import string_to_boolean from ...contextvars import Tenant, Authz -from .authz import Authorization, has_tenant_access +from .authz import Authorization try: import jwcrypto.jwk @@ -145,7 +145,7 @@ def __init__(self, app, service_name="asab.AuthService"): self.App.TaskService.schedule(self._fetch_public_keys_if_needed()) self.Authorizations: typing.Dict[typing.Tuple[str, str], Authorization] = {} - self.App.PubSub.subscribe("Application.housekeeping!", self.delete_expired_authorizations) + self.App.PubSub.subscribe("Application.housekeeping!", self.delete_invalid_authorizations) def _prepare_mock_user_info(self): @@ -204,7 +204,7 @@ def is_ready(self): async def build_authorization(self, id_token: str) -> Authorization: """ - Build authorization from ID token string and tenant context. + Build authorization from ID token string. :param id_token: Base64-encoded JWToken from Authorization header :return: Valid asab.web.auth.Authorization object @@ -215,11 +215,11 @@ async def build_authorization(self, id_token: str) -> Authorization: # Try if the object already exists authz = self.Authorizations.get(id_token) if authz is not None: - if not authz.is_valid(): - L.warning("Authorization has expired.", struct_data={ - "cid": authz.CredentialsId, "exp": authz.Expiration.isoformat()}) + try: + authz.validate() + except AccessDeniedError as e: del self.Authorizations[id_token] - raise AccessDeniedError() + raise e return authz # Create a new Authorization object and store it @@ -234,11 +234,19 @@ async def build_authorization(self, id_token: str) -> Authorization: return authz - async def delete_expired_authorizations(self): + async def delete_invalid_authorizations(self): + """ + Check for expired Authorization objects and delete them + """ expired = [] + # Find expired for key, authz in self.Authorizations.items(): - if not authz.is_valid(): + try: + authz.validate() + except AccessDeniedError: expired.append(key) + + # Delete expired for key in expired: del self.Authorizations[key] From f0b10c917e8f89c1b956ff74919f1c9c25c0aacf Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 10 Oct 2024 14:06:12 +0200 Subject: [PATCH 29/44] re-introduce is_valid (necessary for cleanup) --- asab/web/auth/authz.py | 34 +++++++++++++++++++++------------- asab/web/auth/service.py | 9 ++++----- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/asab/web/auth/authz.py b/asab/web/auth/authz.py index adaec935..f5a20885 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authz.py @@ -46,14 +46,11 @@ def __repr__(self): ) - def validate(self): + def is_valid(self): """ - Ensure that the authorization is not expired. + Check that the authorization is not expired. """ - if datetime.datetime.now(datetime.timezone.utc) > self.Expiration: - L.warning("Authorization expired.", struct_data={ - "cid": self.CredentialsId, "azp": self.AuthorizedParty, "exp": self.Expiration.isoformat()}) - raise AccessDeniedError() + return datetime.datetime.now(datetime.timezone.utc) < self.Expiration def has_superuser_access(self) -> bool: @@ -62,7 +59,7 @@ def has_superuser_access(self) -> bool: :return: Is the agent a superuser? """ - self.validate() + self.require_valid() return is_superuser(self.UserInfo) @@ -73,7 +70,7 @@ def has_resource_access(self, *resources: typing.Iterable[str]) -> bool: :param resources: List of resource IDs whose authorization is requested. :return: Is resource access authorized? """ - self.validate() + self.require_valid() return has_resource_access(self.UserInfo, resources, tenant=Tenant.get(None)) @@ -83,15 +80,26 @@ def has_tenant_access(self) -> bool: :return: Is tenant access authorized? """ - self.validate() + self.require_valid() - tenant = Tenant.get(None) - if tenant is None: - raise ValueError("No tenant in context nor in argument.") + try: + tenant = Tenant.get() + except LookupError as e: + raise ValueError("No tenant in context.") from e return has_tenant_access(self.UserInfo, tenant) + def require_valid(self): + """ + Ensure that the authorization is not expired. + """ + if not self.is_valid(): + L.warning("Authorization expired.", struct_data={ + "cid": self.CredentialsId, "azp": self.AuthorizedParty, "exp": self.Expiration.isoformat()}) + raise AccessDeniedError() + + def require_superuser_access(self): """ Assert that the agent has superuser access. @@ -133,7 +141,7 @@ def authorized_resources(self) -> typing.Optional[typing.Set[str]]: :return: Set of authorized resources. """ - self.validate() + self.require_valid() return get_authorized_resources(self.UserInfo, Tenant.get(None)) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index e1b5bd60..6b0af885 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -216,7 +216,7 @@ async def build_authorization(self, id_token: str) -> Authorization: authz = self.Authorizations.get(id_token) if authz is not None: try: - authz.validate() + authz.require_valid() except AccessDeniedError as e: del self.Authorizations[id_token] raise e @@ -238,12 +238,10 @@ async def delete_invalid_authorizations(self): """ Check for expired Authorization objects and delete them """ - expired = [] # Find expired + expired = [] for key, authz in self.Authorizations.items(): - try: - authz.validate() - except AccessDeniedError: + if not authz.is_valid(): expired.append(key) # Delete expired @@ -277,6 +275,7 @@ async def _fetch_public_keys_if_needed(self, *args, **kwargs): """ Check if public keys have been fetched from the authorization server and fetch them if not yet. """ + # TODO: Refactor into Key Providers # Add internal shared auth key if self.DiscoveryService is not None: if self.DiscoveryService.InternalAuthKey is not None: From de2322d4610b5362c4ab20f8cd175309c6dd9fb9 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 10 Oct 2024 16:23:01 +0200 Subject: [PATCH 30/44] auto-install authorization middleware --- asab/web/auth/service.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 6b0af885..08b19de0 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -147,6 +147,9 @@ def __init__(self, app, service_name="asab.AuthService"): self.Authorizations: typing.Dict[typing.Tuple[str, str], Authorization] = {} self.App.PubSub.subscribe("Application.housekeeping!", self.delete_invalid_authorizations) + # Try to auto-install authorization middleware + self.install() + def _prepare_mock_user_info(self): # Load custom user info @@ -178,14 +181,25 @@ def is_enabled(self) -> bool: return self.Mode in {AuthMode.ENABLED, AuthMode.MOCK} - def install(self, web_container): + def install(self, web_container=None): """ Apply authorization to all web handlers in a web container, according to their arguments and path parameters. :param web_container: Web container to be protected by authorization. :type web_container: asab.web.WebContainer """ - # TODO: Call this automatically if there is only one container + if web_container is None: + # Locate web container if there is only one + web_service = self.App.get_service("asab.WebService") + if len(web_service.Containers) != 1: + return + web_container = web_service.WebContainer + + # Check that the middleware has not been installed yet + for middleware in web_container.WebApp.on_startup: + if middleware == self._wrap_handlers: + return + web_container.WebApp.on_startup.append(self._wrap_handlers) From 95ff62f8e3207841facbbca28fd30022743af0d4 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 10 Oct 2024 17:50:16 +0200 Subject: [PATCH 31/44] update auth docs --- .../web/authorization_multitenancy.md | 143 ++++++++---------- 1 file changed, 67 insertions(+), 76 deletions(-) diff --git a/docs/reference/services/web/authorization_multitenancy.md b/docs/reference/services/web/authorization_multitenancy.md index c4fc259b..3baa2fc8 100644 --- a/docs/reference/services/web/authorization_multitenancy.md +++ b/docs/reference/services/web/authorization_multitenancy.md @@ -17,23 +17,32 @@ authorization server ## Getting started -To get started, initialize `AuthService` and install it in your `asab.web.WebContainer`: +To get started, add `asab.web` module to your application and initialize `asab.web.auth.AuthService`: + +```python +import asab +import asab.web +import asab.web.auth + +... -``` python class MyApplication(asab.Application): def __init__(self): super().__init__() - # Initialize web container - self.add_module(asab.web.Module) - self.WebService = self.get_service("asab.WebService") - self.WebContainer = asab.web.WebContainer(self.WebService, "web") + # Initialize web module + asab.web.create_web_server(self) - # Initialize authorization service and install the decorators + # Initialize authorization service self.AuthService = asab.web.auth.AuthService(self) - self.AuthService.install(self.WebContainer) ``` +!!! note + + If your app has more than one web container, you will need to call `AuthService.install(web_container)` to apply + the authorization. + + !!! note You may also need to specify your authorization server's `public_keys_url` @@ -42,22 +51,36 @@ class MyApplication(asab.Application): you can run the auth module in "mock mode". See the `configuration` section for details. -Every handler in `WebContainer` now accepts only requests with a valid authentication. +Every handler in your web server now accepts only requests with a valid authentication. Unauthenticated requests are automatically answered with [HTTP 401: Unauthorized](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401). -Authenticated requests will be inspected for user info, authorized user tenant and resources. -These attributes are passed to the handler method, if the method in question has -the corresponding keyword arguments (`user_info`, `tenant` and `resources`). -In the following example, the method will receive `user_info` and `resources` from the request: - -``` python -async def order_breakfast(self, request, *, tenant, user_info, resources): - user_id = user_info["sub"] - user_name = user_info["preferred_username"] - if "pancakes:eat" in resources: - ... +For every authenticated request, an `asab.web.auth.Authorization` object is created and stored +in `asab.contextvars.Authz` for easy access. +It contains authorization and authentication details, such as `CredentialsId`, `Username` or `Email`, and +access-checking methods `has_resource_access`, `require_resource_access` and more (see reference below). + +```python +import asab.contextvars +import asab.web.rest + +... + +async def order_breakfast(request): + authz = asab.contextvars.Authz.get() + username = authz.Username + + # This will raise asab.exceptions.AccessDeniedError when the user is not authorized for resource `breakfast:access` + authz.require_resource_access("breakfast:access") + print("{} is ordering breakfast.".format(username)) + + if authz.has_resource_access("breakfast:pancakes"): + print("{} can get pancakes for breakfast!".format(username)) + + if authz.has_superuser_access(): + print("{} can get anything they want!".format(username)) + return asab.web.rest.json_response(request, { - "result": "Good morning {}, your breakfast will be ready in a minute!".format(user_name) + "result": "Good morning {}, your breakfast will be ready in a minute!".format(username) }) ``` @@ -68,11 +91,11 @@ See [examples/web-auth.py](https://github.com/TeskaLabs/asab/blob/master/example The `asab.web.auth` module is configured in the `[auth]` section with the following options: -| Option | Type | Meaning | -|-----------------------|------------------| --- | -| `public_keys_url` | URL | The URL of the authorization server's public keys (also known as `jwks_uri` in [OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8414#section-2)) | -| `enabled` | boolean or `"mock"` | Enables or disables authentication and authorization or switches to mock authorization. In mock mode, all incoming requests are authorized with mock user info. There is no communication with the authorization server (so it is not necessary to configure `public_keys_url` in dev mode). -| `mock_user_info_path` | path | Path to JSON file that contains user info claims used in mock mode. The structure of user info should follow the [OpenID Connect userinfo definition](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse) and also contain the `resources` object. +| Option | Type | Meaning | +| --- | --- | --- | +| `public_keys_url` | URL | The URL of the authorization server's public keys (also known as `jwks_uri` in [OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8414#section-2)) | +| `enabled` | boolean or `"mock"` | Enables or disables authentication and authorization or switches to mock authorization. In mock mode, all incoming requests are authorized with mock user info. There is no communication with the authorization server (so it is not necessary to configure `public_keys_url` in dev mode). | +| `mock_user_info_path` | path | Path to JSON file that contains user info claims used in mock mode. The structure of user info should follow the [OpenID Connect userinfo definition](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse) and also contain the `resources` object. | Default options: @@ -84,6 +107,12 @@ mock_user_info_path=/conf/mock-userinfo.json ## Multitenancy +`asab.web.auth.AuthService` supports multi-tenancy. +Incoming request can specify tenant context using `X-Tenant` HTTP header. +If this header is not empty, `AuthService` extracts the tenant ID and verifies if the request is authorized +to access that tenant. +The request tenant can easily be accessed anywhere using `asab.contextvars.Tenant.get()`. + ### Strictly multitenant endpoints Strictly multitenant endpoints always operate within a tenant, hence they need the `tenant` parameter to be always provided. @@ -91,59 +120,18 @@ Such endpoints must define the `tenant` parameter in their **URL path** and incl Auth service extracts the tenant from the URL path, validates the tenant existence, checks if the request is authorized for the tenant, and finally passes the tenant name to the handler method. -!!! example "Example handler:" - - ``` python - class MenuHandler: - def __init__(self, app): - self.MenuService = app.get_service("MenuService") - router = app.WebContainer.WebApp.router - # Add a path with `tenant` parameter - router.add_get("/{tenant}/todays-menu", self.get_todays_menu) - - # Define handler method with `tenant` argument in the signature - async def get_todays_menu(self, request, *, tenant): - menu = await self.MenuService.get_todays_menu(tenant) - return asab.web.rest.json_response(request, data=menu) - ``` - -!!! example "Example request:" +```python +import asab.contextvars +import asab.web.rest - ``` - GET http://localhost:8080/lazy-raccoon-bistro/todays-menu - ``` +... +async def get_todays_menu(request): + tenant = asab.contextvars.Tenant.get() + menu = await get_todays_menu(tenant) + return asab.web.rest.json_response(request, data=menu) +``` -### Configurable multitenant endpoints - -Configurable multitenant endpoints usually operate within a tenant, -but they can also operate in tenantless mode if the application is designed for that. - -When you create an endpoint *without* `tenant` parameter in the URL path and *with* `tenant` argument in the -handler method, the Auth service will either expect the `tenant` parameter to be provided in the **URL query**. -If it is not in the query, the tenant variable is set to `None`. - -!!! example "Example handler:" - - ``` python - class MenuHandler: - def __init__(self, app): - self.MenuService = app.get_service("MenuService") - router = app.WebContainer.WebApp.router - # Add a path without `tenant` parameter - router.add_get("/todays-menu", self.get_todays_menu) - - # Define handler method with `tenant` argument in the signature - async def get_todays_menu(self, request, *, tenant): - menu = await self.MenuService.get_todays_menu(tenant) - return asab.web.rest.json_response(request, data=menu) - ``` - -!!! example "Example requests:" - - ``` - GET http://localhost:8080/todays-menu?tenant=lazy-raccoon-bistro - ``` ## Mock mode @@ -165,6 +153,9 @@ When dev mode is enabled, you don't have to provide `[public_keys_url]` since th ::: asab.web.auth.AuthService +::: asab.web.auth.Authorization + + ::: asab.web.auth.require ::: asab.web.auth.noauth From a3100d20b13250b4c5e9e86107be8f955bf6be52 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Fri, 11 Oct 2024 12:06:10 +0200 Subject: [PATCH 32/44] renaming, docstrings --- asab/web/auth/__init__.py | 2 +- asab/web/auth/{authz.py => authorization.py} | 14 +++++------ asab/web/auth/service.py | 26 +++----------------- 3 files changed, 11 insertions(+), 31 deletions(-) rename asab/web/auth/{authz.py => authorization.py} (95%) diff --git a/asab/web/auth/__init__.py b/asab/web/auth/__init__.py index e06a00c1..104493cd 100644 --- a/asab/web/auth/__init__.py +++ b/asab/web/auth/__init__.py @@ -1,6 +1,6 @@ from .decorator import require, noauth from .service import AuthService -from .authz import Authorization +from .authorization import Authorization __all__ = ( "AuthService", diff --git a/asab/web/auth/authz.py b/asab/web/auth/authorization.py similarity index 95% rename from asab/web/auth/authz.py rename to asab/web/auth/authorization.py index f5a20885..f6bf9d92 100644 --- a/asab/web/auth/authz.py +++ b/asab/web/auth/authorization.py @@ -2,7 +2,7 @@ import typing import logging -from ...exceptions import AccessDeniedError +from ...exceptions import AccessDeniedError, NotAuthenticatedError from ...contextvars import Tenant L = logging.getLogger(__name__) @@ -13,7 +13,7 @@ class Authorization: """ - Contains authentication and authorization details, provides methods for checking access control. + Contains authentication and authorization details, provides methods for checking and enforcing access control. Requires that AuthService is initialized and enabled. """ @@ -33,8 +33,6 @@ def __init__(self, auth_service, userinfo: dict): self.IssuedAt = datetime.datetime.fromtimestamp(int(self.UserInfo["iat"]), datetime.timezone.utc) self.Expiration = datetime.datetime.fromtimestamp(int(self.UserInfo["exp"]), datetime.timezone.utc) - print(self) - def __repr__(self): return "".format( @@ -97,7 +95,7 @@ def require_valid(self): if not self.is_valid(): L.warning("Authorization expired.", struct_data={ "cid": self.CredentialsId, "azp": self.AuthorizedParty, "exp": self.Expiration.isoformat()}) - raise AccessDeniedError() + raise NotAuthenticatedError() def require_superuser_access(self): @@ -134,10 +132,10 @@ def require_tenant_access(self): def authorized_resources(self) -> typing.Optional[typing.Set[str]]: """ - Return the set of EXPLICITLY authorized resources. (Use carefully with superusers.) + Return the set of authorized resources. - NOTE: If possible, use methods has_resource_access(resource_id) and is_superuser() instead of inspecting - the set of resources. + NOTE: If possible, use methods has_resource_access(resource_id) and has_superuser_access() instead of inspecting + the set of resources directly. :return: Set of authorized resources. """ diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 08b19de0..f2f26c94 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -21,7 +21,7 @@ from ...library.service import LogObsolete from ...utils import string_to_boolean from ...contextvars import Tenant, Authz -from .authz import Authorization +from .authorization import Authorization try: import jwcrypto.jwk @@ -83,28 +83,10 @@ class AuthMode(enum.Enum): class AuthService(Service): """ Provides authentication and authorization of incoming requests. - - Configuration: - Configuration section: auth - Configuration options: - public_keys_url: - - default: "" - - URL containing the authorization server's public JWKey set (usually found at "/.well-known/jwks.json") - enabled: - - default: "yes" - - options: "yes", "no", "mocked" - - Switch authentication and authorization on, off or activate mock mode. - - In MOCK MODE - - no authorization server is needed, - - all incoming requests are mock-authorized with pre-defined user info, - - custom mock user info can supplied in a JSON file. - mock_user_info_path: - - default: "/conf/mock-userinfo.json" """ _PUBLIC_KEYS_URL_DEFAULT = "http://localhost:3081/.well-known/jwks.json" - def __init__(self, app, service_name="asab.AuthService"): super().__init__(app, service_name) self.PublicKeysUrl = Config.get("auth", "public_keys_url") or None @@ -145,7 +127,7 @@ def __init__(self, app, service_name="asab.AuthService"): self.App.TaskService.schedule(self._fetch_public_keys_if_needed()) self.Authorizations: typing.Dict[typing.Tuple[str, str], Authorization] = {} - self.App.PubSub.subscribe("Application.housekeeping!", self.delete_invalid_authorizations) + self.App.PubSub.subscribe("Application.housekeeping!", self._delete_invalid_authorizations) # Try to auto-install authorization middleware self.install() @@ -231,7 +213,7 @@ async def build_authorization(self, id_token: str) -> Authorization: if authz is not None: try: authz.require_valid() - except AccessDeniedError as e: + except NotAuthenticatedError as e: del self.Authorizations[id_token] raise e return authz @@ -248,7 +230,7 @@ async def build_authorization(self, id_token: str) -> Authorization: return authz - async def delete_invalid_authorizations(self): + async def _delete_invalid_authorizations(self): """ Check for expired Authorization objects and delete them """ From 7cf6a07a55a8151932f3096e770a9ca4a71be289 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Fri, 11 Oct 2024 12:29:21 +0200 Subject: [PATCH 33/44] documentation --- asab/web/auth/decorator.py | 13 ++++++------- .../services/web/authorization_multitenancy.md | 18 +++++++----------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/asab/web/auth/decorator.py b/asab/web/auth/decorator.py index 366a1212..0f00f772 100644 --- a/asab/web/auth/decorator.py +++ b/asab/web/auth/decorator.py @@ -14,16 +14,16 @@ def require(*resources): """ - Specify resources required for endpoint access. - Requests without these resources result in HTTP 403 response. + Require that the request have authorized access to one or more resources. + Requests without these resources result in AccessDeniedError and consequently in an HTTP 403 response. Args: - resources (Iterable): Resources required to access the decorated method. + resources (Iterable): Resources whose authorization is required. Examples: ```python - @asab.web.authz.require("my-app:token:generate") + @asab.web.auth.require("my-app:token:generate") async def generate_token(self, request): data = await self.service.generate_token() return asab.web.rest.json_response(request, data) @@ -49,12 +49,11 @@ async def wrapper(*args, **kwargs): def noauth(handler): """ Exempt the decorated handler from authentication and authorization. - The `tenant`, `user_info` and `resources` arguments are not available in the handler. Examples: ```python - @asab.web.authz.noauth + @asab.web.auth.noauth async def get_public_info(self, request): data = await self.service.get_public_info() return asab.web.rest.json_response(request, data) @@ -62,7 +61,7 @@ async def get_public_info(self, request): """ argspec = inspect.getfullargspec(handler) args = set(argspec.kwonlyargs).union(argspec.args) - for arg in ("tenant", "user_info", "resources"): + for arg in ("tenant", "user_info", "resources", "authz"): if arg in args: raise Exception( "{}(): Handler with @noauth cannot have {!r} in its arguments.".format(handler.__qualname__, arg)) diff --git a/docs/reference/services/web/authorization_multitenancy.md b/docs/reference/services/web/authorization_multitenancy.md index 3baa2fc8..a80c07d8 100644 --- a/docs/reference/services/web/authorization_multitenancy.md +++ b/docs/reference/services/web/authorization_multitenancy.md @@ -15,6 +15,7 @@ It works best with TeskaLabs [Seacat Auth](https://github.com/TeskaLabs/seacat-a authorization server (See [its documentation](https://docs.teskalabs.com/seacat-auth/getting-started/quick-start) for setup instructions). + ## Getting started To get started, add `asab.web` module to your application and initialize `asab.web.auth.AuthService`: @@ -48,7 +49,7 @@ class MyApplication(asab.Application): You may also need to specify your authorization server's `public_keys_url` (also known as `jwks_uri` in [OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8414#section-2)). In case you don't have any authorization server at hand, - you can run the auth module in "mock mode". See the `configuration` section for details. + you can run the auth module in "mock mode". See the [Configuration section](#configuration) for details. Every handler in your web server now accepts only requests with a valid authentication. @@ -57,7 +58,7 @@ Unauthenticated requests are automatically answered with For every authenticated request, an `asab.web.auth.Authorization` object is created and stored in `asab.contextvars.Authz` for easy access. It contains authorization and authentication details, such as `CredentialsId`, `Username` or `Email`, and -access-checking methods `has_resource_access`, `require_resource_access` and more (see reference below). +access-checking methods `has_resource_access`, `require_superuser_access` and more ([see reference](#asab.web.auth.Authorization)). ```python import asab.contextvars @@ -86,6 +87,7 @@ async def order_breakfast(request): See [examples/web-auth.py](https://github.com/TeskaLabs/asab/blob/master/examples/web-auth.py) for a full demo ASAB application with auth module. + ## Configuration The `asab.web.auth` module is configured @@ -105,20 +107,13 @@ enabled=yes mock_user_info_path=/conf/mock-userinfo.json ``` + ## Multitenancy -`asab.web.auth.AuthService` supports multi-tenancy. Incoming request can specify tenant context using `X-Tenant` HTTP header. If this header is not empty, `AuthService` extracts the tenant ID and verifies if the request is authorized to access that tenant. -The request tenant can easily be accessed anywhere using `asab.contextvars.Tenant.get()`. - -### Strictly multitenant endpoints - -Strictly multitenant endpoints always operate within a tenant, hence they need the `tenant` parameter to be always provided. -Such endpoints must define the `tenant` parameter in their **URL path** and include `tenant` argument in the handler method. -Auth service extracts the tenant from the URL path, validates the tenant existence, -checks if the request is authorized for the tenant, and finally passes the tenant name to the handler method. +The request tenant can easily be accessed using `asab.contextvars.Tenant.get()`. ```python import asab.contextvars @@ -148,6 +143,7 @@ mock_user_info_path=${THIS_DIR}/mock_userinfo.json When dev mode is enabled, you don't have to provide `[public_keys_url]` since this option is ignored. + ## Reference ::: asab.web.auth.AuthService From 5267ffd4a886eda2b95aa1d912d014a556b7d5c8 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Fri, 11 Oct 2024 12:29:31 +0200 Subject: [PATCH 34/44] cleanup --- examples/web-authz-rbac.py | 58 -------------------- examples/web-authz-userinfo.conf | 5 -- examples/web-authz-userinfo.py | 91 -------------------------------- 3 files changed, 154 deletions(-) delete mode 100644 examples/web-authz-rbac.py delete mode 100644 examples/web-authz-userinfo.conf delete mode 100644 examples/web-authz-userinfo.py diff --git a/examples/web-authz-rbac.py b/examples/web-authz-rbac.py deleted file mode 100644 index edabed10..00000000 --- a/examples/web-authz-rbac.py +++ /dev/null @@ -1,58 +0,0 @@ -import logging - -import asab -import asab.web -import asab.web.authz -import asab.web.rest -import asab.web.tenant - -# - -L = logging.getLogger(__name__) - -# - - -class MyRBACSecuredApplication(asab.Application): - """ - MyRBACSecuredApplication serves endpoints, which are checked for tenant authorization rights using SeaCat Auth RBAC. - - Test by: - - 1.) Run SeaCat Auth at: http://localhost:8081 - 2.) Perform OAuth authentication to obtain access token - 3.) Run: curl -H "Authorization: " http://localhost:8089/cars - """ - - async def initialize(self): - # Loading the web service module - self.add_module(asab.web.Module) - - # Locate web service - websvc = self.get_service("asab.WebService") - - # Create a dedicated web container - container = asab.web.WebContainer(websvc, 'example:rbac', config={"listen": "0.0.0.0 8089"}) - - # Add authz service - # It is required by asab.web.authz.required decorator - authz_service = asab.web.authz.AuthzService(self) - container.WebApp.middlewares.append( - asab.web.authz.authz_middleware_factory(self, authz_service) - ) - - # Enable exception to JSON exception middleware - container.WebApp.middlewares.append(asab.web.rest.JsonExceptionMiddleware) - - # Add a route - container.WebApp.router.add_get('/cars', self.get_cars) - - @asab.web.authz.required("car:list") - async def get_cars(self, request): - cars = ["Skoda", "Volvo", "Kia"] - return asab.web.rest.json_response(request=request, data=cars) - - -if __name__ == '__main__': - app = MyRBACSecuredApplication() - app.run() diff --git a/examples/web-authz-userinfo.conf b/examples/web-authz-userinfo.conf deleted file mode 100644 index bee2fc0f..00000000 --- a/examples/web-authz-userinfo.conf +++ /dev/null @@ -1,5 +0,0 @@ -[web] -listen=0.0.0.0 8089 - -[authz] -public_keys_url=http://localhost:8081/openidconnect/public_keys diff --git a/examples/web-authz-userinfo.py b/examples/web-authz-userinfo.py deleted file mode 100644 index 12103fca..00000000 --- a/examples/web-authz-userinfo.py +++ /dev/null @@ -1,91 +0,0 @@ -import logging - -import asab -import asab.web -import asab.web.authz -import asab.web.rest -import asab.web.tenant - -# - -L = logging.getLogger(__name__) - -# - - -class MyApplication(asab.Application): - """ - MyApplication serves endpoints which use user info obtained from ID tokens issued by the authorization server. - - Test by: - 1) Run NGINX server with Seacat Auth (at localhost:8081) - 2) Run this example app with config file: - ```sh - python3 examples/web-authz-userinfo.py -c examples/web-authz-userinfo.conf - ``` - 1) In Seacat Admin UI, register a public web client for this app, with client_id="example-app" - 3) Set up a NGINX location with intropection for this example app: - ```nginx - location /example { - rewrite ^/example/(.*)? /$1 break; - proxy_pass http://localhost:8089; - - auth_request /_example_cookie_introspect; - error_page 401 /auth/api/openidconnect/authorize?response_type=code&scope=openid%20cookie%20profile&client_id=example-app&redirect_uri=$request_uri; - - auth_request_set $authorization $upstream_http_authorization; - proxy_set_header Authorization $authorization; - - auth_request_set $cookie $upstream_http_cookie; - proxy_set_header Cookie $cookie; - - auth_request_set $set_cookie $upstream_http_set_cookie; - add_header Set-Cookie $set_cookie; - } - - location = /_example_cookie_introspect { - internal; - proxy_method POST; - proxy_set_body "$http_authorization"; - proxy_pass http://auth_api/cookie/nginx?client_id=example-app; - proxy_ignore_headers Cache-Control Expires Set-Cookie; - } - ``` - 4) Access "https://YOUR_DOMAIN/example/user" in your browser - """ - - async def initialize(self): - # Loading the web service module - self.add_module(asab.web.Module) - - # Locate web service - websvc = self.get_service("asab.WebService") - - # Create a dedicated web container - container = asab.web.WebContainer(websvc, "web") - - # Add authz service - # It is required by asab.web.authz.required decorator - authz_service = asab.web.authz.AuthzService(self) - container.WebApp.middlewares.append( - asab.web.authz.authz_middleware_factory(self, authz_service) - ) - - # Enable exception to JSON exception middleware - container.WebApp.middlewares.append(asab.web.rest.JsonExceptionMiddleware) - - # Add a route - container.WebApp.router.add_get('/user', self.get_userinfo) - - @asab.web.authz.userinfo_handler - async def get_userinfo(self, request, *, userinfo): - message = "Hi {}, your email is {}".format( - userinfo.get("preferred_username"), - userinfo.get("email") - ) - return asab.web.rest.json_response(request=request, data={"message": message}) - - -if __name__ == '__main__': - app = MyApplication() - app.run() From b878a70a3ec4586094926bfd1c2fa292a803bc9e Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Fri, 11 Oct 2024 12:47:10 +0200 Subject: [PATCH 35/44] cleanup, naming --- asab/web/auth/service.py | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index f2f26c94..81279ebd 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -245,7 +245,7 @@ async def _delete_invalid_authorizations(self): del self.Authorizations[key] - def bearer_token_from_request(self, request): + def get_bearer_token_from_authorization_header(self, request): """ Validate the Authorizetion header and extract the Bearer token value """ @@ -369,7 +369,7 @@ async def wrapper(*args, **kwargs): if not self.is_enabled(): return await handler(*args, **kwargs) - bearer_token = self.bearer_token_from_request(request) + bearer_token = self.get_bearer_token_from_authorization_header(request) authz = await self.build_authorization(bearer_token) # Authorize tenant context @@ -552,25 +552,6 @@ def _get_id_token_claims_without_verification(bearer_token: str): return claims -def _get_bearer_token(request): - """ - Validate the Authorizetion header and extract the Bearer token value - """ - authorization_header = request.headers.get(aiohttp.hdrs.AUTHORIZATION) - if authorization_header is None: - L.warning("No Authorization header.") - raise aiohttp.web.HTTPUnauthorized() - try: - auth_type, token_value = authorization_header.split(" ", 1) - except ValueError: - L.warning("Cannot parse Authorization header.") - raise aiohttp.web.HTTPBadRequest() - if auth_type != "Bearer": - L.warning("Unsupported Authorization header type: {!r}".format(auth_type)) - raise aiohttp.web.HTTPUnauthorized() - return token_value - - def _pass_user_info(handler): """ Add user info to the handler arguments From 771959da077ea832f9dc63ac4d528fc63922603b Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Mon, 14 Oct 2024 12:11:24 +0200 Subject: [PATCH 36/44] separate tenant extraction for websocket --- asab/web/auth/service.py | 62 +++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 81279ebd..021e11c9 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -21,6 +21,7 @@ from ...library.service import LogObsolete from ...utils import string_to_boolean from ...contextvars import Tenant, Authz +from ..websocket import WebSocketFactory from .authorization import Authorization try: @@ -413,7 +414,7 @@ def _wrap_handler(self, route): route_info = route.get_info() tenant_in_path = "formatter" in route_info and "{tenant}" in route_info["formatter"] - # Extract the actual handler method for signature checks + # Extract the actual unwrapped handler method for signature inspection handler_method = route.handler while hasattr(handler_method, "__wrapped__"): # While loop unwraps handlers wrapped in multiple decorators. @@ -423,12 +424,14 @@ def _wrap_handler(self, route): if hasattr(handler_method, "__func__"): handler_method = handler_method.__func__ + is_websocket = isinstance(handler_method, WebSocketFactory) + if hasattr(handler_method, "NoAuth"): return argspec = inspect.getfullargspec(handler_method) args = set(argspec.kwonlyargs).union(argspec.args) - # Extract the whole handler for wrapping + # Extract the whole handler including its existing decorators and wrappers handler = route.handler # Apply the decorators IN REVERSE ORDER (the last applied wrapper affects the request first) @@ -465,7 +468,10 @@ def _wrap_handler(self, route): # 1) Set tenant context (no authorization yet) # TODO: This should be eventually done by TenantService - handler = _set_tenant_context_from_request_header(handler) + if is_websocket: + handler = _set_tenant_context_from_sec_websocket_protocol_header(handler) + else: + handler = _set_tenant_context_from_x_tenant_header(handler) route._handler = handler @@ -595,23 +601,47 @@ async def wrapper(*args, **kwargs): return wrapper -def _set_tenant_context_from_request_header(handler): +def _set_tenant_context_from_x_tenant_header(handler): """ - Extract tenant from request header (X-Tenant or Sec-Websocket-Protocol) and add it to context + Extract tenant from X-Tenant header and add it to context """ def get_tenant_from_header(request) -> str: - if request.headers.get("Upgrade") == "websocket": - # Get tenant from Sec-Websocket-Protocol header for websocket requests - protocols = request.headers.get("Sec-Websocket-Protocol", "") - for protocol in protocols.split(", "): - protocol = protocol.strip() - if protocol.startswith("tenant_"): - return protocol[7:] - else: - return None + # Get tenant from X-Tenant header for HTTP requests + return request.headers.get("X-Tenant") + + @functools.wraps(handler) + async def wrapper(*args, **kwargs): + request = args[-1] + tenant = get_tenant_from_header(request) + + if tenant is None: + response = await handler(*args, **kwargs) + else: + assert len(tenant) < 128 # Limit tenant name length to 128 characters to maintain sanity + tenant_ctx = Tenant.set(tenant) + try: + response = await handler(*args, **kwargs) + finally: + Tenant.reset(tenant_ctx) + + return response + + return wrapper + + +def _set_tenant_context_from_sec_websocket_protocol_header(handler): + """ + Extract tenant from Sec-Websocket-Protocol header and add it to context + """ + def get_tenant_from_header(request) -> str: + # Get tenant from Sec-Websocket-Protocol header for websocket requests + protocols = request.headers.get("Sec-Websocket-Protocol", "") + for protocol in protocols.split(", "): + protocol = protocol.strip() + if protocol.startswith("tenant_"): + return protocol[7:] else: - # Get tenant from X-Tenant header for HTTP requests - return request.headers.get("X-Tenant") + return None @functools.wraps(handler) async def wrapper(*args, **kwargs): From 365365b2b47312a4ba50fd22bc8a4e22b0462d2f Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Mon, 14 Oct 2024 12:32:14 +0200 Subject: [PATCH 37/44] fix import --- asab/web/auth/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 021e11c9..027c55a1 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -14,11 +14,11 @@ import aiohttp.web import aiohttp.client_exceptions +from ... import LogObsolete from ...abc.service import Service from ...config import Config from ...exceptions import NotAuthenticatedError, AccessDeniedError from ...api.discovery import NotDiscoveredError -from ...library.service import LogObsolete from ...utils import string_to_boolean from ...contextvars import Tenant, Authz from ..websocket import WebSocketFactory From 0c160bcc782dd0332d98d7401d061a6d99fc125b Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Tue, 22 Oct 2024 16:27:55 +0200 Subject: [PATCH 38/44] split auto-install method --- asab/web/auth/service.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 027c55a1..20f237eb 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -131,7 +131,7 @@ def __init__(self, app, service_name="asab.AuthService"): self.App.PubSub.subscribe("Application.housekeeping!", self._delete_invalid_authorizations) # Try to auto-install authorization middleware - self.install() + self._try_auto_install() def _prepare_mock_user_info(self): @@ -164,20 +164,26 @@ def is_enabled(self) -> bool: return self.Mode in {AuthMode.ENABLED, AuthMode.MOCK} - def install(self, web_container=None): + def _try_auto_install(self): """ - Apply authorization to all web handlers in a web container, according to their arguments and path parameters. + If there is exactly one web container, install authorization middleware on it. + """ + web_service = self.App.get_service("asab.WebService") + if web_service is None: + return + if len(web_service.Containers) != 1: + return + web_container = web_service.WebContainer + self.install(web_container) + + + def install(self, web_container): + """ + Apply authorization to all web handlers in a web container. :param web_container: Web container to be protected by authorization. :type web_container: asab.web.WebContainer """ - if web_container is None: - # Locate web container if there is only one - web_service = self.App.get_service("asab.WebService") - if len(web_service.Containers) != 1: - return - web_container = web_service.WebContainer - # Check that the middleware has not been installed yet for middleware in web_container.WebApp.on_startup: if middleware == self._wrap_handlers: From d183de82873bad50b9b521b0a8b95952ad6a0e7a Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Tue, 22 Oct 2024 16:55:30 +0200 Subject: [PATCH 39/44] warn at duplicate installation --- asab/web/auth/service.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 20f237eb..576e494d 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -187,6 +187,8 @@ def install(self, web_container): # Check that the middleware has not been installed yet for middleware in web_container.WebApp.on_startup: if middleware == self._wrap_handlers: + L.warning("WebContainer has authorization middleware installed already.", struct_data={ + "web_container": web_container.Config.get("listen")}) return web_container.WebApp.on_startup.append(self._wrap_handlers) From 0ebf25b45a55765d14c577e1b142847a8c53cf1d Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Tue, 22 Oct 2024 17:15:30 +0200 Subject: [PATCH 40/44] warn when no container has authorization installed after initialization --- asab/web/auth/service.py | 55 ++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 576e494d..f1f59d03 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -134,6 +134,10 @@ def __init__(self, app, service_name="asab.AuthService"): self._try_auto_install() + async def initialize(self, app): + self._check_if_installed() + + def _prepare_mock_user_info(self): # Load custom user info mock_user_info_path = Config.get("auth", "mock_user_info_path") @@ -164,19 +168,6 @@ def is_enabled(self) -> bool: return self.Mode in {AuthMode.ENABLED, AuthMode.MOCK} - def _try_auto_install(self): - """ - If there is exactly one web container, install authorization middleware on it. - """ - web_service = self.App.get_service("asab.WebService") - if web_service is None: - return - if len(web_service.Containers) != 1: - return - web_container = web_service.WebContainer - self.install(web_container) - - def install(self, web_container): """ Apply authorization to all web handlers in a web container. @@ -509,6 +500,44 @@ async def _get_userinfo_from_id_token(self, bearer_token): raise NotAuthenticatedError() + def _check_if_installed(self): + """ + Check if there is at least one web container with authorization installed + """ + web_service = self.App.get_service("asab.WebService") + if web_service is None or len(web_service.Containers) == 0: + L.warning("Authorization is not installed: There are no web containers.") + return + + for web_container in web_service.Containers: + for middleware in web_container.WebApp.on_startup: + if middleware == self._wrap_handlers: + # Container has authorization installed + break + else: + continue + + # Container has authorization installed + break + + else: + L.warning("Authorization is not installed on any web container.") + return + + + def _try_auto_install(self): + """ + If there is exactly one web container, install authorization middleware on it. + """ + web_service = self.App.get_service("asab.WebService") + if web_service is None: + return + if len(web_service.Containers) != 1: + return + web_container = web_service.WebContainer + self.install(web_container) + + def _get_id_token_claims(bearer_token: str, auth_server_public_key): """ Parse and validate JWT ID token and extract the claims (user info) From 83dbf01c0cd8b0e795c4254c7fe01a747a9a497d Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Wed, 23 Oct 2024 10:54:11 +0200 Subject: [PATCH 41/44] more descriptive logging on authservice init --- asab/web/auth/service.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index f1f59d03..d06c34cf 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -175,11 +175,19 @@ def install(self, web_container): :param web_container: Web container to be protected by authorization. :type web_container: asab.web.WebContainer """ + web_service = self.App.get_service("asab.WebService") + # Check that the middleware has not been installed yet for middleware in web_container.WebApp.on_startup: if middleware == self._wrap_handlers: - L.warning("WebContainer has authorization middleware installed already.", struct_data={ - "web_container": web_container.Config.get("listen")}) + if len(web_service.Containers) == 1: + L.warning( + "WebContainer has authorization middleware installed already. " + "You don't need to call `AuthService.install()` in applications with a single WebContainer; " + "it is called automatically at init time." + ) + else: + L.warning("WebContainer has authorization middleware installed already.") return web_container.WebApp.on_startup.append(self._wrap_handlers) @@ -509,7 +517,7 @@ def _check_if_installed(self): L.warning("Authorization is not installed: There are no web containers.") return - for web_container in web_service.Containers: + for web_container in web_service.Containers.values(): for middleware in web_container.WebApp.on_startup: if middleware == self._wrap_handlers: # Container has authorization installed @@ -535,7 +543,9 @@ def _try_auto_install(self): if len(web_service.Containers) != 1: return web_container = web_service.WebContainer + self.install(web_container) + L.info("WebContainer authorization installed automatically.") def _get_id_token_claims(bearer_token: str, auth_server_public_key): From 791fb80e73772db56092b88a3dcede065595f28d Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Wed, 23 Oct 2024 15:05:11 +0200 Subject: [PATCH 42/44] more descriptive logging on authservice init --- asab/web/auth/service.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index d06c34cf..cdfb7b6e 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -529,7 +529,11 @@ def _check_if_installed(self): break else: - L.warning("Authorization is not installed on any web container.") + L.warning( + "Authorization is not installed in any web container. " + "In applications with more than one WebContainer there is no automatic installation; " + "you have to call `AuthService.install(web_container)` explicitly." + ) return From 1c278a7d13eab254763cf9e895011245ddc0ca6d Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Wed, 23 Oct 2024 15:05:24 +0200 Subject: [PATCH 43/44] remove obsolete installation --- examples/web-auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/web-auth.py b/examples/web-auth.py index 95e07585..bdc3f9fc 100644 --- a/examples/web-auth.py +++ b/examples/web-auth.py @@ -52,7 +52,6 @@ def __init__(self): # Initialize authorization self.AuthService = asab.web.auth.AuthService(self) - self.AuthService.install(self.WebContainer) # Add routes self.WebContainer.WebApp.router.add_get("/", self.info) From abeb5b3ce1721b9ebc69a3570e85ca0186114337 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Wed, 23 Oct 2024 15:33:03 +0200 Subject: [PATCH 44/44] make UserInfo attr protected --- asab/web/auth/authorization.py | 41 +++++++++++++++++++++++----------- asab/web/auth/service.py | 2 +- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/asab/web/auth/authorization.py b/asab/web/auth/authorization.py index f6bf9d92..35a1d6cb 100644 --- a/asab/web/auth/authorization.py +++ b/asab/web/auth/authorization.py @@ -21,17 +21,19 @@ def __init__(self, auth_service, userinfo: dict): self.AuthService = auth_service if not self.AuthService.is_enabled(): raise ValueError("Cannot create Authorization when AuthService is disabled.") - self.UserInfo = userinfo or {} - self.CredentialsId = self.UserInfo.get("sub") - self.Username = self.UserInfo.get("preferred_username") or self.UserInfo.get("username") - self.Email = self.UserInfo.get("email") - self.Phone = self.UserInfo.get("phone") + # Userinfo should not be accessed directly + self._UserInfo = userinfo or {} - self.Issuer = self.UserInfo.get("iss") # Who issued the authorization - self.AuthorizedParty = self.UserInfo.get("azp") # What party (application) is authorized - self.IssuedAt = datetime.datetime.fromtimestamp(int(self.UserInfo["iat"]), datetime.timezone.utc) - self.Expiration = datetime.datetime.fromtimestamp(int(self.UserInfo["exp"]), datetime.timezone.utc) + self.CredentialsId = self._UserInfo.get("sub") + self.Username = self._UserInfo.get("preferred_username") or self._UserInfo.get("username") + self.Email = self._UserInfo.get("email") + self.Phone = self._UserInfo.get("phone") + + self.Issuer = self._UserInfo.get("iss") # Who issued the authorization + self.AuthorizedParty = self._UserInfo.get("azp") # What party (application) is authorized + self.IssuedAt = datetime.datetime.fromtimestamp(int(self._UserInfo["iat"]), datetime.timezone.utc) + self.Expiration = datetime.datetime.fromtimestamp(int(self._UserInfo["exp"]), datetime.timezone.utc) def __repr__(self): @@ -58,7 +60,7 @@ def has_superuser_access(self) -> bool: :return: Is the agent a superuser? """ self.require_valid() - return is_superuser(self.UserInfo) + return is_superuser(self._UserInfo) def has_resource_access(self, *resources: typing.Iterable[str]) -> bool: @@ -69,7 +71,7 @@ def has_resource_access(self, *resources: typing.Iterable[str]) -> bool: :return: Is resource access authorized? """ self.require_valid() - return has_resource_access(self.UserInfo, resources, tenant=Tenant.get(None)) + return has_resource_access(self._UserInfo, resources, tenant=Tenant.get(None)) def has_tenant_access(self) -> bool: @@ -85,7 +87,7 @@ def has_tenant_access(self) -> bool: except LookupError as e: raise ValueError("No tenant in context.") from e - return has_tenant_access(self.UserInfo, tenant) + return has_tenant_access(self._UserInfo, tenant) def require_valid(self): @@ -140,7 +142,20 @@ def authorized_resources(self) -> typing.Optional[typing.Set[str]]: :return: Set of authorized resources. """ self.require_valid() - return get_authorized_resources(self.UserInfo, Tenant.get(None)) + return get_authorized_resources(self._UserInfo, Tenant.get(None)) + + + def user_info(self) -> typing.Dict[str, typing.Any]: + """ + Return OpenID Connect UserInfo. + + NOTE: If possible, use Authz attributes (CredentialsId, Username etc.) instead of inspecting the user info + dictionary directly. + + :return: User info + """ + self.require_valid() + return self._UserInfo def is_superuser(userinfo: typing.Mapping) -> bool: diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index cdfb7b6e..a6f16deb 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -616,7 +616,7 @@ def _pass_user_info(handler): @functools.wraps(handler) async def wrapper(*args, **kwargs): authz = Authz.get(None) - return await handler(*args, user_info=authz.UserInfo if authz is not None else None, **kwargs) + return await handler(*args, user_info=authz.user_info() if authz is not None else None, **kwargs) return wrapper