Files
govoplan-access/src/govoplan_access/backend/api/v1/admin_common.py
2026-07-14 13:22:09 +02:00

455 lines
17 KiB
Python

from __future__ import annotations
from collections import defaultdict
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_access.backend.admin.service import (
AdminConflictError,
AdminValidationError,
assert_tenant_owner_exists,
role_assignment_counts,
set_user_groups,
set_user_roles,
tenant_owner_user_ids,
)
from govoplan_access.backend.api.v1.admin_schemas import (
ApiKeyAdminItem,
GroupSummary,
RoleSummary,
SystemAccountItem,
UserAdminItem,
)
from govoplan_access.backend.security.sessions import (
collect_direct_user_roles,
collect_system_roles,
collect_user_groups,
collect_user_scopes,
)
from govoplan_access.backend.semantic import (
collect_external_function_roles,
collect_function_assignment_ids,
collect_function_delegation_ids,
)
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope
from govoplan_access.backend.db.models import (
Account,
ApiKey,
Group,
GroupRoleAssignment,
Role,
SystemRoleAssignment,
Tenant,
User,
UserGroupMembership,
UserRoleAssignment,
)
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import OrganizationDirectory
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes
def _http_admin_error(exc: Exception) -> HTTPException:
if isinstance(exc, AdminConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, AdminValidationError):
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
def _require_system_role_assignment(principal: ApiPrincipal) -> None:
if not (has_scope(principal, "system:roles:assign") or has_scope(principal, "system:access:assign")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:roles:assign")
def _require_permission(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _resolve_tenant(
session: Session,
principal: ApiPrincipal,
tenant_id: str | None,
) -> Tenant:
target_id = tenant_id or principal.tenant_id
if target_id != principal.tenant_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Switch to the target tenant before using tenant-administration endpoints.",
)
tenant = session.get(Tenant, target_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
return tenant
def _tenant_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, tuple[int, int]]:
if not role_ids:
return {}
user_counts = {
role_id: count
for role_id, count in session.query(UserRoleAssignment.role_id, func.count(UserRoleAssignment.id))
.filter(UserRoleAssignment.role_id.in_(role_ids))
.group_by(UserRoleAssignment.role_id)
.all()
}
group_counts = {
role_id: count
for role_id, count in session.query(GroupRoleAssignment.role_id, func.count(GroupRoleAssignment.id))
.filter(GroupRoleAssignment.role_id.in_(role_ids))
.group_by(GroupRoleAssignment.role_id)
.all()
}
return {role_id: (int(user_counts.get(role_id, 0)), int(group_counts.get(role_id, 0))) for role_id in role_ids}
def _system_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, int]:
if not role_ids:
return {}
return {
role_id: int(count)
for role_id, count in session.query(SystemRoleAssignment.role_id, func.count(SystemRoleAssignment.id))
.filter(SystemRoleAssignment.role_id.in_(role_ids))
.group_by(SystemRoleAssignment.role_id)
.all()
}
def _accounts_by_id(session: Session, account_ids: list[str]) -> dict[str, Account]:
if not account_ids:
return {}
return {
account.id: account
for account in session.query(Account).filter(Account.id.in_(sorted(set(account_ids)))).all()
}
def _accounts_by_user_id(session: Session, user_ids: list[str]) -> dict[str, Account]:
if not user_ids:
return {}
return {
user.id: account
for user, account in session.query(User, Account)
.join(Account, Account.id == User.account_id)
.filter(User.id.in_(sorted(set(user_ids))))
.all()
}
def _group_member_ids_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[str]]:
grouped: dict[str, list[str]] = defaultdict(list)
if not group_ids:
return {}
for group_id, user_id in (
session.query(UserGroupMembership.group_id, UserGroupMembership.user_id)
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.group_id.in_(sorted(set(group_ids))))
.order_by(UserGroupMembership.group_id.asc(), UserGroupMembership.user_id.asc())
.all()
):
grouped[group_id].append(user_id)
return dict(grouped)
def _roles_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[Role]]:
grouped: dict[str, list[Role]] = defaultdict(list)
if not group_ids:
return {}
for group_id, role in (
session.query(GroupRoleAssignment.group_id, Role)
.join(Role, Role.id == GroupRoleAssignment.role_id)
.filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id.in_(sorted(set(group_ids))), Role.tenant_id == tenant_id)
.order_by(GroupRoleAssignment.group_id.asc(), Role.name.asc())
.all()
):
grouped[group_id].append(role)
return dict(grouped)
def _groups_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Group]]:
grouped: dict[str, list[Group]] = defaultdict(list)
if not user_ids:
return {}
for user_id, group in (
session.query(UserGroupMembership.user_id, Group)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.tenant_id == tenant_id,
UserGroupMembership.user_id.in_(sorted(set(user_ids))),
Group.is_active.is_(True),
)
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
.all()
):
grouped[user_id].append(group)
return dict(grouped)
def _roles_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Role]]:
grouped: dict[str, list[Role]] = defaultdict(list)
if not user_ids:
return {}
for user_id, role in (
session.query(UserRoleAssignment.user_id, Role)
.join(Role, Role.id == UserRoleAssignment.role_id)
.filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.user_id.in_(sorted(set(user_ids))),
Role.tenant_id == tenant_id,
)
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
.all()
):
grouped[user_id].append(role)
return dict(grouped)
def _role_summary(
session: Session,
role: Role,
*,
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
system_role_assignment_counts: dict[str, int] | None = None,
) -> RoleSummary:
group_count = 0
if role.tenant_id is None:
user_count = (
system_role_assignment_counts.get(role.id, 0)
if system_role_assignment_counts is not None
else session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
)
permission_level = "system"
else:
user_count, group_count = (
tenant_role_assignment_counts.get(role.id, (0, 0))
if tenant_role_assignment_counts is not None
else role_assignment_counts(session, role.id)
)
permission_level = "tenant"
permissions = list(role.permissions or [])
return RoleSummary(
id=role.id,
slug=role.slug,
name=role.name,
description=role.description,
permissions=permissions,
effective_permission_count=effective_permission_count(permissions, level=permission_level),
is_builtin=role.is_builtin,
is_assignable=role.is_assignable,
user_assignments=user_count,
group_assignments=group_count,
level="system" if role.tenant_id is None else "tenant",
system_template_id=role.system_template_id,
system_required=role.system_required,
)
def _group_summary(
session: Session,
group: Group,
*,
include_members: bool = True,
member_ids_by_group: dict[str, list[str]] | None = None,
roles_by_group: dict[str, list[Role]] | None = None,
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
) -> GroupSummary:
member_ids = (
member_ids_by_group.get(group.id, [])
if member_ids_by_group is not None
else [
row[0]
for row in session.query(UserGroupMembership.user_id)
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id)
.all()
]
)
roles = (
roles_by_group.get(group.id, [])
if roles_by_group is not None
else (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id)
.order_by(Role.name.asc())
.all()
)
)
return GroupSummary(
id=group.id,
slug=group.slug,
name=group.name,
description=group.description,
is_active=group.is_active,
member_count=len(member_ids),
member_ids=member_ids if include_members else [],
roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
created_at=group.created_at,
updated_at=group.updated_at,
system_template_id=group.system_template_id,
system_required=group.system_required,
)
def _user_item(
session: Session,
user: User,
*,
owner_ids: set[str] | None = None,
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
organization_directory: OrganizationDirectory | None = None,
accounts_by_id: dict[str, Account] | None = None,
groups_by_user: dict[str, list[Group]] | None = None,
roles_by_user: dict[str, list[Role]] | None = None,
group_member_ids_by_group: dict[str, list[str]] | None = None,
group_roles_by_group: dict[str, list[Role]] | None = None,
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
) -> UserAdminItem:
account = accounts_by_id.get(user.account_id) if accounts_by_id is not None else session.get(Account, user.account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
groups = groups_by_user.get(user.id, []) if groups_by_user is not None else collect_user_groups(session, user)
roles = roles_by_user.get(user.id, []) if roles_by_user is not None else collect_direct_user_roles(session, user)
external_roles = (
collect_external_function_roles(
session,
user,
idm_assignments,
organization_directory=organization_directory,
)
if idm_assignments else []
)
effective_scopes = set(collect_user_scopes(session, user, include_system=False))
for role in external_roles:
effective_scopes.update(role.permissions or [])
function_assignment_ids = collect_function_assignment_ids(session, user)
function_assignment_ids.extend(item.id for item in idm_assignments)
effective_owner_ids = owner_ids if owner_ids is not None else tenant_owner_user_ids(session, user.tenant_id)
return UserAdminItem(
id=user.id,
account_id=account.id,
tenant_id=user.tenant_id,
email=account.email,
display_name=user.display_name or account.display_name,
is_active=user.is_active,
account_is_active=account.is_active,
password_reset_required=account.password_reset_required,
last_login_at=account.last_login_at,
groups=[
_group_summary(
session,
group,
include_members=False,
member_ids_by_group=group_member_ids_by_group,
roles_by_group=group_roles_by_group,
tenant_role_assignment_counts=tenant_role_assignment_counts,
)
for group in groups
],
roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)),
function_delegation_ids=collect_function_delegation_ids(session, user),
effective_scopes=expand_scopes(effective_scopes),
is_owner=user.id in effective_owner_ids,
is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1,
created_at=user.created_at,
updated_at=user.updated_at,
)
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
memberships = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.account_id == account.id)
.order_by(Tenant.name.asc())
.all()
)
owner_ids_by_tenant = {
tenant.id: tenant_owner_user_ids(session, tenant.id)
for _, tenant in memberships
}
return SystemAccountItem(
account_id=account.id,
email=account.email,
display_name=account.display_name,
is_active=account.is_active,
memberships=[
{
"tenant_id": tenant.id,
"tenant_name": tenant.name,
"user_id": user.id,
"is_active": user.is_active and tenant.is_active,
"role_ids": [role.id for role in collect_direct_user_roles(session, user)],
"group_ids": [group.id for group in collect_user_groups(session, user)],
"is_owner": user.id in owner_ids_by_tenant[tenant.id],
"is_last_active_owner": (
user.id in owner_ids_by_tenant[tenant.id]
and len(owner_ids_by_tenant[tenant.id]) == 1
),
}
for user, tenant in memberships
],
roles=[_role_summary(session, role) for role in collect_system_roles(session, account)],
last_login_at=account.last_login_at,
)
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
if accounts_by_user_id is not None:
account = accounts_by_user_id.get(item.user_id)
else:
user = session.get(User, item.user_id)
account = session.get(Account, user.account_id) if user else None
return ApiKeyAdminItem(
id=item.id,
user_id=item.user_id,
user_email=account.email if account else "Unknown account",
name=item.name,
prefix=item.prefix,
scopes=item.scopes or [],
expires_at=item.expires_at,
last_used_at=item.last_used_at,
revoked_at=item.revoked_at,
created_at=item.created_at,
)
def _set_system_memberships(session: Session, account: Account, requested: list[dict]) -> None:
desired = {item["tenant_id"]: item for item in requested}
existing = {item.tenant_id: item for item in session.query(User).filter(User.account_id == account.id).all()}
affected = set(existing) | set(desired)
for tenant_id, user in existing.items():
if tenant_id not in desired:
user.is_active = False
session.add(user)
for tenant_id, item in desired.items():
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise AdminValidationError(f"Unknown tenant: {tenant_id}")
user = existing.get(tenant_id)
if user is None:
user = User(
tenant_id=tenant.id,
account_id=account.id,
email=account.email,
display_name=account.display_name,
is_active=bool(item.get("is_active", True)),
auth_provider=account.auth_provider,
password_hash=account.password_hash,
)
session.add(user)
session.flush()
else:
user.is_active = bool(item.get("is_active", True))
user.email = account.email
session.add(user)
set_user_roles(session, user=user, role_ids=item.get("role_ids", []))
set_user_groups(session, user=user, group_ids=item.get("group_ids", []))
session.flush()
for tenant_id in affected:
tenant = session.get(Tenant, tenant_id)
if tenant and tenant.is_active:
assert_tenant_owner_exists(session, tenant_id)