diff --git a/CHANGELOG.md b/CHANGELOG.md index 44fcddb4..93a91a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,10 @@ - User impersonation (#188, PLUM Sprint 230509) - Configurable client session expiration (#204, PLUM Sprint 230509) - Additional TLS options in LDAP provider (#208, PLUM Sprint 230519) +- Impersonation REST API (#210, PLUM Sprint 230602) ### Refactoring +- Allow impersonating superuser credentials, but exclude the superuser resource (#210, PLUM Sprint 230602) - Session performance improvement (#211) --- diff --git a/seacatauth/authn/handler.py b/seacatauth/authn/handler.py index d2932453..858a114c 100644 --- a/seacatauth/authn/handler.py +++ b/seacatauth/authn/handler.py @@ -10,6 +10,7 @@ import urllib.parse import jwcrypto.jwk +import seacatauth.exceptions from ..audit import AuditCode from ..cookie import set_cookie, delete_cookie from ..decorators import access_control @@ -32,29 +33,31 @@ class AuthenticationHandler(object): def __init__(self, app, authn_svc): self.App = app self.AuthenticationService = authn_svc - self.CredentialsService = app.get_service('seacatauth.CredentialsService') - self.SessionService = app.get_service('seacatauth.SessionService') - self.CookieService = app.get_service('seacatauth.CookieService') - self.AuditService = app.get_service('seacatauth.AuditService') + self.CredentialsService = app.get_service("seacatauth.CredentialsService") + self.SessionService = app.get_service("seacatauth.SessionService") + self.CookieService = app.get_service("seacatauth.CookieService") + self.AuditService = app.get_service("seacatauth.AuditService") self.BatmanService = app.BatmanService - self.CommunicationService = app.get_service('seacatauth.CommunicationService') + self.CommunicationService = app.get_service("seacatauth.CommunicationService") web_app = app.WebContainer.WebApp - web_app.router.add_put(r'/public/login.prologue', self.login_prologue) - web_app.router.add_put(r'/public/login/{lsid}', self.login) - web_app.router.add_put(r'/public/login/{lsid}/smslogin', self.smslogin) - web_app.router.add_put(r'/public/login/{lsid}/webauthn', self.webauthn_login) - web_app.router.add_put(r'/public/logout', self.logout) - web_app.router.add_post("/impersonate", self.impersonate) + web_app.router.add_put(r"/public/login.prologue", self.login_prologue) + web_app.router.add_put(r"/public/login/{lsid}", self.login) + web_app.router.add_put(r"/public/login/{lsid}/smslogin", self.smslogin) + web_app.router.add_put(r"/public/login/{lsid}/webauthn", self.webauthn_login) + web_app.router.add_put(r"/public/logout", self.logout) + web_app.router.add_put("/impersonate", self.impersonate) + web_app.router.add_post("/impersonate", self.impersonate_and_redirect) # Public endpoints web_app_public = app.PublicWebContainer.WebApp - web_app_public.router.add_put(r'/public/login.prologue', self.login_prologue) - web_app_public.router.add_put(r'/public/login/{lsid}', self.login) - web_app_public.router.add_put(r'/public/login/{lsid}/smslogin', self.smslogin) - web_app_public.router.add_put(r'/public/login/{lsid}/webauthn', self.webauthn_login) - web_app_public.router.add_put(r'/public/logout', self.logout) - web_app_public.router.add_post("/impersonate", self.impersonate) + web_app_public.router.add_put(r"/public/login.prologue", self.login_prologue) + web_app_public.router.add_put(r"/public/login/{lsid}", self.login) + web_app_public.router.add_put(r"/public/login/{lsid}/smslogin", self.smslogin) + web_app_public.router.add_put(r"/public/login/{lsid}/webauthn", self.webauthn_login) + web_app_public.router.add_put(r"/public/logout", self.logout) + web_app_public.router.add_put("/impersonate", self.impersonate) + web_app_public.router.add_post("/impersonate", self.impersonate_and_redirect) @asab.web.rest.json_schema_handler({ "type": "object", @@ -297,15 +300,20 @@ async def logout(self, request): delete_cookie(self.App, response) - # If the current session is impersonated, try to restore the original session + # If the current session is impersonated and the original session has a + # root cookie, try to restore the original session if session.Authentication.ImpersonatorSessionId is not None: try: impersonator_session = await self.SessionService.get(session.Authentication.ImpersonatorSessionId) - print(impersonator_session) - set_cookie(self.App, response, impersonator_session) except KeyError: L.log(asab.LOG_NOTICE, "Impersonator session not found.", struct_data={ "sid": session.Authentication.ImpersonatorSessionId}) + else: + if impersonator_session.Cookie is None: + # Case when the impersonation was started by an M2M session, which has no cookie + pass + else: + set_cookie(self.App, response, impersonator_session) if self.BatmanService is not None: response.del_cookie(self.BatmanService.CookieName) @@ -385,13 +393,54 @@ async def _get_client_login_key(self, client_id): login_key = None return login_key + @asab.web.rest.json_schema_handler({ + "type": "object", + "required": ["credentials_id"], + "properties": { + "credentials_id": { + "type": "string", + "description": "Credentials ID of the impersonation target."}, + "expiration": { + "oneOf": [{"type": "string"}, {"type": "number"}], + "description": + "Expiration of the impersonated session. The value can be either the number of seconds " + "or a time-unit string such as '4 h' or '3 d'."}}, + "example": { + "credentials_id": "mongodb:default:abc123def456", + "expiration": "5m"} + }) + @access_control("authz:impersonate") + async def impersonate(self, request, *, json_data): + """ + Open a root session impersonated as a different user. + Response contains a Set-Cookie header with the new root session cookie. + + Requires `authz:impersonate`. + """ + from_info = [request.remote] + ff = request.headers.get("X-Forwarded-For") + if ff is not None: + from_info.extend(ff.split(", ")) + + target_cid = json_data["credentials_id"] + if request.Session.Session.ParentSessionId is None: + impersonator_root_session = request.Session + else: + impersonator_root_session = await self.SessionService.get(request.Session.Session.ParentSessionId) + + session = await self._impersonate(impersonator_root_session, from_info, target_cid) + response = asab.web.rest.json_response(request, {"result": "OK"}) + set_cookie(self.App, response, session, cookie_domain=self.CookieService.RootCookieDomain) + return response + @access_control("authz:impersonate") - async def impersonate(self, request, *, credentials_id): + async def impersonate_and_redirect(self, request): """ - Open a root session as a different user. Responds contains a Set-Cookie header with the new root session cookie. - This effectively overwrites user's current root cookie. Reference to current root session is kept in the - impersonated session. On logout, the original root cookie is set again. + Open a root session impersonated as a different user. Response contains a Set-Cookie header with the new + root session cookie and redirection to the authorize endpoint. This effectively overwrites user's current + root cookie. Reference to current root session is kept in the impersonated session. + On logout, the original root cookie is set again. Requires `authz:impersonate`. --- @@ -427,7 +476,6 @@ async def impersonate(self, request, *, credentials_id): oidc_service = self.App.get_service("seacatauth.OpenIdConnectService") client_service = self.App.get_service("seacatauth.ClientService") - # TODO: Restrict impersonation based on agent X target resource intersection from_info = [request.remote] ff = request.headers.get("X-Forwarded-For") if ff is not None: @@ -435,22 +483,61 @@ async def impersonate(self, request, *, credentials_id): request_data = await request.post() target_cid = request_data["credentials_id"] - if request.Session.Session.Type == "root": impersonator_root_session = request.Session else: impersonator_root_session = await self.SessionService.get(request.Session.Session.ParentSessionId) + session = await self._impersonate(impersonator_root_session, from_info, target_cid) + + client_dict = await client_service.get(request_data["client_id"]) + query = { + k: v for k, v in request_data.items() + if k in frozenset([ + "redirect_uri", "response_type", "scope", "prompt", "code_challenge", "code_challenge_method"]) + } + authorize_uri = oidc_service.build_authorize_uri(client_dict, client_id=request_data["client_id"], **query) + + response = aiohttp.web.HTTPFound( + authorize_uri, + headers={ + "Location": authorize_uri, + "Refresh": "0;url={}".format(authorize_uri), + }, + content_type="text/html", + text="""\n\n...\n\n""" + ) + set_cookie(self.App, response, session, cookie_domain=self.CookieService.RootCookieDomain) + return response + + async def _impersonate(self, impersonator_root_session, impersonator_from_info, target_cid): + """ + Create a new impersonated session and log the event. + """ + # TODO: Restrict impersonation based on agent X target resource intersection + impersonator_cid = impersonator_root_session.Credentials.Id try: - session = await self.AuthenticationService.create_impersonated_session(impersonator_root_session, target_cid) + session = await self.AuthenticationService.create_impersonated_session( + impersonator_root_session, target_cid) + except seacatauth.exceptions.AccessDeniedError as e: + await self.AuditService.append( + AuditCode.IMPERSONATION_FAILED, + { + "impersonator_cid": impersonator_cid, + "impersonator_sid": impersonator_root_session.SessionId, + "target_cid": target_cid, + "fi": impersonator_from_info, + } + ) + raise aiohttp.web.HTTPForbidden() from e except Exception as e: await self.AuditService.append( AuditCode.IMPERSONATION_FAILED, { - "impersonator_cid": credentials_id, + "impersonator_cid": impersonator_cid, "impersonator_sid": impersonator_root_session.SessionId, "target_cid": target_cid, - "fi": from_info, + "fi": impersonator_from_info, } ) raise e @@ -459,40 +546,21 @@ async def impersonate(self, request, *, credentials_id): asab.LOG_NOTICE, "Impersonation successful.", struct_data={ - "impersonator_cid": credentials_id, + "impersonator_cid": impersonator_cid, "impersonator_sid": impersonator_root_session.SessionId, "target_cid": target_cid, "target_sid": str(session.Session.Id), - "from_info": from_info, + "from_info": impersonator_from_info, } ) await self.AuditService.append( AuditCode.IMPERSONATION_SUCCESSFUL, { - "impersonator_cid": credentials_id, + "impersonator_cid": impersonator_cid, "impersonator_sid": impersonator_root_session.SessionId, "target_cid": target_cid, "target_sid": session.Session.Id, - "fi": from_info, + "fi": impersonator_from_info, } ) - - client_dict = await client_service.get(request_data["client_id"]) - query = { - k: v for k, v in request_data.items() - if k in frozenset([ - "redirect_uri", "response_type", "scope", "prompt", "code_challenge", "code_challenge_method"]) - } - authorize_uri = oidc_service.build_authorize_uri(client_dict, client_id=request_data["client_id"], **query) - - response = aiohttp.web.HTTPFound( - authorize_uri, - headers={ - "Location": authorize_uri, - "Refresh": "0;url={}".format(authorize_uri), - }, - content_type="text/html", - text="""\n\n...\n\n""" - ) - set_cookie(self.App, response, session, cookie_domain=self.CookieService.RootCookieDomain) - return response + return session diff --git a/seacatauth/authn/service.py b/seacatauth/authn/service.py index e99e8df7..093163a1 100644 --- a/seacatauth/authn/service.py +++ b/seacatauth/authn/service.py @@ -438,10 +438,10 @@ async def create_impersonated_session(self, impersonator_session, target_cid: st target_authz = await build_credentials_authz( self.TenantService, self.RoleService, target_cid, tenants=None) if self.RBACService.is_superuser(target_authz): - L.warning("Cannot impersonate a superuser.", struct_data={ - "impersonator_cid": impersonator_cid, "target_cid": target_cid}) - raise exceptions.AccessDeniedError( - subject=impersonator_cid, resource="impersonate:{}".format(target_cid)) + L.warning( + "Impersonation target is a superuser. Resource 'authz:superuser' will be excluded " + "from the impersonated session.", + struct_data={"impersonator_cid": impersonator_cid, "target_cid": target_cid}) scope = frozenset(["profile", "email", "phone"]) session_builders = [ @@ -450,7 +450,8 @@ async def create_impersonated_session(self, impersonator_session, target_cid: st tenant_service=self.TenantService, role_service=self.RoleService, credentials_id=target_cid, - tenants=None # Root session is tenant-agnostic + tenants=None, # Root session is tenant-agnostic + exclude_resources={"authz:superuser", "authz:impersonate"}, ), cookie_session_builder(), await available_factors_session_builder(self, target_cid), diff --git a/seacatauth/authz/utils.py b/seacatauth/authz/utils.py index 8233bc39..e58158f3 100644 --- a/seacatauth/authz/utils.py +++ b/seacatauth/authz/utils.py @@ -1,4 +1,7 @@ -async def build_credentials_authz(tenant_service, role_service, credentials_id, tenants=None): +async def build_credentials_authz( + tenant_service, role_service, credentials_id, + tenants=None, exclude_resources=None +): """ Creates a nested 'authz' dict with tenant:resource structure: { @@ -7,13 +10,18 @@ async def build_credentials_authz(tenant_service, role_service, credentials_id, 'tenantB': ['resourceA', 'resourceB', 'resourceE', 'resourceD'], } """ + exclude_resources = exclude_resources or frozenset() # Add global roles and resources under "*" authz = {} tenant = "*" authz[tenant] = set() for role in await role_service.get_roles_by_credentials(credentials_id, [tenant]): - authz[tenant].update(await role_service.get_role_resources(role)) + authz[tenant].update( + res + for res in await role_service.get_role_resources(role) + if res not in exclude_resources + ) authz[tenant] = list(authz[tenant]) # Add tenant-specific roles and resources @@ -21,7 +29,11 @@ async def build_credentials_authz(tenant_service, role_service, credentials_id, for tenant in tenants: authz[tenant] = set() for role in await role_service.get_roles_by_credentials(credentials_id, [tenant]): - authz[tenant].update(await role_service.get_role_resources(role)) + authz[tenant].update( + res + for res in await role_service.get_role_resources(role) + if res not in exclude_resources + ) authz[tenant] = list(authz[tenant]) return authz diff --git a/seacatauth/cookie/service.py b/seacatauth/cookie/service.py index 9823270e..e3c306c2 100644 --- a/seacatauth/cookie/service.py +++ b/seacatauth/cookie/service.py @@ -159,6 +159,12 @@ async def create_cookie_client_session(self, root_session, client_id, scope, ten except KeyError: raise KeyError("Client '{}' not found".format(client_id)) + # Make sure dangerous resources are removed from impersonated sessions + if root_session.Authentication.ImpersonatorSessionId is not None: + exclude_resources = {"authz:superuser", "authz:impersonate"} + else: + exclude_resources = None + # Build the session session_builders = [ await credentials_session_builder(self.CredentialsService, root_session.Credentials.Id, scope), @@ -167,6 +173,7 @@ async def create_cookie_client_session(self, root_session, client_id, scope, ten role_service=self.RoleService, credentials_id=root_session.Credentials.Id, tenants=tenants, + exclude_resources=exclude_resources, ), cookie_session_builder(), ] diff --git a/seacatauth/openidconnect/service.py b/seacatauth/openidconnect/service.py index 062ede09..be58a6cc 100644 --- a/seacatauth/openidconnect/service.py +++ b/seacatauth/openidconnect/service.py @@ -258,6 +258,12 @@ async def create_oidc_session( code_challenge_method: str = None ): # TODO: Choose builders based on scope + # Make sure dangerous resources are removed from impersonated sessions + if root_session.Authentication.ImpersonatorSessionId is not None: + exclude_resources = {"authz:superuser", "authz:impersonate"} + else: + exclude_resources = set() + session_builders = [ await credentials_session_builder(self.CredentialsService, root_session.Credentials.Id, scope), await authz_session_builder( @@ -265,6 +271,7 @@ async def create_oidc_session( role_service=self.RoleService, credentials_id=root_session.Credentials.Id, tenants=tenants, + exclude_resources=exclude_resources, ) ] diff --git a/seacatauth/session/builders.py b/seacatauth/session/builders.py index bc277d55..19b0aba8 100644 --- a/seacatauth/session/builders.py +++ b/seacatauth/session/builders.py @@ -43,13 +43,16 @@ async def external_login_session_builder(external_login_service, credentials_id) return ((SessionAdapter.FN.Authentication.ExternalLoginOptions, external_logins),) -async def authz_session_builder(tenant_service, role_service, credentials_id, tenants=None): +async def authz_session_builder( + tenant_service, role_service, credentials_id, + tenants=None, exclude_resources=None +): """ Add 'authz' dict with currently authorized tenants and their resources Add 'tenants' list with complete list of credential's tenants """ tenants = tenants or [] - authz = await build_credentials_authz(tenant_service, role_service, credentials_id, tenants) + authz = await build_credentials_authz(tenant_service, role_service, credentials_id, tenants, exclude_resources) user_tenants = list(set(await tenant_service.get_tenants(credentials_id)).union(tenants)) return ( (SessionAdapter.FN.Authorization.Authz, authz),