Skip to content

Commit

Permalink
Merge pull request #210 from TeskaLabs/feature/impersonation-rest-api
Browse files Browse the repository at this point in the history
Impersonation REST API
  • Loading branch information
byewokko authored Jun 5, 2023
2 parents b9d7ac0 + f17d3a7 commit 6a89a36
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 63 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand Down
174 changes: 121 additions & 53 deletions seacatauth/authn/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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`.
---
Expand Down Expand Up @@ -427,30 +476,68 @@ 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:
from_info.extend(ff.split(", "))

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="""<!doctype html>\n<html lang="en">\n<head></head><body>...</body>\n</html>\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
Expand All @@ -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="""<!doctype html>\n<html lang="en">\n<head></head><body>...</body>\n</html>\n"""
)
set_cookie(self.App, response, session, cookie_domain=self.CookieService.RootCookieDomain)
return response
return session
11 changes: 6 additions & 5 deletions seacatauth/authn/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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),
Expand Down
18 changes: 15 additions & 3 deletions seacatauth/authz/utils.py
Original file line number Diff line number Diff line change
@@ -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:
{
Expand All @@ -7,21 +10,30 @@ 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
if tenant_service.is_enabled() and tenants is not None:
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
7 changes: 7 additions & 0 deletions seacatauth/cookie/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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(),
]
Expand Down
7 changes: 7 additions & 0 deletions seacatauth/openidconnect/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,13 +258,20 @@ 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(
tenant_service=self.TenantService,
role_service=self.RoleService,
credentials_id=root_session.Credentials.Id,
tenants=tenants,
exclude_resources=exclude_resources,
)
]

Expand Down
7 changes: 5 additions & 2 deletions seacatauth/session/builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down

0 comments on commit 6a89a36

Please sign in to comment.