From 202746e85efdd486ae552276cd946378be66ec32 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 3 Aug 2023 16:40:49 +0200 Subject: [PATCH 1/6] Add config switch to disable authn/z --- asab/web/auth/service.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 30abc1673..9d4e8b104 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -78,6 +78,9 @@ # Whether the app is tenant-aware "multitenancy": "yes", + # Whether the authentication and authorization are enabled + "enabled": "yes", + # In DEV MODE # - no authorization server is needed, # - all incoming requests are "authorized" with custom mocked user info data loaded from a JSON file, @@ -96,6 +99,7 @@ class AuthService(asab.Service): def __init__(self, app, service_name="asab.AuthzService"): super().__init__(app, service_name) self.MultitenancyEnabled = asab.Config.getboolean("auth", "multitenancy") + self.AuthEnabled = asab.Config.getboolean("auth", "enabled") self.PublicKeysUrl = asab.Config.get("auth", "public_keys_url") self.DevModeEnabled = asab.Config.getboolean("auth", "dev_mode") @@ -196,6 +200,8 @@ def has_superuser_access(self, authorized_resources: typing.Iterable) -> bool: """ Check if the superuser resource is present in the authorized resource list. """ + if not self.AuthEnabled: + return True return SUPERUSER_RESOURCE in authorized_resources @@ -203,6 +209,8 @@ def has_resource_access(self, authorized_resources: typing.Iterable, required_re """ Check if the requested resources or the superuser resource are present in the authorized resource list. """ + if not self.AuthEnabled: + return True if self.has_superuser_access(authorized_resources): return True for resource in required_resources: @@ -284,10 +292,15 @@ async def wrapper(*args, **kwargs): assert user_info is not None # Add userinfo, tenants and global resources to the request - request._UserInfo = user_info - resource_dict = request._UserInfo["resources"] - request._Resources = frozenset(resource_dict.get("*", [])) - request._Tenants = frozenset(t for t in resource_dict.keys() if t != "*") + if self.AuthEnabled: + request._UserInfo = user_info + resource_dict = request._UserInfo["resources"] + request._Resources = frozenset(resource_dict.get("*", [])) + request._Tenants = frozenset(t for t in resource_dict.keys() if t != "*") + else: + request._UserInfo = None + request._Resources = frozenset() + request._Tenants = frozenset() # Add access control methods to the request def has_resource_access(*required_resources: list) -> bool: @@ -386,7 +399,8 @@ def _add_tenant_from_path(self, handler): async def wrapper(*args, **kwargs): request = args[-1] tenant = request.match_info["tenant"] - self._authorize_tenant_request(request, tenant) + if self.AuthEnabled: + self._authorize_tenant_request(request, tenant) return await handler(*args, tenant=tenant, **kwargs) return wrapper @@ -407,7 +421,8 @@ async def wrapper(*args, **kwargs): tenant = None else: tenant = request.query["tenant"] - self._authorize_tenant_request(request, tenant) + if self.AuthEnabled: + self._authorize_tenant_request(request, tenant) return await handler(*args, tenant=tenant, **kwargs) return wrapper From 3d9ec84bea1ffa687353e7691761fa75b9c5ff98 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 3 Aug 2023 17:15:29 +0200 Subject: [PATCH 2/6] Debugging auth switch --- asab/web/auth/service.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 9d4e8b104..253a9d169 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -102,6 +102,12 @@ def __init__(self, app, service_name="asab.AuthzService"): self.AuthEnabled = asab.Config.getboolean("auth", "enabled") self.PublicKeysUrl = asab.Config.get("auth", "public_keys_url") + if jwcrypto is None: + 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.DevModeEnabled = asab.Config.getboolean("auth", "dev_mode") if self.DevModeEnabled: # Load custom user info @@ -125,17 +131,11 @@ def __init__(self, app, service_name="asab.AuthzService"): "remove tenants and resources, change username etc.), provide your own user info in {!r}.".format( list(t for t in self.DevUserInfo.get("resources", {}).keys() if t != "*"), dev_user_info_path)) - else: - if len(self.PublicKeysUrl) == 0: + elif self.AuthEnabled and len(self.PublicKeysUrl) == 0: self.PublicKeysUrl = self._PUBLIC_KEYS_URL_DEFAULT L.warning( "No 'public_keys_url' provided in [auth] config section. " "Defaulting to {!r}.".format(self._PUBLIC_KEYS_URL_DEFAULT)) - if jwcrypto is None: - 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.AuthServerPublicKey = None # TODO: Support multiple public keys # Limit the frequency of auth server requests to save network traffic @@ -144,7 +144,7 @@ def __init__(self, app, service_name="asab.AuthzService"): async def initialize(self, app): - if self.DevModeEnabled is False: + if self.AuthEnabled and not self.DevModeEnabled: await self._fetch_public_keys_if_needed() @@ -163,7 +163,9 @@ def is_ready(self): """ Check if the service is ready to authorize requests. """ - if self.DevModeEnabled is True: + if not self.AuthEnabled: + return True + if self.DevModeEnabled: return True if self.AuthServerPublicKey is None: return False @@ -282,25 +284,26 @@ def _authenticate_request(self, handler): @functools.wraps(handler) async def wrapper(*args, **kwargs): request = args[-1] - if self.DevModeEnabled: + if not self.AuthEnabled: + user_info = None + elif self.DevModeEnabled: user_info = self.DevUserInfo 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) - assert user_info is not None - # Add userinfo, tenants and global resources to the request if self.AuthEnabled: + assert user_info is not None request._UserInfo = user_info resource_dict = request._UserInfo["resources"] request._Resources = frozenset(resource_dict.get("*", [])) request._Tenants = frozenset(t for t in resource_dict.keys() if t != "*") else: request._UserInfo = None - request._Resources = frozenset() - request._Tenants = frozenset() + request._Resources = None + request._Tenants = None # Add access control methods to the request def has_resource_access(*required_resources: list) -> bool: From 8feed6f879cb8d600efecab49eebfbe1ca641405 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 3 Aug 2023 17:19:37 +0200 Subject: [PATCH 3/6] Update example with auth switch --- examples/web-auth.py | 63 ++++++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/examples/web-auth.py b/examples/web-auth.py index 5aa8b0583..f3556aece 100644 --- a/examples/web-auth.py +++ b/examples/web-auth.py @@ -4,11 +4,15 @@ import typing # Set up a web container listening at port 8080 -asab.Config["web"] = {"listen": "0.0.0.0 8089"} +asab.Config["web"] = {"listen": "0.0.0.0 8088"} + +# Disables or enables all authentication and authorization. +# When disabled, the `resources` and `userinfo` handler arguments are set to `None`. +asab.Config["auth"]["enabled"] = "yes" # Changes the behavior of endpoints with configurable tenant parameter. -# With multitenancy enabled, the tenant paramerter in query is required. -# With multitenancy disabled, the tenant paramerter in query is ignored. +# With multitenancy enabled, the `tenant` paramerter in query is required. +# With multitenancy disabled, the `tenant` paramerter in query is ignored. asab.Config["auth"]["multitenancy"] = "yes" # Activating the dev mode disables communication with the authorization server. @@ -73,7 +77,7 @@ async def no_auth(self, request): return asab.web.rest.json_response(request, data) - async def auth(self, request, *, user_info: dict, resources: frozenset): + async def auth(self, request, *, user_info: typing.Optional[dict], resources: typing.Optional[frozenset]): """ TENANT-AGNOSTIC - returns 401 if authentication not successful @@ -84,14 +88,14 @@ async def auth(self, request, *, user_info: dict, resources: frozenset): """ data = { "tenant": "NOT AVAILABLE", - "resources": list(resources), + "resources": list(resources) if resources else None, "user_info": user_info, } return asab.web.rest.json_response(request, data) @asab.web.auth.require("something:access", "something:edit") - async def auth_resource(self, request, *, user_info: dict, resources: frozenset): + async def auth_resource(self, request, *, user_info: typing.Optional[dict], resources: typing.Optional[frozenset]): """ TENANT-AGNOSTIC + RESOURCE CHECK - returns 401 if authentication not successful @@ -104,7 +108,7 @@ async def auth_resource(self, request, *, user_info: dict, resources: frozenset) """ data = { "tenant": "NOT AVAILABLE", - "resources": list(resources), + "resources": list(resources) if resources else None, "user_info": user_info, } return asab.web.rest.json_response(request, data) @@ -114,20 +118,30 @@ async def auth_resource(self, request, *, user_info: dict, resources: frozenset) "type": "object" }) @asab.web.auth.require("something:access", "something:edit") - async def auth_resource_put(self, request, *, user_info: dict, resources: frozenset, json_data: dict): + async def auth_resource_put( + self, request, *, + user_info: typing.Optional[dict], + resources: typing.Optional[frozenset], + json_data: dict + ): """ Decorator asab.web.auth.require can be used together with other decorators. """ data = { "tenant": "NOT AVAILABLE", - "resources": list(resources), + "resources": list(resources) if resources else None, "user_info": user_info, "json_data": json_data, } return asab.web.rest.json_response(request, data) - async def tenant_in_path(self, request, *, tenant: str, user_info: dict, resources: frozenset): + async def tenant_in_path( + self, request, *, + tenant: typing.Optional[str], + user_info: typing.Optional[dict], + resources: typing.Optional[frozenset] + ): """ TENANT-AWARE - returns 401 if authentication not successful @@ -140,13 +154,18 @@ async def tenant_in_path(self, request, *, tenant: str, user_info: dict, resourc """ data = { "tenant": tenant, - "resources": list(resources), + "resources": list(resources) if resources else None, "user_info": user_info, } return asab.web.rest.json_response(request, data) - async def tenant_in_query(self, request, *, tenant: typing.Union[str|None], user_info: dict, resources: frozenset): + async def tenant_in_query( + self, request, *, + tenant: typing.Optional[str], + user_info: typing.Optional[dict], + resources: typing.Optional[frozenset] + ): """ CONFIGURABLY TENANT-AWARE - returns 401 if authentication not successful @@ -165,14 +184,19 @@ async def tenant_in_query(self, request, *, tenant: typing.Union[str|None], user """ data = { "tenant": tenant, - "resources": list(resources), + "resources": list(resources) if resources else None, "user_info": user_info, } return asab.web.rest.json_response(request, data) @asab.web.auth.require("something:access", "something:edit") - async def tenant_in_path_resources(self, request, *, tenant: typing.Union[str|None], user_info: dict, resources: frozenset): + async def tenant_in_path_resources( + self, request, *, + tenant: typing.Optional[str], + user_info: typing.Optional[dict], + resources: typing.Optional[frozenset] + ): """ TENANT-AWARE + RESOURCE CHECK - returns 401 if authentication not successful @@ -187,14 +211,19 @@ async def tenant_in_path_resources(self, request, *, tenant: typing.Union[str|No """ data = { "tenant": tenant, - "resources": list(resources), + "resources": list(resources) if resources else None, "user_info": user_info, } return asab.web.rest.json_response(request, data) @asab.web.auth.require("something:access", "something:edit") - async def tenant_in_query_resources(self, request, *, tenant: typing.Union[str|None], user_info: dict, resources: frozenset): + async def tenant_in_query_resources( + self, request, *, + tenant: typing.Optional[str], + user_info: typing.Optional[dict], + resources: typing.Optional[frozenset] + ): """ CONFIGURABLY TENANT-AWARE + RESOURCE CHECK - returns 401 if authentication not successful @@ -215,7 +244,7 @@ async def tenant_in_query_resources(self, request, *, tenant: typing.Union[str|N """ data = { "tenant": tenant, - "resources": list(resources), + "resources": list(resources) if resources else None, "user_info": user_info, } return asab.web.rest.json_response(request, data) From b2537d6ad292db5e114c06a41447bc1e9e20a4e6 Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 3 Aug 2023 17:34:41 +0200 Subject: [PATCH 4/6] Debug auth config and init --- asab/web/auth/service.py | 62 +++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/asab/web/auth/service.py b/asab/web/auth/service.py index 253a9d169..2c0c0426d 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -100,48 +100,50 @@ def __init__(self, app, service_name="asab.AuthzService"): super().__init__(app, service_name) self.MultitenancyEnabled = asab.Config.getboolean("auth", "multitenancy") self.AuthEnabled = asab.Config.getboolean("auth", "enabled") + self.DevModeEnabled = asab.Config.getboolean("auth", "dev_mode") self.PublicKeysUrl = asab.Config.get("auth", "public_keys_url") - if jwcrypto is None: + if not self.AuthEnabled: + pass + elif self.DevModeEnabled: + self.DevUserInfo = self._prepare_dev_user_info() + elif jwcrypto is None: raise ModuleNotFoundError( - "You are trying to use asab.web.authz without 'jwcrypto' installed. " + "You are trying to use asab.web.auth module without 'jwcrypto' installed. " "Please run 'pip install jwcrypto' " "or install asab with 'authz' optional dependency.") - - self.DevModeEnabled = asab.Config.getboolean("auth", "dev_mode") - if self.DevModeEnabled: - # Load custom user info - dev_user_info_path = asab.Config.get("auth", "dev_user_info_path") - if os.path.isfile(dev_user_info_path): - with open(dev_user_info_path, "rb") as fp: - self.DevUserInfo = json.load(fp) - else: - self.DevUserInfo = DEV_USERINFO_DEFAULT - - # Validate user info - resources = self.DevUserInfo.get("resources", {}) - if not isinstance(resources, dict) or not all( - map(lambda kv: isinstance(kv[0], str) and isinstance(kv[1], list), resources.items()) - ): - raise ValueError("User info 'resources' must be an object with string keys and array values.") - + elif len(self.PublicKeysUrl) == 0: + self.PublicKeysUrl = self._PUBLIC_KEYS_URL_DEFAULT L.warning( - "AuthService is running in DEV MODE. All web requests will be authorized with mock user info, which " - "currently grants access to the following tenants: {}. To customize dev mode authorization (add or " - "remove tenants and resources, change username etc.), provide your own user info in {!r}.".format( - list(t for t in self.DevUserInfo.get("resources", {}).keys() if t != "*"), - dev_user_info_path)) - elif self.AuthEnabled and len(self.PublicKeysUrl) == 0: - self.PublicKeysUrl = self._PUBLIC_KEYS_URL_DEFAULT - L.warning( - "No 'public_keys_url' provided in [auth] config section. " - "Defaulting to {!r}.".format(self._PUBLIC_KEYS_URL_DEFAULT)) + "No 'public_keys_url' provided in [auth] config section. " + "Defaulting to {!r}.".format(self._PUBLIC_KEYS_URL_DEFAULT)) self.AuthServerPublicKey = None # TODO: Support multiple public keys # Limit the frequency of auth server requests to save network traffic self.AuthServerCheckCooldown = datetime.timedelta(minutes=5) self.AuthServerLastSuccessfulCheck = None + def _prepare_dev_user_info(self): + # Load custom user info + dev_user_info_path = asab.Config.get("auth", "dev_user_info_path") + if os.path.isfile(dev_user_info_path): + with open(dev_user_info_path, "rb") as fp: + user_info = json.load(fp) + else: + user_info = DEV_USERINFO_DEFAULT + # Validate user info + resources = self.DevUserInfo.get("resources", {}) + if not isinstance(resources, dict) or not all( + map(lambda kv: isinstance(kv[0], str) and isinstance(kv[1], list), resources.items()) + ): + raise ValueError("User info 'resources' must be an object with string keys and array values.") + L.warning( + "AuthService is running in DEV MODE. All web requests will be authorized with mock user info, which " + "currently grants access to the following tenants: {}. To customize dev mode authorization (add or " + "remove tenants and resources, change username etc.), provide your own user info in {!r}.".format( + list(t for t in self.DevUserInfo.get("resources", {}).keys() if t != "*"), + dev_user_info_path)) + return user_info async def initialize(self, app): if self.AuthEnabled and not self.DevModeEnabled: From 96b5ee0afc2eae3cf261de79faedab7ba89a6a7f Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 3 Aug 2023 17:42:44 +0200 Subject: [PATCH 5/6] flake8 --- 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 2c0c0426d..c68b30715 100644 --- a/asab/web/auth/service.py +++ b/asab/web/auth/service.py @@ -134,7 +134,7 @@ def _prepare_dev_user_info(self): # Validate user info resources = self.DevUserInfo.get("resources", {}) if not isinstance(resources, dict) or not all( - map(lambda kv: isinstance(kv[0], str) and isinstance(kv[1], list), resources.items()) + map(lambda kv: isinstance(kv[0], str) and isinstance(kv[1], list), resources.items()) ): raise ValueError("User info 'resources' must be an object with string keys and array values.") L.warning( From 39700d90c4ea6f00555c791f10c12cefdfaeaa4f Mon Sep 17 00:00:00 2001 From: "robin.hruska@teskalabs.com" Date: Thu, 10 Aug 2023 10:45:22 +0200 Subject: [PATCH 6/6] fix example --- examples/web-auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/web-auth.py b/examples/web-auth.py index f3556aece..00eacd323 100644 --- a/examples/web-auth.py +++ b/examples/web-auth.py @@ -4,7 +4,7 @@ import typing # Set up a web container listening at port 8080 -asab.Config["web"] = {"listen": "0.0.0.0 8088"} +asab.Config["web"] = {"listen": "0.0.0.0 8080"} # Disables or enables all authentication and authorization. # When disabled, the `resources` and `userinfo` handler arguments are set to `None`.