455 lines
17 KiB
Python
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)
|