336 lines
10 KiB
Python
336 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import timedelta
|
|
from collections.abc import Iterable
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
|
from govoplan_access.backend.db.models import (
|
|
Account,
|
|
AuthSession,
|
|
Group,
|
|
GroupRoleAssignment,
|
|
Role,
|
|
SystemRoleAssignment,
|
|
Tenant,
|
|
User,
|
|
UserGroupMembership,
|
|
UserRoleAssignment,
|
|
)
|
|
from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles
|
|
from govoplan_access.backend.permissions.catalog import expand_scopes, role_templates_for_level
|
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
|
|
|
SESSION_RANDOM_BYTES = 32
|
|
DEFAULT_SESSION_HOURS = 12
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class CreatedSession:
|
|
model: AuthSession
|
|
token: str
|
|
csrf_token: str
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class UserAuthorizationContext:
|
|
tenant_roles: list[Role]
|
|
system_roles: list[Role]
|
|
groups: list[Group]
|
|
function_assignment_ids: tuple[str, ...]
|
|
function_delegation_ids: tuple[str, ...]
|
|
scopes: list[str]
|
|
|
|
|
|
def generate_session_token() -> str:
|
|
return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES)
|
|
|
|
|
|
def hash_session_token(token: str) -> str:
|
|
return hash_secret(token)
|
|
|
|
|
|
def generate_csrf_token() -> str:
|
|
return generate_secret("", random_bytes=SESSION_RANDOM_BYTES)
|
|
|
|
|
|
def hash_csrf_token(token: str) -> str:
|
|
return hash_secret(token)
|
|
|
|
|
|
def verify_session_token(token: str, expected_hash: str) -> bool:
|
|
return verify_secret(token, expected_hash)
|
|
|
|
|
|
def verify_auth_session_csrf(auth_session: AuthSession, csrf_token: str | None) -> bool:
|
|
if not csrf_token or not auth_session.csrf_token_hash:
|
|
return False
|
|
return verify_secret(csrf_token, auth_session.csrf_token_hash)
|
|
|
|
|
|
def create_auth_session(
|
|
session: Session,
|
|
*,
|
|
user: User,
|
|
hours: int = DEFAULT_SESSION_HOURS,
|
|
user_agent: str | None = None,
|
|
ip_address: str | None = None,
|
|
) -> CreatedSession:
|
|
if not user.account_id:
|
|
raise ValueError("Tenant membership has no global account.")
|
|
token = generate_session_token()
|
|
csrf_token = generate_csrf_token()
|
|
now = utc_now()
|
|
model = AuthSession(
|
|
tenant_id=user.tenant_id,
|
|
user_id=user.id,
|
|
account_id=user.account_id,
|
|
token_hash=hash_session_token(token),
|
|
csrf_token_hash=hash_csrf_token(csrf_token),
|
|
expires_at=now + timedelta(hours=hours),
|
|
last_seen_at=now,
|
|
user_agent=user_agent,
|
|
ip_address=ip_address,
|
|
)
|
|
user.last_login_at = now
|
|
if user.account:
|
|
user.account.last_login_at = now
|
|
session.add(user.account)
|
|
session.add(model)
|
|
session.add(user)
|
|
session.flush()
|
|
return CreatedSession(model=model, token=token, csrf_token=csrf_token)
|
|
|
|
|
|
def authenticate_session_token(session: Session, token: str) -> AuthSession | None:
|
|
token_hash = hash_session_token(token)
|
|
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none()
|
|
if not model:
|
|
return None
|
|
now = utc_now()
|
|
expires_at = ensure_aware_utc(model.expires_at)
|
|
if expires_at is None or expires_at < now:
|
|
return None
|
|
model.last_seen_at = now
|
|
session.add(model)
|
|
return model
|
|
|
|
|
|
def revoke_auth_session(session: Session, token: str) -> bool:
|
|
model = authenticate_session_token(session, token)
|
|
if not model:
|
|
return False
|
|
model.revoked_at = utc_now()
|
|
session.add(model)
|
|
return True
|
|
|
|
|
|
def switch_auth_session_tenant(session: Session, auth_session: AuthSession, tenant_id: str) -> User:
|
|
membership = (
|
|
session.query(User)
|
|
.join(Tenant, Tenant.id == User.tenant_id)
|
|
.filter(
|
|
User.account_id == auth_session.account_id,
|
|
User.tenant_id == tenant_id,
|
|
User.is_active.is_(True),
|
|
Tenant.is_active.is_(True),
|
|
)
|
|
.one_or_none()
|
|
)
|
|
if membership is None:
|
|
raise LookupError("The account does not have an active membership in this tenant.")
|
|
auth_session.tenant_id = membership.tenant_id
|
|
auth_session.user_id = membership.id
|
|
auth_session.last_seen_at = utc_now()
|
|
session.add(auth_session)
|
|
session.flush()
|
|
return membership
|
|
|
|
|
|
def collect_direct_user_roles(session: Session, user: User) -> list[Role]:
|
|
return (
|
|
session.query(Role)
|
|
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
|
|
.filter(
|
|
UserRoleAssignment.user_id == user.id,
|
|
UserRoleAssignment.tenant_id == user.tenant_id,
|
|
Role.tenant_id == user.tenant_id,
|
|
)
|
|
.order_by(Role.name.asc())
|
|
.all()
|
|
)
|
|
|
|
|
|
def collect_user_roles(session: Session, user: User) -> list[Role]:
|
|
roles_by_id: dict[str, Role] = {role.id: role for role in collect_direct_user_roles(session, user)}
|
|
|
|
group_roles = (
|
|
session.query(Role)
|
|
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
|
.join(UserGroupMembership, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
|
|
.join(Group, Group.id == UserGroupMembership.group_id)
|
|
.filter(
|
|
UserGroupMembership.user_id == user.id,
|
|
UserGroupMembership.tenant_id == user.tenant_id,
|
|
Group.is_active.is_(True),
|
|
Role.tenant_id == user.tenant_id,
|
|
)
|
|
.all()
|
|
)
|
|
for role in group_roles:
|
|
roles_by_id[role.id] = role
|
|
for role in collect_function_roles(session, user):
|
|
roles_by_id[role.id] = role
|
|
for role in _materialized_default_authenticated_roles(session, user):
|
|
roles_by_id[role.id] = role
|
|
return list(roles_by_id.values())
|
|
|
|
|
|
def collect_system_roles(session: Session, account: Account) -> list[Role]:
|
|
return (
|
|
session.query(Role)
|
|
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
|
.filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None))
|
|
.order_by(Role.name.asc())
|
|
.all()
|
|
)
|
|
|
|
|
|
def collect_user_groups(session: Session, user: User) -> list[Group]:
|
|
return (
|
|
session.query(Group)
|
|
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
|
|
.filter(
|
|
UserGroupMembership.user_id == user.id,
|
|
UserGroupMembership.tenant_id == user.tenant_id,
|
|
Group.is_active.is_(True),
|
|
)
|
|
.order_by(Group.name.asc())
|
|
.all()
|
|
)
|
|
|
|
|
|
def collect_user_authorization_context(
|
|
session: Session,
|
|
user: User,
|
|
*,
|
|
account: Account | None = None,
|
|
include_system: bool = True,
|
|
extra_roles: Iterable[Role] = (),
|
|
) -> UserAuthorizationContext:
|
|
account = account or user.account
|
|
roles_by_id: dict[str, Role] = {role.id: role for role in collect_direct_user_roles(session, user)}
|
|
groups = collect_user_groups(session, user)
|
|
group_ids = [group.id for group in groups]
|
|
if group_ids:
|
|
group_roles = (
|
|
session.query(Role)
|
|
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
|
.filter(
|
|
GroupRoleAssignment.tenant_id == user.tenant_id,
|
|
GroupRoleAssignment.group_id.in_(sorted(group_ids)),
|
|
Role.tenant_id == user.tenant_id,
|
|
)
|
|
.order_by(Role.name.asc())
|
|
.all()
|
|
)
|
|
for role in group_roles:
|
|
roles_by_id[role.id] = role
|
|
|
|
function_context = collect_function_authorization_context(session, user)
|
|
for role in function_context.roles:
|
|
roles_by_id[role.id] = role
|
|
for role in extra_roles:
|
|
roles_by_id[role.id] = role
|
|
for role in _materialized_default_authenticated_roles(session, user):
|
|
roles_by_id[role.id] = role
|
|
|
|
tenant_roles = list(roles_by_id.values())
|
|
system_roles = collect_system_roles(session, account) if include_system and account is not None else []
|
|
default_slugs = _default_authenticated_slugs()
|
|
scopes = {
|
|
scope
|
|
for role in tenant_roles
|
|
if role.slug not in default_slugs
|
|
for scope in (role.permissions or [])
|
|
}
|
|
scopes.update(
|
|
scope
|
|
for role in system_roles
|
|
for scope in (role.permissions or [])
|
|
)
|
|
scopes.update(_default_authenticated_scopes())
|
|
return UserAuthorizationContext(
|
|
tenant_roles=tenant_roles,
|
|
system_roles=system_roles,
|
|
groups=groups,
|
|
function_assignment_ids=function_context.assignment_ids,
|
|
function_delegation_ids=function_context.delegation_ids,
|
|
scopes=expand_scopes(scopes),
|
|
)
|
|
|
|
|
|
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
|
|
scopes = _default_authenticated_scopes()
|
|
default_slugs = _default_authenticated_slugs()
|
|
for role in collect_user_roles(session, user):
|
|
if role.slug not in default_slugs:
|
|
scopes.update(role.permissions or [])
|
|
if include_system and user.account:
|
|
for role in collect_system_roles(session, user.account):
|
|
scopes.update(role.permissions or [])
|
|
return expand_scopes(scopes)
|
|
|
|
|
|
def _default_authenticated_slugs() -> set[str]:
|
|
return {
|
|
template.slug
|
|
for template in role_templates_for_level("tenant")
|
|
if template.default_authenticated
|
|
}
|
|
|
|
|
|
def _default_authenticated_scopes() -> set[str]:
|
|
return {
|
|
scope
|
|
for template in role_templates_for_level("tenant")
|
|
if template.default_authenticated
|
|
for scope in template.permissions
|
|
}
|
|
|
|
|
|
def _materialized_default_authenticated_roles(
|
|
session: Session,
|
|
user: User,
|
|
) -> list[Role]:
|
|
"""Return the optional database projection without mutating auth reads."""
|
|
|
|
default_slugs = _default_authenticated_slugs()
|
|
if not default_slugs:
|
|
return []
|
|
return (
|
|
session.query(Role)
|
|
.filter(
|
|
Role.tenant_id == user.tenant_id,
|
|
Role.slug.in_(default_slugs),
|
|
)
|
|
.order_by(Role.name.asc(), Role.id.asc())
|
|
.all()
|
|
)
|
|
|
|
|
|
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
|
|
return (
|
|
session.query(User, Tenant)
|
|
.join(Tenant, Tenant.id == User.tenant_id)
|
|
.filter(
|
|
User.account_id == account.id,
|
|
User.is_active.is_(True),
|
|
Tenant.is_active.is_(True),
|
|
)
|
|
.order_by(Tenant.name.asc())
|
|
.all()
|
|
)
|