From ab07075a679b8c3704ec4c2e1e0ceb8491d8d8c7 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 14 Jul 2026 13:22:09 +0200 Subject: [PATCH] intermittent commit --- .../backend/api/v1/admin_common.py | 221 ++++++- .../backend/api/v1/admin_schemas.py | 90 +-- src/govoplan_access/backend/api/v1/auth.py | 493 +++++++++++++-- src/govoplan_access/backend/api/v1/routes.py | 576 ++++++++++++++---- .../backend/auth/dependencies.py | 362 ++++++++--- .../backend/permissions/catalog.py | 144 ++--- .../backend/security/sessions.py | 63 +- src/govoplan_access/backend/semantic.py | 58 ++ tests/test_admin_batch_helpers.py | 58 ++ tests/test_admin_pagination.py | 89 +++ tests/test_auth_dependencies.py | 49 ++ tests/test_optional_tenancy_contract.py | 4 +- tests/test_permission_catalog_contract.py | 29 + webui/src/api/admin.ts | 140 +---- webui/src/features/admin/AdminPage.tsx | 11 +- webui/src/features/admin/SystemUsersPanel.tsx | 13 +- webui/src/features/admin/utils/deltaRows.ts | 7 +- 17 files changed, 1821 insertions(+), 586 deletions(-) create mode 100644 tests/test_admin_batch_helpers.py create mode 100644 tests/test_admin_pagination.py create mode 100644 tests/test_auth_dependencies.py create mode 100644 tests/test_permission_catalog_contract.py diff --git a/src/govoplan_access/backend/api/v1/admin_common.py b/src/govoplan_access/backend/api/v1/admin_common.py index d6ea6c8..d5527e2 100644 --- a/src/govoplan_access/backend/api/v1/admin_common.py +++ b/src/govoplan_access/backend/api/v1/admin_common.py @@ -1,6 +1,9 @@ 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 ( @@ -41,6 +44,7 @@ from govoplan_access.backend.db.models import ( Tenant, User, UserGroupMembership, + UserRoleAssignment, ) from govoplan_core.core.idm import OrganizationFunctionAssignmentRef from govoplan_core.core.organizations import OrganizationDirectory @@ -82,13 +86,147 @@ def _resolve_tenant( return tenant -def _role_summary(session: Session, role: Role) -> RoleSummary: +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 = session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count() + 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 = role_assignment_counts(session, role.id) + 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( @@ -108,19 +246,35 @@ def _role_summary(session: Session, role: Role) -> RoleSummary: ) -def _group_summary(session: Session, group: Group, *, include_members: bool = True) -> GroupSummary: - member_ids = [ - row[0] - for row in session.query(UserGroupMembership.user_id) - .filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id) - .all() - ] +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 = ( - 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() + 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, @@ -130,7 +284,7 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr is_active=group.is_active, member_count=len(member_ids), member_ids=member_ids if include_members else [], - roles=[_role_summary(session, role) for role in roles], + 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, @@ -145,12 +299,18 @@ def _user_item( 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 = session.get(Account, user.account_id) + 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 = collect_user_groups(session, user) - roles = collect_direct_user_roles(session, user) + 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, @@ -176,8 +336,18 @@ def _user_item( 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) for group in groups], - roles=[_role_summary(session, role) for role in roles], + 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), @@ -226,9 +396,12 @@ def _system_account_item(session: Session, account: Account) -> SystemAccountIte ) -def _api_key_item(session: Session, item: ApiKey) -> ApiKeyAdminItem: - user = session.get(User, item.user_id) - account = session.get(Account, user.account_id) if user else None +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, diff --git a/src/govoplan_access/backend/api/v1/admin_schemas.py b/src/govoplan_access/backend/api/v1/admin_schemas.py index 491d77f..79d22c0 100644 --- a/src/govoplan_access/backend/api/v1/admin_schemas.py +++ b/src/govoplan_access/backend/api/v1/admin_schemas.py @@ -3,41 +3,10 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field from govoplan_core.api.v1.schemas import DeltaDeletedItem - -RETENTION_DAY_KEYS = ( - "raw_campaign_json_retention_days", - "generated_eml_retention_days", - "stored_report_detail_retention_days", - "mock_mailbox_retention_days", - "audit_detail_retention_days", -) -RETENTION_POLICY_FIELD_KEYS = ( - "store_raw_campaign_json", - *RETENTION_DAY_KEYS, - "audit_detail_level", -) - - -def default_allow_lower_level_limits() -> dict[str, bool]: - return {key: True for key in RETENTION_POLICY_FIELD_KEYS} - - -def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None: - if value in (None, ""): - return default_allow_lower_level_limits() if fill_defaults else None - if not isinstance(value, dict): - raise ValueError("allow_lower_level_limits must be an object") - normalized = default_allow_lower_level_limits() if fill_defaults else {} - for key, allowed in value.items(): - clean_key = str(key) - if clean_key not in RETENTION_POLICY_FIELD_KEYS: - raise ValueError(f"Unknown retention policy field: {clean_key}") - normalized[clean_key] = bool(allowed) - return normalized - +from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem class PermissionItem(BaseModel): @@ -98,6 +67,13 @@ class TenantOwnerCandidateListResponse(BaseModel): accounts: list[TenantOwnerCandidate] +class PagedListResponse(BaseModel): + total: int = 0 + page: int = 1 + page_size: int = 500 + pages: int = 1 + + class TenantCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -179,7 +155,7 @@ class IdentityAdminItem(BaseModel): updated_at: datetime -class IdentityListResponse(BaseModel): +class IdentityListResponse(PagedListResponse): identities: list[IdentityAdminItem] @@ -529,7 +505,7 @@ class ResourceAccessExplanationResponse(BaseModel): provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list) -class UserListResponse(BaseModel): +class UserListResponse(PagedListResponse): users: list[UserAdminItem] @@ -567,7 +543,7 @@ class UserUpdateRequest(BaseModel): role_ids: list[str] | None = None -class GroupListResponse(BaseModel): +class GroupListResponse(PagedListResponse): groups: list[GroupSummary] @@ -599,7 +575,7 @@ class GroupUpdateRequest(BaseModel): role_ids: list[str] | None = None -class RoleListResponse(BaseModel): +class RoleListResponse(PagedListResponse): roles: list[RoleSummary] @@ -638,7 +614,7 @@ class SystemAccountItem(BaseModel): last_login_at: datetime | None = None -class SystemAccountListResponse(BaseModel): +class SystemAccountListResponse(PagedListResponse): accounts: list[SystemAccountItem] roles: list[RoleSummary] @@ -704,42 +680,6 @@ class PolicySourceStepItem(BaseModel): policy: dict[str, Any] = Field(default_factory=dict) -class PrivacyRetentionPolicyItem(BaseModel): - model_config = ConfigDict(extra="forbid") - - store_raw_campaign_json: bool = True - raw_campaign_json_retention_days: int | None = Field(default=None, ge=0) - generated_eml_retention_days: int | None = Field(default=None, ge=0) - stored_report_detail_retention_days: int | None = Field(default=None, ge=0) - mock_mailbox_retention_days: int | None = Field(default=None, ge=0) - audit_detail_retention_days: int | None = Field(default=None, ge=0) - audit_detail_level: Literal["full", "redacted", "minimal"] = "full" - allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits) - - @field_validator("allow_lower_level_limits", mode="before") - @classmethod - def _normalize_allow_lower_level_limits(cls, value: Any) -> Any: - return normalize_allow_lower_level_limits(value, fill_defaults=True) - - -class PrivacyRetentionPolicyPatchItem(BaseModel): - model_config = ConfigDict(extra="forbid") - - store_raw_campaign_json: bool | None = None - raw_campaign_json_retention_days: int | None = Field(default=None, ge=0) - generated_eml_retention_days: int | None = Field(default=None, ge=0) - stored_report_detail_retention_days: int | None = Field(default=None, ge=0) - mock_mailbox_retention_days: int | None = Field(default=None, ge=0) - audit_detail_retention_days: int | None = Field(default=None, ge=0) - audit_detail_level: Literal["full", "redacted", "minimal"] | None = None - allow_lower_level_limits: dict[str, bool] | None = None - - @field_validator("allow_lower_level_limits", mode="before") - @classmethod - def _normalize_allow_lower_level_limits(cls, value: Any) -> Any: - return normalize_allow_lower_level_limits(value, fill_defaults=False) - - class PrivacyRetentionPolicyScopeRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -912,7 +852,7 @@ class ApiKeyAdminItem(BaseModel): created_at: datetime -class ApiKeyListResponse(BaseModel): +class ApiKeyListResponse(PagedListResponse): api_keys: list[ApiKeyAdminItem] diff --git a/src/govoplan_access/backend/api/v1/auth.py b/src/govoplan_access/backend/api/v1/auth.py index d5432e8..f456b6c 100644 --- a/src/govoplan_access/backend/api/v1/auth.py +++ b/src/govoplan_access/backend/api/v1/auth.py @@ -1,9 +1,17 @@ from __future__ import annotations +from dataclasses import dataclass + from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy.orm import Session from govoplan_core.api.v1.schemas import ( + AuthGroupsResponse, + AuthProfileResponse, + AuthRolesResponse, + AuthShellResponse, + AuthSessionResponse, + AuthSessionUserInfo, GroupInfo, LoginRequest, LoginResponse, @@ -22,9 +30,9 @@ from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityD from govoplan_core.core.registry import PlatformRegistry from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal from govoplan_core.admin.settings import get_system_settings -from govoplan_core.audit.logging import audit_from_principal +from govoplan_core.audit.logging import audit_event from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode -from govoplan_access.backend.db.models import Account, Tenant, User +from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Group, Role, Tenant, User from govoplan_core.db.session import get_session from govoplan_core.i18n import ( i18n_settings, @@ -36,24 +44,39 @@ from govoplan_core.i18n import ( tenant_enabled_language_codes, user_enabled_language_codes, ) -from govoplan_access.backend.permissions.catalog import normalize_email, scopes_grant +from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes, normalize_email, scopes_grant from govoplan_core.security.time import utc_now from govoplan_core.settings import settings from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher +from govoplan_access.backend.security.api_keys import authenticate_api_key from govoplan_access.backend.security.passwords import verify_password from govoplan_access.backend.security.sessions import ( + authenticate_session_token, + collect_user_authorization_context, collect_system_roles, collect_tenant_memberships, collect_user_groups, collect_user_roles, collect_user_scopes, create_auth_session, + verify_auth_session_csrf, ) router = APIRouter(prefix="/auth", tags=["auth"]) +@dataclass(slots=True) +class AuthContext: + account: Account + user: User + tenant: Tenant + auth_method: str + source: str + auth_session: AuthSession | None = None + api_key: ApiKey | None = None + + def _cookie_samesite() -> str: value = settings.auth_cookie_samesite.lower().strip() if value not in {"lax", "strict", "none"}: @@ -125,6 +148,18 @@ def _user_info( ) +def _session_user_info(user: User, account: Account) -> AuthSessionUserInfo: + return AuthSessionUserInfo( + id=user.id, + account_id=account.id, + email=account.email, + display_name=account.display_name or user.display_name, + tenant_display_name=user.display_name, + is_tenant_admin=user.is_tenant_admin, + password_reset_required=account.password_reset_required, + ) + + def _roles_info(roles, *, level: str = "tenant") -> list[RoleInfo]: return [ RoleInfo( @@ -142,10 +177,20 @@ def _groups_info(groups) -> list[GroupInfo]: return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups] -def _tenant_memberships(session: Session, account: Account) -> list[TenantMembershipInfo]: +def _tenant_memberships( + session: Session, + account: Account, + *, + active_user_id: str | None = None, + active_tenant_roles: list[Role] | None = None, +) -> list[TenantMembershipInfo]: memberships: list[TenantMembershipInfo] = [] for user, tenant in collect_tenant_memberships(session, account): - roles = collect_user_roles(session, user) + roles = ( + active_tenant_roles + if active_tenant_roles is not None and user.id == active_user_id + else collect_user_roles(session, user) + ) memberships.append( TenantMembershipInfo( id=tenant.id, @@ -159,6 +204,20 @@ def _tenant_memberships(session: Session, account: Account) -> list[TenantMember return memberships +def _tenant_membership_summaries(session: Session, account: Account) -> list[TenantMembershipInfo]: + return [ + TenantMembershipInfo( + id=tenant.id, + slug=tenant.slug, + name=tenant.name, + is_active=tenant.is_active and user.is_active, + default_locale=tenant.default_locale, + roles=[], + ) + for user, tenant in collect_tenant_memberships(session, account) + ] + + def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[str, object]: system_settings = get_system_settings(session) system_payload = system_i18n_payload(system_settings) @@ -214,6 +273,254 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun return account, row[0], row[1] +def _extract_auth_token(request: Request) -> tuple[str | None, str]: + x_api_key = request.headers.get("x-api-key") + if x_api_key: + return x_api_key.strip(), "api_key" + authorization = request.headers.get("authorization") + if authorization and authorization.lower().startswith("bearer "): + return authorization[7:].strip(), "bearer" + cookie_token = request.cookies.get(settings.auth_session_cookie_name) + if cookie_token: + return cookie_token.strip(), "cookie" + return None, "none" + + +def _active_context_or_401( + *, + user: User | None, + account: Account | None, + tenant: Tenant | None, + expected_tenant_id: str, +) -> tuple[Account, User, Tenant]: + if ( + not user or not account or not tenant + or not user.is_active or not account.is_active or not tenant.is_active + or user.account_id != account.id + or user.tenant_id != expected_tenant_id + or tenant.id != expected_tenant_id + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session") + return account, user, tenant + + +def _session_response( + *, + account: Account, + user: User, + tenant: Tenant, + auth_method: str, + auth_session: AuthSession | None = None, + api_key: ApiKey | None = None, +) -> AuthSessionResponse: + active_tenant = _tenant_info(tenant) + return AuthSessionResponse( + authenticated=True, + auth_method=auth_method, # type: ignore[arg-type] + user=_session_user_info(user, account), + tenant=active_tenant, + active_tenant=active_tenant, + session_id=auth_session.id if auth_session else None, + api_key_id=api_key.id if api_key else None, + expires_at=auth_session.expires_at if auth_session else api_key.expires_at if api_key else None, + ) + + +def _resolve_auth_context(request: Request, session: Session) -> AuthContext: + token, source = _extract_auth_token(request) + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token") + + api_key = authenticate_api_key(session, token) if source != "cookie" else None + if api_key is not None: + user = session.get(User, api_key.user_id) + account = session.get(Account, user.account_id) if user else None + tenant = session.get(Tenant, api_key.tenant_id) + account, user, tenant = _active_context_or_401( + user=user, + account=account, + tenant=tenant, + expected_tenant_id=api_key.tenant_id, + ) + return AuthContext( + account=account, + user=user, + tenant=tenant, + auth_method="api_key", + source=source, + api_key=api_key, + ) + + auth_session = authenticate_session_token(session, token) + if auth_session is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token") + user = session.get(User, auth_session.user_id) + account = session.get(Account, auth_session.account_id) + tenant = session.get(Tenant, auth_session.tenant_id) + account, user, tenant = _active_context_or_401( + user=user, + account=account, + tenant=tenant, + expected_tenant_id=auth_session.tenant_id, + ) + return AuthContext( + account=account, + user=user, + tenant=tenant, + auth_method="session", + source=source, + auth_session=auth_session, + ) + + +def _shell_response( + session: Session, + *, + account: Account, + user: User, + tenant: Tenant, + scopes: list[str], + auth_method: str, + auth_session: AuthSession | None = None, + api_key: ApiKey | None = None, +) -> AuthShellResponse: + active_tenant = _tenant_info(tenant) + memberships = ( + _tenant_membership_summaries(session, account) + if auth_session is not None + else [ + TenantMembershipInfo( + id=tenant.id, + slug=tenant.slug, + name=tenant.name, + is_active=tenant.is_active and user.is_active, + default_locale=tenant.default_locale, + roles=[], + ) + ] + ) + return AuthShellResponse( + user=_user_info(user, account), + tenant=active_tenant, + active_tenant=active_tenant, + tenants=memberships, + scopes=scopes, + principal=PrincipalContextInfo( + account_id=account.id, + membership_id=user.id, + tenant_id=tenant.id, + scopes=scopes, + auth_method=auth_method, # type: ignore[arg-type] + api_key_id=api_key.id if api_key else None, + session_id=auth_session.id if auth_session else None, + email=account.email, + display_name=account.display_name or user.display_name, + ), + ) + + +def _resolve_lightweight_session(request: Request, session: Session) -> AuthSessionResponse: + context = _resolve_auth_context(request, session) + return _session_response( + account=context.account, + user=context.user, + tenant=context.tenant, + auth_method=context.auth_method, + auth_session=context.auth_session, + api_key=context.api_key, + ) + + +def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse: + context = _resolve_auth_context(request, session) + if context.api_key is not None: + user_scopes = collect_user_scopes(session, context.user, include_system=False) + scopes = intersect_api_key_scopes(user_scopes, context.api_key.scopes or []) + return _shell_response( + session, + account=context.account, + user=context.user, + tenant=context.tenant, + scopes=scopes, + auth_method="api_key", + api_key=context.api_key, + ) + + scopes = collect_user_scopes(session, context.user, include_system=True) + return _shell_response( + session, + account=context.account, + user=context.user, + tenant=context.tenant, + scopes=scopes, + auth_method="session", + auth_session=context.auth_session, + ) + + +def _profile_response(session: Session, context: AuthContext) -> AuthProfileResponse: + languages = _language_context(session, tenant=context.tenant, user=context.user) + tenant_enabled = list(languages["tenant_enabled_language_codes"]) + user_enabled = list(languages["user_enabled_language_codes"]) + preferred_language = str(languages["preferred_language"]) + active_tenant = _tenant_info(context.tenant, enabled_language_codes=tenant_enabled) + return AuthProfileResponse( + user=_user_info( + context.user, + context.account, + preferred_language=preferred_language, + enabled_language_codes=user_enabled, + ), + tenant=active_tenant, + active_tenant=active_tenant, + available_languages=languages["available_languages"], + enabled_language_codes=tenant_enabled, + default_language=preferred_language, + ) + + +def _roles_response(session: Session, context: AuthContext) -> AuthRolesResponse: + tenant_roles = collect_user_roles(session, context.user) + system_roles = collect_system_roles(session, context.account) if context.auth_session is not None else [] + return AuthRolesResponse(roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system")) + + +def _groups_response(session: Session, context: AuthContext) -> AuthGroupsResponse: + return AuthGroupsResponse(groups=_groups_info(collect_user_groups(session, context.user))) + + +def _verify_profile_mutation_allowed(request: Request, context: AuthContext) -> None: + if context.auth_session is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile") + if context.source != "cookie": + return + header_token = request.headers.get("x-csrf-token") + cookie_token = request.cookies.get(settings.auth_csrf_cookie_name) + if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(context.auth_session, header_token): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token") + + +def _roles_from_principal(session: Session, principal: ApiPrincipal, *, tenant_id: str) -> tuple[list[Role], list[Role]]: + roles = ( + session.query(Role) + .filter(Role.id.in_(sorted(principal.role_ids))) + .order_by(Role.name.asc()) + .all() + ) + tenant_roles = [role for role in roles if role.tenant_id == tenant_id] + system_roles = [role for role in roles if role.tenant_id is None and principal.auth_session is not None] + return tenant_roles, system_roles + + +def _groups_from_principal(session: Session, principal: ApiPrincipal, *, tenant_id: str) -> list[Group]: + return ( + session.query(Group) + .filter(Group.tenant_id == tenant_id, Group.id.in_(sorted(principal.group_ids))) + .order_by(Group.name.asc()) + .all() + ) + + def _me_response( session: Session, *, @@ -229,17 +536,42 @@ def _me_response( include_all_memberships: bool = True, identity_directory: IdentityDirectory | None = None, identity_id: str | None = None, + tenant_roles: list[Role] | None = None, + system_roles: list[Role] | None = None, + groups: list[Group] | None = None, + function_assignment_ids: tuple[str, ...] | None = None, + delegation_ids: tuple[str, ...] | None = None, ) -> MeResponse: - tenant_roles = collect_user_roles(session, user) - system_roles = collect_system_roles(session, account) if include_system else [] - groups = collect_user_groups(session, user) + if tenant_roles is None: + tenant_roles = collect_user_roles(session, user) + if system_roles is None: + system_roles = collect_system_roles(session, account) if include_system else [] + elif not include_system: + system_roles = [] + if groups is None: + groups = collect_user_groups(session, user) scopes = effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system) + resolved_function_assignment_ids = ( + function_assignment_ids + if function_assignment_ids is not None + else tuple(collect_function_assignment_ids(session, user)) + ) + resolved_delegation_ids = ( + delegation_ids + if delegation_ids is not None + else tuple(collect_function_delegation_ids(session, user)) + ) languages = _language_context(session, tenant=tenant, user=user) tenant_enabled = list(languages["tenant_enabled_language_codes"]) user_enabled = list(languages["user_enabled_language_codes"]) preferred_language = str(languages["preferred_language"]) active_tenant = _tenant_info(tenant, enabled_language_codes=tenant_enabled) - memberships = _tenant_memberships(session, account) if include_all_memberships else [ + memberships = _tenant_memberships( + session, + account, + active_user_id=user.id, + active_tenant_roles=tenant_roles, + ) if include_all_memberships else [ TenantMembershipInfo( id=tenant.id, slug=tenant.slug, @@ -266,8 +598,8 @@ def _me_response( scopes=frozenset(scopes), group_ids=frozenset(group.id for group in groups), role_ids=frozenset(role.id for role in tenant_roles + system_roles), - function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)), - delegation_ids=frozenset(collect_function_delegation_ids(session, user)), + function_assignment_ids=frozenset(resolved_function_assignment_ids), + delegation_ids=frozenset(resolved_delegation_ids), auth_method=auth_method, api_key_id=api_key_id, session_id=session_id, @@ -286,9 +618,18 @@ def _me_response( def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)): account, user, tenant = _resolve_login_user(session, payload) identity_directory = _identity_directory_from_request(request) - me_payload = _me_response(session, account=account, user=user, tenant=tenant, identity_directory=identity_directory) + authorization_context = collect_user_authorization_context( + session, + user, + account=account, + include_system=True, + ) + tenant_roles = authorization_context.tenant_roles + system_roles = authorization_context.system_roles + groups = authorization_context.groups + effective_scopes = authorization_context.scopes maintenance_mode = saved_maintenance_mode(session) - if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE): + if maintenance_mode.enabled and not scopes_grant(effective_scopes, MAINTENANCE_ACCESS_SCOPE): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=maintenance_response_detail(maintenance_mode), @@ -312,19 +653,66 @@ def login(payload: LoginRequest, request: Request, response: Response, session: account=account, user=user, tenant=tenant, - effective_scopes=me_payload.scopes, + effective_scopes=effective_scopes, auth_method="session", session_id=created.model.id, identity_directory=identity_directory, + tenant_roles=tenant_roles, + system_roles=system_roles, + groups=groups, + function_assignment_ids=authorization_context.function_assignment_ids, + delegation_ids=authorization_context.function_delegation_ids, ).model_dump(), ) +@router.get("/session", response_model=AuthSessionResponse) +def auth_session(request: Request, session: Session = Depends(get_session)): + return _resolve_lightweight_session(request, session) + + +@router.get("/shell", response_model=AuthShellResponse) +def auth_shell(request: Request, session: Session = Depends(get_session)): + return _resolve_shell_auth(request, session) + + +@router.get("/profile", response_model=AuthProfileResponse) +def auth_profile(request: Request, session: Session = Depends(get_session)): + return _profile_response(session, _resolve_auth_context(request, session)) + + +@router.get("/roles", response_model=AuthRolesResponse) +def auth_roles(request: Request, session: Session = Depends(get_session)): + context = _resolve_auth_context(request, session) + authorization_context = collect_user_authorization_context( + session, + context.user, + account=context.account, + include_system=context.auth_session is not None, + ) + return AuthRolesResponse( + roles=_roles_info(authorization_context.tenant_roles) + + _roles_info(authorization_context.system_roles, level="system") + ) + + +@router.get("/groups", response_model=AuthGroupsResponse) +def auth_groups(request: Request, session: Session = Depends(get_session)): + return _groups_response(session, _resolve_auth_context(request, session)) + + @router.get("/me", response_model=MeResponse) def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)): tenant = session.get(Tenant, principal.tenant_id) if tenant is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found") + tenant_roles: list[Role] | None = None + system_roles: list[Role] | None = None + groups: list[Group] | None = None + if principal.role_ids: + tenant_roles, system_roles = _roles_from_principal(session, principal, tenant_id=tenant.id) + if principal.group_ids: + groups = _groups_from_principal(session, principal, tenant_id=tenant.id) return _me_response( session, account=principal.account, @@ -338,33 +726,42 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = include_system=principal.auth_session is not None, include_all_memberships=principal.auth_session is not None, identity_id=principal.principal.identity_id, + tenant_roles=tenant_roles, + system_roles=system_roles, + groups=groups, + function_assignment_ids=tuple(principal.function_assignment_ids), + delegation_ids=tuple(principal.delegation_ids), ) -@router.patch("/profile", response_model=MeResponse) +@router.patch("/profile", response_model=AuthProfileResponse) def update_profile( payload: ProfileUpdateRequest, - principal: ApiPrincipal = Depends(get_api_principal), + request: Request, session: Session = Depends(get_session), ): - if principal.auth_session is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile") + context = _resolve_auth_context(request, session) + _verify_profile_mutation_allowed(request, context) if "display_name" in payload.model_fields_set: - principal.account.display_name = payload.display_name.strip() if payload.display_name else None - session.add(principal.account) + context.account.display_name = payload.display_name.strip() if payload.display_name else None + session.add(context.account) if "tenant_display_name" in payload.model_fields_set: - principal.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None - session.add(principal.user) - next_settings = dict(principal.user.settings or {}) + context.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None + session.add(context.user) + next_settings = dict(context.user.settings or {}) settings_changed = False if {"preferred_language", "enabled_language_codes"}.intersection(payload.model_fields_set): - tenant = session.get(Tenant, principal.tenant_id) - if tenant is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found") system_settings = get_system_settings(session) - system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale) - tenant_enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale) + system_enabled = system_enabled_language_codes( + system_settings.settings, + default_locale=system_settings.default_locale, + ) + tenant_enabled = tenant_enabled_language_codes( + context.tenant.settings, + system_enabled, + default_locale=context.tenant.default_locale, + ) next_i18n = i18n_settings(next_settings) if "preferred_language" in payload.model_fields_set: preferred = normalize_language_code(payload.preferred_language) @@ -394,35 +791,23 @@ def update_profile( next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump() settings_changed = True if settings_changed: - principal.user.settings = next_settings - session.add(principal.user) + context.user.settings = next_settings + session.add(context.user) - audit_from_principal( + audit_event( session, - principal, + tenant_id=context.tenant.id, + user_id=context.user.id, action="profile.updated", object_type="account", - object_id=principal.account.id, + object_id=context.account.id, details={"fields": sorted(payload.model_fields_set)}, ) - tenant = session.get(Tenant, principal.tenant_id) - if tenant is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found") session.commit() - return _me_response( - session, - account=principal.account, - user=principal.user, - tenant=tenant, - auth_method=principal.auth_method, - session_id=principal.session_id, - include_system=True, - include_all_memberships=True, - identity_id=principal.principal.identity_id, - ) + return _profile_response(session, context) -@router.post("/switch-tenant", response_model=MeResponse) +@router.post("/switch-tenant", response_model=AuthShellResponse) def switch_tenant( payload: SwitchTenantRequest, principal: ApiPrincipal = Depends(get_api_principal), @@ -441,14 +826,20 @@ def switch_tenant( if membership is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found") session.commit() - return _me_response( + authorization_context = collect_user_authorization_context( + session, + membership, + account=principal.account, + include_system=True, + ) + return _shell_response( session, account=principal.account, user=membership, tenant=tenant, + scopes=authorization_context.scopes, auth_method="session", - session_id=principal.session_id, - identity_id=principal.principal.identity_id, + auth_session=principal.auth_session, ) diff --git a/src/govoplan_access/backend/api/v1/routes.py b/src/govoplan_access/backend/api/v1/routes.py index a45aefc..70d9edb 100644 --- a/src/govoplan_access/backend/api/v1/routes.py +++ b/src/govoplan_access/backend/api/v1/routes.py @@ -41,15 +41,23 @@ from govoplan_access.backend.admin.service import ( update_system_role, ) from govoplan_access.backend.api.v1.admin_common import ( + _accounts_by_id, + _accounts_by_user_id, _api_key_item, + _group_member_ids_by_group_id, _group_summary, + _groups_by_user_id, _http_admin_error, _require_permission, _require_system_role_assignment, _resolve_tenant, + _roles_by_group_id, + _roles_by_user_id, _role_summary, _set_system_memberships, + _system_role_assignment_counts, _system_account_item, + _tenant_role_assignment_counts, _user_item, ) from govoplan_access.backend.api.v1.admin_schemas import ( @@ -213,6 +221,45 @@ ACCESS_FUNCTION_DELEGATIONS_COLLECTION = "access.admin.function_delegations" ACCESS_SYSTEM_ROLES_COLLECTION = "access.admin.system_roles" ACCESS_SYSTEM_ACCOUNTS_COLLECTION = "access.admin.system_accounts" ACCESS_API_KEYS_COLLECTION = "access.admin.api_keys" +ACCESS_FULL_CURSOR_PREFIX = "full:" + + +def _page_query(query, *, page: int, page_size: int): + total = query.order_by(None).count() + pages = max(1, (total + page_size - 1) // page_size) + items = query.offset((page - 1) * page_size).limit(page_size).all() + return items, {"total": total, "page": page, "page_size": page_size, "pages": pages} + + +def _encode_full_delta_cursor(scope: str, *, page: int, snapshot_sequence: int) -> str: + return f"{ACCESS_FULL_CURSOR_PREFIX}{scope}:{int(page)}:{int(snapshot_sequence)}" + + +def _decode_full_delta_cursor(value: str | None, *, scope: str) -> tuple[int, int] | None: + if not value or not value.startswith(ACCESS_FULL_CURSOR_PREFIX): + return None + parts = value.split(":", 3) + if len(parts) != 4 or parts[1] != scope: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid full snapshot cursor") + try: + page = int(parts[2]) + snapshot_sequence = int(parts[3]) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid full snapshot cursor") from exc + if page < 1 or snapshot_sequence < 0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid full snapshot cursor") + return page, snapshot_sequence + + +def _full_delta_page(query, *, page: int, page_size: int, scope: str, snapshot_sequence: int): + items, pagination = _page_query(query, page=page, page_size=page_size) + has_more = page < pagination["pages"] + watermark = ( + _encode_full_delta_cursor(scope, page=page + 1, snapshot_sequence=snapshot_sequence) + if has_more + else encode_sequence_watermark(snapshot_sequence) + ) + return items, pagination, watermark, has_more def _access_delta_watermark(session: Session, tenant_id: str | None, collections: tuple[str, ...]) -> str: @@ -318,14 +365,35 @@ def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {', '.join(scopes)}") -def _identity_item(session: Session, identity: Identity) -> IdentityAdminItem: +def _identity_account_links_by_identity(session: Session, identities: list[Identity]) -> dict[str, list[tuple[IdentityAccountLink, Account]]]: + identity_ids = [identity.id for identity in identities] + if not identity_ids: + return {} + grouped: dict[str, list[tuple[IdentityAccountLink, Account]]] = {} rows = ( session.query(IdentityAccountLink, Account) .join(Account, Account.id == IdentityAccountLink.account_id) - .filter(IdentityAccountLink.identity_id == identity.id) - .order_by(IdentityAccountLink.is_primary.desc(), Account.email.asc()) + .filter(IdentityAccountLink.identity_id.in_(identity_ids)) + .order_by(IdentityAccountLink.identity_id.asc(), IdentityAccountLink.is_primary.desc(), Account.email.asc()) .all() ) + for link, account in rows: + grouped.setdefault(link.identity_id, []).append((link, account)) + return grouped + + +def _identity_item(session: Session, identity: Identity, *, account_links_by_identity: dict[str, list[tuple[IdentityAccountLink, Account]]] | None = None) -> IdentityAdminItem: + rows = ( + account_links_by_identity.get(identity.id, []) + if account_links_by_identity is not None + else ( + session.query(IdentityAccountLink, Account) + .join(Account, Account.id == IdentityAccountLink.account_id) + .filter(IdentityAccountLink.identity_id == identity.id) + .order_by(IdentityAccountLink.is_primary.desc(), Account.email.asc()) + .all() + ) + ) return IdentityAdminItem( id=identity.id, display_name=identity.display_name, @@ -1073,12 +1141,16 @@ def approve_configuration_change_request_endpoint( @router.get("/identities", response_model=IdentityListResponse) def list_identities( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "access:account:read")), ): del principal - identities = session.query(Identity).order_by(Identity.display_name.asc(), Identity.created_at.asc()).all() - return IdentityListResponse(identities=[_identity_item(session, item) for item in identities]) + query = session.query(Identity).order_by(Identity.display_name.asc(), Identity.created_at.asc()) + identities, pagination = _page_query(query, page=page, page_size=page_size) + account_links_by_identity = _identity_account_links_by_identity(session, identities) + return IdentityListResponse(identities=[_identity_item(session, item, account_links_by_identity=account_links_by_identity) for item in identities], **pagination) @router.post("/identities", response_model=IdentityAdminItem, status_code=status.HTTP_201_CREATED) @@ -1952,24 +2024,67 @@ def revoke_function_delegation( return None -def _full_users_delta_response(session: Session, tenant: Tenant) -> UserListDeltaResponse: - users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all() +def _user_items_for_response(session: Session, tenant: Tenant, users: list[User]) -> list[UserAdminItem]: + user_ids = [user.id for user in users] + accounts_by_id = _accounts_by_id(session, [user.account_id for user in users]) + groups_by_user = _groups_by_user_id(session, tenant_id=tenant.id, user_ids=user_ids) + roles_by_user = _roles_by_user_id(session, tenant_id=tenant.id, user_ids=user_ids) + group_ids = sorted({group.id for groups in groups_by_user.values() for group in groups}) + group_member_ids_by_group = _group_member_ids_by_group_id(session, tenant_id=tenant.id, group_ids=group_ids) + group_roles_by_group = _roles_by_group_id(session, tenant_id=tenant.id, group_ids=group_ids) + role_ids = sorted( + { + role.id + for roles in roles_by_user.values() + for role in roles + } + | { + role.id + for roles in group_roles_by_group.values() + for role in roles + } + ) + tenant_role_counts = _tenant_role_assignment_counts(session, role_ids) owner_ids = tenant_owner_user_ids(session, tenant.id) idm_directory = _optional_idm_directory() organization_directory = _optional_organization_directory() + return [ + _user_item_for_response( + session, + user, + owner_ids=owner_ids, + idm_directory=idm_directory, + organization_directory=organization_directory, + accounts_by_id=accounts_by_id, + groups_by_user=groups_by_user, + roles_by_user=roles_by_user, + group_member_ids_by_group=group_member_ids_by_group, + group_roles_by_group=group_roles_by_group, + tenant_role_assignment_counts=tenant_role_counts, + ) + for user in users + ] + + +def _full_users_delta_response(session: Session, tenant: Tenant, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> UserListDeltaResponse: + snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_USERS_COLLECTION,)) + page = cursor[0] if cursor is not None else 1 + query = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc(), User.id.asc()) + users, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="users", snapshot_sequence=snapshot_sequence) return UserListDeltaResponse( - users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in users], + users=_user_items_for_response(session, tenant, users), deleted=[], - watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)), - has_more=False, + watermark=watermark, + has_more=has_more, full=True, + **pagination, ) def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> UserListDeltaResponse: entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), since=since, limit=limit) if entries is None: - return _full_users_delta_response(session, tenant) + return _full_users_delta_response(session, tenant, limit=limit) changed_ids = _changed_ids(entries, "access_user") visible = { user.id: user @@ -1978,16 +2093,13 @@ def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit if changed_ids else [] ) } - owner_ids = tenant_owner_user_ids(session, tenant.id) - idm_directory = _optional_idm_directory() - organization_directory = _optional_organization_directory() deleted = [ _delta_deleted_item(entry) for entry in entries if entry.resource_type == "access_user" and entry.resource_id not in visible ] return UserListDeltaResponse( - users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in visible.values()], + users=_user_items_for_response(session, tenant, list(visible.values())), deleted=deleted, watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more), has_more=has_more, @@ -2002,6 +2114,12 @@ def _user_item_for_response( owner_ids: set[str] | None = None, idm_directory: IdmDirectory | None = None, 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: idm_assignments = _idm_assignments_for_user(idm_directory, user) return _user_item( @@ -2010,6 +2128,12 @@ def _user_item_for_response( owner_ids=owner_ids, idm_assignments=idm_assignments, organization_directory=organization_directory, + accounts_by_id=accounts_by_id, + groups_by_user=groups_by_user, + roles_by_user=roles_by_user, + group_member_ids_by_group=group_member_ids_by_group, + group_roles_by_group=group_roles_by_group, + tenant_role_assignment_counts=tenant_role_assignment_counts, ) @@ -2022,23 +2146,27 @@ def list_users_delta( principal: ApiPrincipal = Depends(require_scope("admin:users:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) - if since is None: - return _full_users_delta_response(session, tenant) + full_cursor = _decode_full_delta_cursor(since, scope="users") + if since is None or full_cursor is not None: + return _full_users_delta_response(session, tenant, cursor=full_cursor, limit=limit) return _users_delta_response(session, tenant, since=since, limit=limit) @router.get("/users", response_model=UserListResponse) def list_users( tenant_id: str | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:users:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) - users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all() - owner_ids = tenant_owner_user_ids(session, tenant.id) - idm_directory = _optional_idm_directory() - organization_directory = _optional_organization_directory() - return UserListResponse(users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in users]) + query = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()) + users, pagination = _page_query(query, page=page, page_size=page_size) + return UserListResponse( + users=_user_items_for_response(session, tenant, users), + **pagination, + ) @router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse) @@ -2198,14 +2326,7 @@ def create_user( ) -@router.patch("/users/{user_id}", response_model=UserAdminItem) -def update_user( - user_id: str, - payload: UserUpdateRequest, - tenant_id: str | None = Query(default=None), - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -): +def _require_user_update_permissions(principal: ApiPrincipal, payload: UserUpdateRequest) -> None: if "display_name" in payload.model_fields_set: _require_permission(principal, "admin:users:update") if payload.is_active is not None: @@ -2214,10 +2335,16 @@ def update_user( _require_permission(principal, "admin:groups:manage_members") if payload.role_ids is not None: _require_permission(principal, "admin:roles:assign") - tenant = _resolve_tenant(session, principal, tenant_id) - user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none() - if user is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + +def _apply_user_update( + session: Session, + *, + tenant: Tenant, + user: User, + payload: UserUpdateRequest, + principal: ApiPrincipal, +) -> tuple[set[str], set[str]]: previous_group_ids = _current_user_group_ids(session, user) if payload.group_ids is not None else set() previous_role_ids = _current_user_role_ids(session, user) if payload.role_ids is not None else set() try: @@ -2234,6 +2361,19 @@ def update_user( assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + return previous_group_ids, previous_role_ids + + +def _record_user_update_changes( + session: Session, + *, + tenant: Tenant, + user: User, + payload: UserUpdateRequest, + principal: ApiPrincipal, + previous_group_ids: set[str], + previous_role_ids: set[str], +) -> None: _record_access_change( session, collection=ACCESS_USERS_COLLECTION, @@ -2263,6 +2403,37 @@ def update_user( tenant_id=tenant.id, principal=principal, ) + + +@router.patch("/users/{user_id}", response_model=UserAdminItem) +def update_user( + user_id: str, + payload: UserUpdateRequest, + tenant_id: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +): + _require_user_update_permissions(principal, payload) + tenant = _resolve_tenant(session, principal, tenant_id) + user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none() + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + previous_group_ids, previous_role_ids = _apply_user_update( + session, + tenant=tenant, + user=user, + payload=payload, + principal=principal, + ) + _record_user_update_changes( + session, + tenant=tenant, + user=user, + payload=payload, + principal=principal, + previous_group_ids=previous_group_ids, + previous_role_ids=previous_role_ids, + ) audit_event( session, tenant_id=tenant.id, @@ -2281,21 +2452,43 @@ def update_user( ) -def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse: - groups = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()).all() +def _group_summaries_for_response(session: Session, tenant: Tenant, groups: list[Group]) -> list[GroupSummary]: + group_ids = [group.id for group in groups] + member_ids_by_group = _group_member_ids_by_group_id(session, tenant_id=tenant.id, group_ids=group_ids) + roles_by_group = _roles_by_group_id(session, tenant_id=tenant.id, group_ids=group_ids) + role_ids = sorted({role.id for roles in roles_by_group.values() for role in roles}) + tenant_role_counts = _tenant_role_assignment_counts(session, role_ids) + return [ + _group_summary( + session, + group, + member_ids_by_group=member_ids_by_group, + roles_by_group=roles_by_group, + tenant_role_assignment_counts=tenant_role_counts, + ) + for group in groups + ] + + +def _full_groups_delta_response(session: Session, tenant: Tenant, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> GroupListDeltaResponse: + snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_GROUPS_COLLECTION,)) + page = cursor[0] if cursor is not None else 1 + query = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc(), Group.id.asc()) + groups, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="groups", snapshot_sequence=snapshot_sequence) return GroupListDeltaResponse( - groups=[_group_summary(session, group) for group in groups], + groups=_group_summaries_for_response(session, tenant, groups), deleted=[], - watermark=_access_delta_watermark(session, tenant.id, (ACCESS_GROUPS_COLLECTION,)), - has_more=False, + watermark=watermark, + has_more=has_more, full=True, + **pagination, ) def _groups_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> GroupListDeltaResponse: entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), since=since, limit=limit) if entries is None: - return _full_groups_delta_response(session, tenant) + return _full_groups_delta_response(session, tenant, limit=limit) changed_ids = _changed_ids(entries, "access_group") visible = { group.id: group @@ -2310,7 +2503,7 @@ def _groups_delta_response(session: Session, tenant: Tenant, *, since: str, limi if entry.resource_type == "access_group" and entry.resource_id not in visible ] return GroupListDeltaResponse( - groups=[_group_summary(session, group) for group in visible.values()], + groups=_group_summaries_for_response(session, tenant, list(visible.values())), deleted=deleted, watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), entries=entries, has_more=has_more), has_more=has_more, @@ -2327,20 +2520,27 @@ def list_groups_delta( principal: ApiPrincipal = Depends(require_scope("admin:groups:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) - if since is None: - return _full_groups_delta_response(session, tenant) + full_cursor = _decode_full_delta_cursor(since, scope="groups") + if since is None or full_cursor is not None: + return _full_groups_delta_response(session, tenant, cursor=full_cursor, limit=limit) return _groups_delta_response(session, tenant, since=since, limit=limit) @router.get("/groups", response_model=GroupListResponse) def list_groups( tenant_id: str | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:groups:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) - groups = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()).all() - return GroupListResponse(groups=[_group_summary(session, group) for group in groups]) + query = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()) + groups, pagination = _page_query(query, page=page, page_size=page_size) + return GroupListResponse( + groups=_group_summaries_for_response(session, tenant, groups), + **pagination, + ) @router.post("/groups", response_model=GroupSummary, status_code=status.HTTP_201_CREATED) @@ -2419,24 +2619,23 @@ def create_group( return _group_summary(session, group) -@router.patch("/groups/{group_id}", response_model=GroupSummary) -def update_group( - group_id: str, - payload: GroupUpdateRequest, - tenant_id: str | None = Query(default=None), - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -): +def _require_group_update_permissions(principal: ApiPrincipal, payload: GroupUpdateRequest) -> None: if any(field in payload.model_fields_set for field in ("name", "description", "is_active")): _require_permission(principal, "admin:groups:write") if payload.member_ids is not None: _require_permission(principal, "admin:groups:manage_members") if payload.role_ids is not None: _require_permission(principal, "admin:roles:assign") - tenant = _resolve_tenant(session, principal, tenant_id) - group = session.query(Group).filter(Group.id == group_id, Group.tenant_id == tenant.id).one_or_none() - if group is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") + + +def _apply_group_update( + session: Session, + *, + tenant: Tenant, + group: Group, + payload: GroupUpdateRequest, + principal: ApiPrincipal, +) -> tuple[set[str], set[str]]: previous_member_ids = _current_group_member_ids(session, group) if payload.member_ids is not None else set() previous_role_ids = _current_group_role_ids(session, group) if payload.role_ids is not None else set() try: @@ -2456,6 +2655,19 @@ def update_group( assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + return previous_member_ids, previous_role_ids + + +def _record_group_update_changes( + session: Session, + *, + tenant: Tenant, + group: Group, + payload: GroupUpdateRequest, + principal: ApiPrincipal, + previous_member_ids: set[str], + previous_role_ids: set[str], +) -> None: _record_access_change( session, collection=ACCESS_GROUPS_COLLECTION, @@ -2485,6 +2697,37 @@ def update_group( tenant_id=tenant.id, principal=principal, ) + + +@router.patch("/groups/{group_id}", response_model=GroupSummary) +def update_group( + group_id: str, + payload: GroupUpdateRequest, + tenant_id: str | None = Query(default=None), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +): + _require_group_update_permissions(principal, payload) + tenant = _resolve_tenant(session, principal, tenant_id) + group = session.query(Group).filter(Group.id == group_id, Group.tenant_id == tenant.id).one_or_none() + if group is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") + previous_member_ids, previous_role_ids = _apply_group_update( + session, + tenant=tenant, + group=group, + payload=payload, + principal=principal, + ) + _record_group_update_changes( + session, + tenant=tenant, + group=group, + payload=payload, + principal=principal, + previous_member_ids=previous_member_ids, + previous_role_ids=previous_role_ids, + ) audit_event( session, tenant_id=tenant.id, @@ -2498,23 +2741,32 @@ def update_group( return _group_summary(session, group) -def _full_roles_delta_response(session: Session, tenant: Tenant) -> RoleListDeltaResponse: +def _tenant_role_summaries_for_response(session: Session, roles: list[Role]) -> list[RoleSummary]: + tenant_role_counts = _tenant_role_assignment_counts(session, [role.id for role in roles]) + return [_role_summary(session, role, tenant_role_assignment_counts=tenant_role_counts) for role in roles] + + +def _full_roles_delta_response(session: Session, tenant: Tenant, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse: ensure_default_roles(session, tenant) session.commit() - roles = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc()).all() + snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_ROLES_COLLECTION,)) + page = cursor[0] if cursor is not None else 1 + query = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc(), Role.id.asc()) + roles, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="roles", snapshot_sequence=snapshot_sequence) return RoleListDeltaResponse( - roles=[_role_summary(session, role) for role in roles], + roles=_tenant_role_summaries_for_response(session, roles), deleted=[], - watermark=_access_delta_watermark(session, tenant.id, (ACCESS_ROLES_COLLECTION,)), - has_more=False, + watermark=watermark, + has_more=has_more, full=True, + **pagination, ) def _roles_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> RoleListDeltaResponse: entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), since=since, limit=limit) if entries is None: - return _full_roles_delta_response(session, tenant) + return _full_roles_delta_response(session, tenant, limit=limit) changed_ids = _changed_ids(entries, "access_role") visible = { role.id: role @@ -2529,7 +2781,7 @@ def _roles_delta_response(session: Session, tenant: Tenant, *, since: str, limit if entry.resource_type == "access_role" and entry.resource_id not in visible ] return RoleListDeltaResponse( - roles=[_role_summary(session, role) for role in visible.values()], + roles=_tenant_role_summaries_for_response(session, list(visible.values())), deleted=deleted, watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), entries=entries, has_more=has_more), has_more=has_more, @@ -2546,22 +2798,29 @@ def list_roles_delta( principal: ApiPrincipal = Depends(require_scope("admin:roles:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) - if since is None: - return _full_roles_delta_response(session, tenant) + full_cursor = _decode_full_delta_cursor(since, scope="roles") + if since is None or full_cursor is not None: + return _full_roles_delta_response(session, tenant, cursor=full_cursor, limit=limit) return _roles_delta_response(session, tenant, since=since, limit=limit) @router.get("/roles", response_model=RoleListResponse) def list_roles( tenant_id: str | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:roles:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) ensure_default_roles(session, tenant) session.commit() - roles = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc()).all() - return RoleListResponse(roles=[_role_summary(session, role) for role in roles]) + query = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc()) + roles, pagination = _page_query(query, page=page, page_size=page_size) + return RoleListResponse( + roles=_tenant_role_summaries_for_response(session, roles), + **pagination, + ) @router.post("/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED) @@ -2718,23 +2977,32 @@ def delete_role( return None -def _full_system_roles_delta_response(session: Session) -> RoleListDeltaResponse: +def _system_role_summaries_for_response(session: Session, roles: list[Role]) -> list[RoleSummary]: + system_role_counts = _system_role_assignment_counts(session, [role.id for role in roles]) + return [_role_summary(session, role, system_role_assignment_counts=system_role_counts) for role in roles] + + +def _full_system_roles_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse: ensure_default_roles(session, None) session.commit() - roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() + snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=None, module_id=ACCESS_MODULE_ID, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,)) + page = cursor[0] if cursor is not None else 1 + query = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc(), Role.id.asc()) + roles, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-roles", snapshot_sequence=snapshot_sequence) return RoleListDeltaResponse( - roles=[_role_summary(session, role) for role in roles], + roles=_system_role_summaries_for_response(session, roles), deleted=[], - watermark=_access_delta_watermark(session, None, (ACCESS_SYSTEM_ROLES_COLLECTION,)), - has_more=False, + watermark=watermark, + has_more=has_more, full=True, + **pagination, ) def _system_roles_delta_response(session: Session, *, since: str, limit: int) -> RoleListDeltaResponse: entries, has_more = _access_delta_entries(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), since=since, limit=limit) if entries is None: - return _full_system_roles_delta_response(session) + return _full_system_roles_delta_response(session, limit=limit) changed_ids = _changed_ids(entries, "access_system_role") visible = { role.id: role @@ -2749,7 +3017,7 @@ def _system_roles_delta_response(session: Session, *, since: str, limit: int) -> if entry.resource_type == "access_system_role" and entry.resource_id not in visible ] return RoleListDeltaResponse( - roles=[_role_summary(session, role) for role in visible.values()], + roles=_system_role_summaries_for_response(session, list(visible.values())), deleted=deleted, watermark=_access_delta_response_watermark(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), entries=entries, has_more=has_more), has_more=has_more, @@ -2765,20 +3033,27 @@ def list_system_roles_delta( principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")), ): del principal - if since is None: - return _full_system_roles_delta_response(session) + full_cursor = _decode_full_delta_cursor(since, scope="system-roles") + if since is None or full_cursor is not None: + return _full_system_roles_delta_response(session, cursor=full_cursor, limit=limit) return _system_roles_delta_response(session, since=since, limit=limit) @router.get("/system/roles", response_model=RoleListResponse) def list_system_roles( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")), ): ensure_default_roles(session, None) session.commit() - roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() - return RoleListResponse(roles=[_role_summary(session, role) for role in roles]) + query = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()) + roles, pagination = _page_query(query, page=page, page_size=page_size) + return RoleListResponse( + roles=_system_role_summaries_for_response(session, roles), + **pagination, + ) @router.post("/system/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED) @@ -2901,25 +3176,29 @@ def delete_system_role_endpoint( _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_SYSTEM_ROLES_COLLECTION) -def _full_system_accounts_delta_response(session: Session) -> SystemAccountListDeltaResponse: +def _full_system_accounts_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> SystemAccountListDeltaResponse: ensure_default_roles(session, None) session.commit() system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() - accounts = session.query(Account).order_by(Account.email.asc()).all() + snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=None, module_id=ACCESS_MODULE_ID, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS) + page = cursor[0] if cursor is not None else 1 + query = session.query(Account).order_by(Account.email.asc(), Account.id.asc()) + accounts, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-accounts", snapshot_sequence=snapshot_sequence) return SystemAccountListDeltaResponse( accounts=[_system_account_item(session, account) for account in accounts], - roles=[_role_summary(session, role) for role in system_roles], + roles=_system_role_summaries_for_response(session, system_roles), deleted=[], - watermark=_access_delta_watermark(session, None, _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS), - has_more=False, + watermark=watermark, + has_more=has_more, full=True, + **pagination, ) def _system_accounts_delta_response(session: Session, *, since: str, limit: int) -> SystemAccountListDeltaResponse: entries, has_more = _access_delta_entries(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, since=since, limit=limit) if entries is None: - return _full_system_accounts_delta_response(session) + return _full_system_accounts_delta_response(session, limit=limit) account_ids = _changed_ids(entries, "access_system_account") role_ids = _changed_ids(entries, "access_system_role") visible_accounts = { @@ -2946,7 +3225,7 @@ def _system_accounts_delta_response(session: Session, *, since: str, limit: int) ] return SystemAccountListDeltaResponse( accounts=[_system_account_item(session, account) for account in visible_accounts.values()], - roles=[_role_summary(session, role) for role in visible_roles.values()], + roles=_system_role_summaries_for_response(session, list(visible_roles.values())), deleted=deleted, watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more), has_more=has_more, @@ -2962,42 +3241,58 @@ def list_system_accounts_delta( principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")), ): del principal - if since is None: - return _full_system_accounts_delta_response(session) + full_cursor = _decode_full_delta_cursor(since, scope="system-accounts") + if since is None or full_cursor is not None: + return _full_system_accounts_delta_response(session, cursor=full_cursor, limit=limit) return _system_accounts_delta_response(session, since=since, limit=limit) @router.get("/system/accounts", response_model=SystemAccountListResponse) def list_system_accounts( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")), ): ensure_default_roles(session, None) session.commit() system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() - accounts = session.query(Account).order_by(Account.email.asc()).all() + query = session.query(Account).order_by(Account.email.asc()) + accounts, pagination = _page_query(query, page=page, page_size=page_size) return SystemAccountListResponse( accounts=[_system_account_item(session, account) for account in accounts], - roles=[_role_summary(session, role) for role in system_roles], + roles=_system_role_summaries_for_response(session, system_roles), + **pagination, ) -@router.patch("/system/accounts/{account_id}", response_model=SystemAccountItem) -def update_system_account( - account_id: str, - payload: SystemAccountUpdateRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(get_api_principal), -): +def _require_system_account_update_permissions(principal: ApiPrincipal, payload: SystemAccountUpdateRequest) -> None: if "display_name" in payload.model_fields_set and not has_scope(principal, "system:accounts:update"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:update") if payload.is_active is not None and not has_scope(principal, "system:accounts:suspend"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:suspend") if payload.role_ids is not None: _require_system_role_assignment(principal) - account = session.get(Account, account_id) - if account is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + + +def _active_tenant_ids_for_account(session: Session, account: Account) -> list[str]: + return [ + row[0] + for row in session.query(User.tenant_id) + .join(Tenant, Tenant.id == User.tenant_id) + .filter(User.account_id == account.id, User.is_active.is_(True), Tenant.is_active.is_(True)) + .distinct() + .all() + ] + + +def _apply_system_account_update( + session: Session, + *, + account: Account, + payload: SystemAccountUpdateRequest, + principal: ApiPrincipal, +) -> set[str]: previous_role_ids = _current_system_role_ids(session, account) if payload.role_ids is not None else set() if "display_name" in payload.model_fields_set: account.display_name = payload.display_name.strip() if payload.display_name else None @@ -3013,18 +3308,21 @@ def update_system_account( session.flush() assert_system_owner_exists(session) if not account.is_active: - tenant_ids = [ - row[0] - for row in session.query(User.tenant_id) - .join(Tenant, Tenant.id == User.tenant_id) - .filter(User.account_id == account.id, User.is_active.is_(True), Tenant.is_active.is_(True)) - .distinct() - .all() - ] - for tenant_id in tenant_ids: + for tenant_id in _active_tenant_ids_for_account(session, account): assert_tenant_owner_exists(session, tenant_id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc + return previous_role_ids + + +def _record_system_account_update_changes( + session: Session, + *, + account: Account, + payload: SystemAccountUpdateRequest, + principal: ApiPrincipal, + previous_role_ids: set[str], +) -> None: _record_access_change( session, collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, @@ -3044,6 +3342,27 @@ def update_system_account( tenant_id=None, principal=principal, ) + + +@router.patch("/system/accounts/{account_id}", response_model=SystemAccountItem) +def update_system_account( + account_id: str, + payload: SystemAccountUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +): + _require_system_account_update_permissions(principal, payload) + account = session.get(Account, account_id) + if account is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") + previous_role_ids = _apply_system_account_update(session, account=account, payload=payload, principal=principal) + _record_system_account_update_changes( + session, + account=account, + payload=payload, + principal=principal, + previous_role_ids=previous_role_ids, + ) audit_from_principal( session, principal, @@ -3266,24 +3585,38 @@ def update_system_account_memberships( return _system_account_item(session, account) -def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool) -> ApiKeyListDeltaResponse: +def _api_key_items_for_response(session: Session, keys: list[ApiKey]) -> list[ApiKeyAdminItem]: + accounts_by_user_id = _accounts_by_user_id(session, [item.user_id for item in keys]) + return [_api_key_item(session, item, accounts_by_user_id=accounts_by_user_id) for item in keys] + + +def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, cursor: tuple[int, int] | None = None, limit: int = 500) -> ApiKeyListDeltaResponse: query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) if not include_revoked: query = query.filter(ApiKey.revoked_at.is_(None)) - keys = query.order_by(ApiKey.created_at.desc()).all() + snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_API_KEYS_COLLECTION,)) + page = cursor[0] if cursor is not None else 1 + keys, pagination, watermark, has_more = _full_delta_page( + query.order_by(ApiKey.created_at.desc(), ApiKey.id.asc()), + page=page, + page_size=limit, + scope="api-keys", + snapshot_sequence=snapshot_sequence, + ) return ApiKeyListDeltaResponse( - api_keys=[_api_key_item(session, item) for item in keys], + api_keys=_api_key_items_for_response(session, keys), deleted=[], - watermark=_access_delta_watermark(session, tenant.id, (ACCESS_API_KEYS_COLLECTION,)), - has_more=False, + watermark=watermark, + has_more=has_more, full=True, + **pagination, ) def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, since: str, limit: int) -> ApiKeyListDeltaResponse: entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), since=since, limit=limit) if entries is None: - return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked) + return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, limit=limit) changed_ids = _changed_ids(entries, "access_api_key") query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) if not include_revoked: @@ -3301,7 +3634,7 @@ def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoke if entry.resource_type == "access_api_key" and entry.resource_id not in visible ] return ApiKeyListDeltaResponse( - api_keys=[_api_key_item(session, item) for item in visible.values()], + api_keys=_api_key_items_for_response(session, list(visible.values())), deleted=deleted, watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), entries=entries, has_more=has_more), has_more=has_more, @@ -3319,8 +3652,9 @@ def list_api_keys_delta( principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) - if since is None: - return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked) + full_cursor = _decode_full_delta_cursor(since, scope="api-keys") + if since is None or full_cursor is not None: + return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, cursor=full_cursor, limit=limit) return _api_keys_delta_response(session, tenant, include_revoked=include_revoked, since=since, limit=limit) @@ -3328,6 +3662,8 @@ def list_api_keys_delta( def list_api_keys( tenant_id: str | None = Query(default=None), include_revoked: bool = Query(default=False), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")), ): @@ -3335,8 +3671,8 @@ def list_api_keys( query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) if not include_revoked: query = query.filter(ApiKey.revoked_at.is_(None)) - keys = query.order_by(ApiKey.created_at.desc()).all() - return ApiKeyListResponse(api_keys=[_api_key_item(session, item) for item in keys]) + keys, pagination = _page_query(query.order_by(ApiKey.created_at.desc()), page=page, page_size=page_size) + return ApiKeyListResponse(api_keys=_api_key_items_for_response(session, keys), **pagination) @router.post("/api-keys", response_model=AdminApiKeyCreateResponse, status_code=status.HTTP_201_CREATED) diff --git a/src/govoplan_access/backend/auth/dependencies.py b/src/govoplan_access/backend/auth/dependencies.py index 221b126..d21f0c2 100644 --- a/src/govoplan_access/backend/auth/dependencies.py +++ b/src/govoplan_access/backend/auth/dependencies.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass + from fastapi import Depends, Header, HTTPException, Request, status from sqlalchemy.orm import Session @@ -21,20 +23,29 @@ from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode from govoplan_core.db.session import get_database, get_session from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User -from govoplan_access.backend.semantic import collect_external_function_roles, collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account +from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account from govoplan_access.backend.security.api_keys import authenticate_api_key from govoplan_access.backend.security.sessions import ( authenticate_session_token, - collect_user_groups, - collect_user_roles, - collect_user_scopes, + collect_user_authorization_context, + UserAuthorizationContext, verify_auth_session_csrf, ) from govoplan_core.security.module_permissions import scopes_grant_compatible -from govoplan_access.backend.permissions.catalog import expand_scopes, intersect_api_key_scopes +from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes from govoplan_core.settings import settings +@dataclass(slots=True) +class ResolvedPrincipalContext: + principal: PrincipalRef + account: Account + user: User + tenant: Tenant + api_key: ApiKey | None = None + auth_session: AuthSession | None = None + + def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]: if x_api_key: return x_api_key.strip(), "api_key" @@ -50,10 +61,6 @@ def _requires_csrf(request: Request) -> bool: return request.method.upper() not in {"GET", "HEAD", "OPTIONS", "TRACE"} -def _principal_group_ids(session: Session, user: User) -> frozenset[str]: - return frozenset(group.id for group in collect_user_groups(session, user)) - - def _build_principal_ref( session: Session, *, @@ -67,22 +74,32 @@ def _build_principal_ref( idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), identity_directory: IdentityDirectory | None = None, extra_roles: tuple[Role, ...] = (), + authorization_context: UserAuthorizationContext | None = None, + include_system_roles: bool = False, ) -> PrincipalRef: - function_assignment_ids = list(collect_function_assignment_ids(session, user)) + if authorization_context is None: + authorization_context = collect_user_authorization_context( + session, + user, + account=account, + include_system=include_system_roles, + extra_roles=extra_roles, + ) + function_assignment_ids = list(authorization_context.function_assignment_ids) function_assignment_ids.extend(item.id for item in idm_assignments) - roles = collect_user_roles(session, user) - role_ids = [role.id for role in roles] - role_ids.extend(role.id for role in extra_roles) + role_ids = [role.id for role in authorization_context.tenant_roles] + if include_system_roles: + role_ids.extend(role.id for role in authorization_context.system_roles) return PrincipalRef( account_id=account.id, membership_id=user.id, tenant_id=tenant_id, identity_id=identity_id_for_account(session, account.id, identity_directory=identity_directory), scopes=frozenset(scopes), - group_ids=_principal_group_ids(session, user), + group_ids=frozenset(group.id for group in authorization_context.groups), role_ids=frozenset(sorted(dict.fromkeys(role_ids))), function_assignment_ids=frozenset(sorted(dict.fromkeys(function_assignment_ids))), - delegation_ids=frozenset(collect_function_delegation_ids(session, user)), + delegation_ids=frozenset(authorization_context.function_delegation_ids), auth_method=auth_method, # type: ignore[arg-type] api_key_id=api_key.id if api_key else None, session_id=auth_session.id if auth_session else None, @@ -124,6 +141,21 @@ def _api_principal_from_ref( ) +def _api_principal_from_context( + context: ResolvedPrincipalContext, + *, + permission_evaluator: PermissionEvaluator | None = None, +) -> ApiPrincipal: + return ApiPrincipal( + principal=context.principal, + account=context.account, + user=context.user, + api_key=context.api_key, + auth_session=context.auth_session, + permission_evaluator=permission_evaluator, + ) + + def _resolve_legacy_principal_ref( request: Request, session: Session, @@ -134,76 +166,232 @@ def _resolve_legacy_principal_ref( identity_directory: IdentityDirectory | None = None, organization_directory: OrganizationDirectory | None = None, ) -> PrincipalRef: + return _resolve_legacy_principal_context( + request, + session, + authorization=authorization, + x_api_key=x_api_key, + idm_directory=idm_directory, + identity_directory=identity_directory, + organization_directory=organization_directory, + ).principal + + +def _resolve_legacy_principal_context( + request: Request, + session: Session, + *, + authorization: str | None, + x_api_key: str | None, + idm_directory: IdmDirectory | None = None, + identity_directory: IdentityDirectory | None = None, + organization_directory: OrganizationDirectory | None = None, +) -> ResolvedPrincipalContext: token, source = _extract_token(request, authorization, x_api_key) if not token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token") + if source != "cookie": + context = _resolve_api_key_principal_context( + session, + token=token, + idm_directory=idm_directory, + identity_directory=identity_directory, + organization_directory=organization_directory, + ) + if context is not None: + return context + + context = _resolve_session_principal_context( + request, + session, + token=token, + source=source, + idm_directory=idm_directory, + identity_directory=identity_directory, + organization_directory=organization_directory, + ) + if context is not None: + return context + + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token") + + +def _resolve_api_key_principal_ref( + session: Session, + *, + token: str, + idm_directory: IdmDirectory | None, + identity_directory: IdentityDirectory | None, + organization_directory: OrganizationDirectory | None, +) -> PrincipalRef | None: + context = _resolve_api_key_principal_context( + session, + token=token, + idm_directory=idm_directory, + identity_directory=identity_directory, + organization_directory=organization_directory, + ) + return context.principal if context is not None else None + + +def _resolve_api_key_principal_context( + session: Session, + *, + token: str, + idm_directory: IdmDirectory | None, + identity_directory: IdentityDirectory | None, + organization_directory: OrganizationDirectory | None, +) -> ResolvedPrincipalContext | None: # API keys remain supported for CLI/automation. Their permissions are the # intersection of the key grant and the owner's current tenant roles. api_key = authenticate_api_key(session, token) - if api_key: - user = session.get(User, api_key.user_id) - account = session.get(Account, user.account_id) if user else None - tenant = session.get(Tenant, api_key.tenant_id) - if ( - not user or not account or not tenant - or not user.is_active or not account.is_active or not tenant.is_active - or user.tenant_id != api_key.tenant_id - ): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal") - idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=api_key.tenant_id) - idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory)) - user_scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=False), idm_roles) - effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or []) - session.commit() - return _build_principal_ref( - session, - api_key=api_key, - account=account, - user=user, - tenant_id=api_key.tenant_id, - scopes=effective_scopes, - auth_method="api_key", - idm_assignments=idm_assignments, - identity_directory=identity_directory, - extra_roles=idm_roles, - ) + if api_key is None: + return None + user = session.get(User, api_key.user_id) + account = session.get(Account, user.account_id) if user else None + tenant = session.get(Tenant, api_key.tenant_id) + if ( + not user or not account or not tenant + or not user.is_active or not account.is_active or not tenant.is_active + or user.tenant_id != api_key.tenant_id + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal") + idm_assignments, idm_roles = _principal_idm_context( + session, + user=user, + account=account, + tenant_id=api_key.tenant_id, + idm_directory=idm_directory, + organization_directory=organization_directory, + ) + authorization_context = collect_user_authorization_context( + session, + user, + account=account, + include_system=False, + extra_roles=idm_roles, + ) + effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or []) + session.commit() + principal = _build_principal_ref( + session, + api_key=api_key, + account=account, + user=user, + tenant_id=api_key.tenant_id, + scopes=effective_scopes, + auth_method="api_key", + idm_assignments=idm_assignments, + identity_directory=identity_directory, + extra_roles=idm_roles, + authorization_context=authorization_context, + ) + return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key) + +def _resolve_session_principal_ref( + request: Request, + session: Session, + *, + token: str, + source: str, + idm_directory: IdmDirectory | None, + identity_directory: IdentityDirectory | None, + organization_directory: OrganizationDirectory | None, +) -> PrincipalRef | None: + context = _resolve_session_principal_context( + request, + session, + token=token, + source=source, + idm_directory=idm_directory, + identity_directory=identity_directory, + organization_directory=organization_directory, + ) + return context.principal if context is not None else None + + +def _resolve_session_principal_context( + request: Request, + session: Session, + *, + token: str, + source: str, + idm_directory: IdmDirectory | None, + identity_directory: IdentityDirectory | None, + organization_directory: OrganizationDirectory | None, +) -> ResolvedPrincipalContext | None: auth_session = authenticate_session_token(session, token) - if auth_session: - user = session.get(User, auth_session.user_id) - account = session.get(Account, auth_session.account_id) - tenant = session.get(Tenant, auth_session.tenant_id) - if ( - not user or not account or not tenant - or not user.is_active or not account.is_active or not tenant.is_active - or user.account_id != account.id - or user.tenant_id != tenant.id - ): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal") - if source == "cookie" and _requires_csrf(request): - header_token = request.headers.get("x-csrf-token") - cookie_token = request.cookies.get(settings.auth_csrf_cookie_name) - if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token") - idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=user.tenant_id) - idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory)) - scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=True), idm_roles) - session.commit() - return _build_principal_ref( - session, - auth_session=auth_session, - account=account, - user=user, - tenant_id=user.tenant_id, - scopes=scopes, - auth_method="session", - idm_assignments=idm_assignments, - identity_directory=identity_directory, - extra_roles=idm_roles, - ) + if auth_session is None: + return None + user = session.get(User, auth_session.user_id) + account = session.get(Account, auth_session.account_id) + tenant = session.get(Tenant, auth_session.tenant_id) + if ( + not user or not account or not tenant + or not user.is_active or not account.is_active or not tenant.is_active + or user.account_id != account.id + or user.tenant_id != tenant.id + ): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal") + if source == "cookie": + _verify_session_csrf(request, auth_session) + idm_assignments, idm_roles = _principal_idm_context( + session, + user=user, + account=account, + tenant_id=user.tenant_id, + idm_directory=idm_directory, + organization_directory=organization_directory, + ) + authorization_context = collect_user_authorization_context( + session, + user, + account=account, + include_system=True, + extra_roles=idm_roles, + ) + scopes = authorization_context.scopes + session.commit() + principal = _build_principal_ref( + session, + auth_session=auth_session, + account=account, + user=user, + tenant_id=user.tenant_id, + scopes=scopes, + auth_method="session", + idm_assignments=idm_assignments, + identity_directory=identity_directory, + extra_roles=idm_roles, + authorization_context=authorization_context, + include_system_roles=True, + ) + return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session) - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token") + +def _verify_session_csrf(request: Request, auth_session: AuthSession) -> None: + if not _requires_csrf(request): + return + header_token = request.headers.get("x-csrf-token") + cookie_token = request.cookies.get(settings.auth_csrf_cookie_name) + if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token") + + +def _principal_idm_context( + session: Session, + *, + user: User, + account: Account, + tenant_id: str, + idm_directory: IdmDirectory | None, + organization_directory: OrganizationDirectory | None, +) -> tuple[tuple[OrganizationFunctionAssignmentRef, ...], tuple[Role, ...]]: + idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=tenant_id) + idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory)) + return idm_assignments, idm_roles def _idm_assignments_for_account( @@ -217,13 +405,6 @@ def _idm_assignments_for_account( return tuple(idm_directory.organization_function_assignments_for_account(account_id, tenant_id=tenant_id)) -def _scopes_with_extra_roles(scopes: list[str], roles: tuple[Role, ...]) -> list[str]: - merged = set(scopes) - for role in roles: - merged.update(role.permissions or []) - return expand_scopes(merged) - - def _registry_from_request(request: Request) -> PlatformRegistry | None: registry = getattr(request.app.state, "govoplan_registry", None) return registry if isinstance(registry, PlatformRegistry) else None @@ -326,19 +507,32 @@ def resolve_api_principal( ) -> ApiPrincipal: resolver = _principal_resolver_from_request(request) permission_evaluator = _permission_evaluator_from_request(request) + if isinstance(resolver, LegacyPrincipalResolver): + context = _resolve_legacy_principal_context( + request, + session, + authorization=authorization, + x_api_key=x_api_key, + idm_directory=resolver._idm_directory, + identity_directory=resolver._identity_directory, + organization_directory=resolver._organization_directory, + ) + api_principal = _api_principal_from_context(context, permission_evaluator=permission_evaluator) + _enforce_maintenance_mode(session, api_principal) + return api_principal if resolver is not None: principal = resolver.resolve_request(request, session=session) api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator) _enforce_maintenance_mode(session, api_principal) return api_principal - principal = _resolve_legacy_principal_ref( + context = _resolve_legacy_principal_context( request, session, authorization=authorization, x_api_key=x_api_key, ) - api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator) + api_principal = _api_principal_from_context(context, permission_evaluator=permission_evaluator) _enforce_maintenance_mode(session, api_principal) return api_principal diff --git a/src/govoplan_access/backend/permissions/catalog.py b/src/govoplan_access/backend/permissions/catalog.py index 04c95a6..a818e0e 100644 --- a/src/govoplan_access/backend/permissions/catalog.py +++ b/src/govoplan_access/backend/permissions/catalog.py @@ -3,71 +3,26 @@ from __future__ import annotations from collections.abc import Iterable, Mapping from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants -from govoplan_access.backend.permissions.evaluator import scopes_grant as _scopes_grant from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate from govoplan_core.security.module_permissions import compatible_required_scopes +from govoplan_core.security.permissions import ALL_PERMISSIONS as CORE_LEGACY_PERMISSION_DEFINITIONS +from govoplan_core.security.permissions import PermissionDefinition as CoreLegacyPermissionDefinition +from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES -LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = { - "campaign:write": frozenset({ - "campaign:create", - "campaign:update", - "campaign:copy", - "campaign:archive", - "campaign:delete", - "campaign:share", - "recipients:read", - "recipients:write", - "recipients:import", - }), - "attachments:read": frozenset({"files:read", "files:download"}), - "attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}), - "admin:users": frozenset({ - "admin:users:read", - "admin:users:create", - "admin:users:update", - "admin:users:suspend", - "admin:groups:read", - "admin:groups:write", - "admin:groups:manage_members", - "admin:roles:read", - "admin:roles:write", - "admin:roles:assign", - }), - "admin:users:write": frozenset({ - "admin:users:create", - "admin:users:update", - "admin:users:suspend", - "admin:roles:assign", - "admin:groups:manage_members", - }), - "admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}), - "admin:settings": frozenset({ - "admin:settings:read", - "admin:settings:write", - "admin:api_keys:read", - "admin:api_keys:create", - "admin:api_keys:revoke", - }), - "system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}), - "system:access:write": frozenset({ - "system:access:assign", - "system:roles:assign", - "system:accounts:create", - "system:accounts:update", - "system:accounts:suspend", - }), -} - - -def _permission(scope: str, label: str, description: str, category: str, level: PermissionLevel) -> PermissionDefinition: - module_id, resource, action = scope.split(":", 2) +def _legacy_permission(permission: CoreLegacyPermissionDefinition) -> PermissionDefinition: + parts = permission.scope.split(":", 2) + if len(parts) == 2: + module_id, action = parts + resource = module_id + else: + module_id, resource, action = parts return PermissionDefinition( - scope=scope, - label=label, - description=description, - category=category, - level=level, + scope=permission.scope, + label=permission.label, + description=permission.description, + category=permission.category, + level=permission.level, # type: ignore[arg-type] module_id=module_id, resource=resource, action=action, @@ -75,43 +30,9 @@ def _permission(scope: str, label: str, description: str, category: str, level: ) -LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = ( - _permission("admin:users:read", "View users", "List tenant users and their effective access.", "Tenant administration", "tenant"), - _permission("admin:users:create", "Create users", "Create tenant memberships for accounts.", "Tenant administration", "tenant"), - _permission("admin:users:update", "Update users", "Edit non-access membership metadata.", "Tenant administration", "tenant"), - _permission("admin:users:suspend", "Suspend users", "Activate or suspend tenant memberships.", "Tenant administration", "tenant"), - _permission("admin:groups:read", "View groups", "List tenant groups, members and assigned roles.", "Tenant administration", "tenant"), - _permission("admin:groups:write", "Manage groups", "Create or edit tenant group definitions.", "Tenant administration", "tenant"), - _permission("admin:groups:manage_members", "Manage group members", "Add and remove users from tenant groups.", "Tenant administration", "tenant"), - _permission("admin:roles:read", "View roles", "Inspect role definitions and the permission catalogue.", "Tenant administration", "tenant"), - _permission("admin:roles:write", "Define roles", "Create and edit assignable tenant role definitions.", "Tenant administration", "tenant"), - _permission("admin:roles:assign", "Assign roles", "Assign tenant roles to users or groups within delegation limits.", "Tenant administration", "tenant"), - _permission("admin:api_keys:read", "View API keys", "List tenant API keys without revealing their secret.", "Tenant administration", "tenant"), - _permission("admin:api_keys:create", "Create API keys", "Create tenant-local API keys within the owner's delegation limits.", "Tenant administration", "tenant"), - _permission("admin:api_keys:revoke", "Revoke API keys", "Revoke tenant-local API keys.", "Tenant administration", "tenant"), - _permission("admin:settings:read", "View tenant settings", "Read tenant defaults and non-policy settings.", "Tenant administration", "tenant"), - _permission("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration", "tenant"), - _permission("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration", "tenant"), - _permission("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration", "tenant"), - _permission("system:tenants:read", "View all tenants", "List tenants and system-wide membership counts.", "System administration", "system"), - _permission("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"), - _permission("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"), - _permission("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"), - _permission("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"), - _permission("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"), - _permission("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"), - _permission("system:accounts:suspend", "Suspend accounts", "Activate or suspend global login accounts while preserving a system owner.", "System administration", "system"), - _permission("system:roles:read", "View system roles", "Inspect instance-wide role definitions and their permissions.", "System administration", "system"), - _permission("system:roles:write", "Define system roles", "Create and edit instance-wide role definitions within delegation limits.", "System administration", "system"), - _permission("system:roles:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "System administration", "system"), - _permission("system:access:read", "View system access", "Compatibility scope for listing accounts with instance-wide roles.", "System administration", "system"), - _permission("system:access:assign", "Assign system access", "Compatibility scope for assigning instance-wide roles.", "System administration", "system"), - _permission("system:audit:read", "View system audit", "Read audit records across tenants.", "System administration", "system"), - _permission("system:settings:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "System administration", "system"), - _permission("system:settings:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "System administration", "system"), - _permission("system:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "System administration", "system"), - _permission("system:governance:read", "View governance templates", "Inspect centrally managed group and role definitions.", "System administration", "system"), - _permission("system:governance:write", "Manage governance templates", "Create and assign centrally managed group and role definitions.", "System administration", "system"), +LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = tuple( + _legacy_permission(permission) + for permission in CORE_LEGACY_PERMISSION_DEFINITIONS ) @@ -146,12 +67,12 @@ def role_templates_for_level(level: PermissionLevel) -> tuple[RoleTemplate, ...] return tuple(template for template in role_templates() if template.level == level) -def scope_grants(granted: str, required: str) -> bool: - catalog = permission_map(include_legacy=True) +def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool: + catalog = catalog if catalog is not None else permission_map(include_legacy=True) if _scope_grants(granted, required, catalog=catalog): return True for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()): - if scope_grants(alias, required): + if scope_grants(alias, required, catalog=catalog): return True return any( _scope_grants(granted, alias, catalog=catalog) @@ -160,8 +81,9 @@ def scope_grants(granted: str, required: str) -> bool: ) -def scopes_grant(scopes: Iterable[str], required: str) -> bool: - return any(scope_grants(scope, required) for scope in scopes) +def scopes_grant(scopes: Iterable[str], required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool: + catalog = catalog if catalog is not None else permission_map(include_legacy=True) + return any(scope_grants(scope, required, catalog=catalog) for scope in scopes) def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]: @@ -173,7 +95,7 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis expanded.add(scope) for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()): expanded.add(alias) - matched = {candidate for candidate in catalog if scope_grants(scope, candidate)} + matched = {candidate for candidate in catalog if scope_grants(scope, candidate, catalog=catalog)} expanded.update(matched) for alias in compatible_required_scopes(scope): if alias != scope: @@ -186,7 +108,8 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis def effective_permission_scopes(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> set[str]: candidates = _effective_permission_candidates(level=level) granted = list(scopes) - return {scope for scope in candidates if scopes_grant(granted, scope)} + catalog = permission_map(include_legacy=True) + return {scope for scope in candidates if scopes_grant(granted, scope, catalog=catalog)} def effective_permission_count(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> int: @@ -207,7 +130,11 @@ def _effective_permission_candidates(*, level: PermissionLevel | None = None) -> continue if level is not None and definition.level != level: continue - if any(scope_grants(active_scope, scope) or scope_grants(scope, active_scope) for active_scope in active_scopes): + if any( + scope_grants(active_scope, scope, catalog=full_catalog) + or scope_grants(scope, active_scope, catalog=full_catalog) + for active_scope in active_scopes + ): continue candidates.add(scope) return candidates @@ -231,7 +158,7 @@ def validate_permissions(scopes: Iterable[str], *, level: PermissionLevel) -> li expanded.add(alias) continue if scope.endswith(":*"): - matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate)} + matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate, catalog=catalog)} if matching: expanded.add(scope) continue @@ -272,8 +199,9 @@ def delegateable_system_scopes(scopes: Iterable[str]) -> set[str]: def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]: user = list(user_scopes) key = list(key_scopes) - tenant_scopes = {scope for scope, definition in permission_map(include_legacy=True).items() if definition.level == "tenant"} - allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope) and scopes_grant(key, scope)} + catalog = permission_map(include_legacy=True) + tenant_scopes = {scope for scope, definition in catalog.items() if definition.level == "tenant"} + allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope, catalog=catalog) and scopes_grant(key, scope, catalog=catalog)} user_raw = set(expand_scopes(user)) key_raw = set(expand_scopes(key)) allowed.update( diff --git a/src/govoplan_access/backend/security/sessions.py b/src/govoplan_access/backend/security/sessions.py index 0d2162b..c785712 100644 --- a/src/govoplan_access/backend/security/sessions.py +++ b/src/govoplan_access/backend/security/sessions.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +from collections.abc import Iterable from sqlalchemy.orm import Session @@ -18,7 +19,7 @@ from govoplan_access.backend.db.models import ( UserGroupMembership, UserRoleAssignment, ) -from govoplan_access.backend.semantic import collect_function_roles +from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles from govoplan_access.backend.permissions.catalog import expand_scopes from govoplan_core.security.time import ensure_aware_utc, utc_now @@ -33,6 +34,16 @@ class CreatedSession: 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) @@ -199,6 +210,56 @@ def collect_user_groups(session: Session, user: User) -> list[Group]: ) +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 + + tenant_roles = list(roles_by_id.values()) + system_roles = collect_system_roles(session, account) if include_system and account is not None else [] + scopes = { + scope + for role in [*tenant_roles, *system_roles] + for scope in (role.permissions or []) + } + 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: set[str] = set() for role in collect_user_roles(session, user): diff --git a/src/govoplan_access/backend/semantic.py b/src/govoplan_access/backend/semantic.py index 261b979..b4cfb9d 100644 --- a/src/govoplan_access/backend/semantic.py +++ b/src/govoplan_access/backend/semantic.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Iterable +from dataclasses import dataclass from sqlalchemy import or_ from sqlalchemy.orm import Session @@ -23,6 +24,13 @@ from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, Organizati from govoplan_core.security.time import utc_now +@dataclass(slots=True) +class FunctionAuthorizationContext: + assignment_ids: tuple[str, ...] + delegation_ids: tuple[str, ...] + roles: tuple[Role, ...] + + def primary_identity_for_account(session: Session, account_id: str) -> Identity | None: return ( session.query(Identity) @@ -169,6 +177,56 @@ def collect_function_roles(session: Session, user: User) -> list[Role]: ) +def collect_function_authorization_context(session: Session, user: User) -> FunctionAuthorizationContext: + assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id) + delegations = active_function_delegations_for_account( + session, + user.account_id, + tenant_id=user.tenant_id, + modes=("delegate", "act_in_place"), + ) + direct_assignment_ids = {assignment.id for assignment in assignments} + delegated_role_assignment_ids = { + delegation.function_assignment_id + for delegation in delegations + if delegation.mode == "delegate" + } + role_assignment_ids = direct_assignment_ids | delegated_role_assignment_ids + function_ids = {assignment.function_id for assignment in assignments} + + missing_role_assignment_ids = delegated_role_assignment_ids - direct_assignment_ids + if missing_role_assignment_ids: + function_ids.update( + row[0] + for row in session.query(FunctionAssignment.function_id) + .filter( + FunctionAssignment.tenant_id == user.tenant_id, + FunctionAssignment.id.in_(sorted(missing_role_assignment_ids)), + ) + .all() + ) + + roles: tuple[Role, ...] = () + if function_ids: + roles = tuple( + session.query(Role) + .join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id) + .filter( + FunctionRoleAssignment.tenant_id == user.tenant_id, + FunctionRoleAssignment.function_id.in_(sorted(function_ids)), + Role.tenant_id == user.tenant_id, + ) + .order_by(Role.name.asc()) + .all() + ) + + return FunctionAuthorizationContext( + assignment_ids=tuple(sorted(role_assignment_ids)), + delegation_ids=tuple(sorted(delegation.id for delegation in delegations)), + roles=roles, + ) + + def collect_external_function_roles( session: Session, user: User, diff --git a/tests/test_admin_batch_helpers.py b/tests/test_admin_batch_helpers.py new file mode 100644 index 0000000..e2bd533 --- /dev/null +++ b/tests/test_admin_batch_helpers.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_access.backend.api.v1.admin_common import ( + _accounts_by_user_id, + _group_member_ids_by_group_id, + _groups_by_user_id, + _roles_by_group_id, + _roles_by_user_id, + _tenant_role_assignment_counts, +) +from govoplan_access.backend.db.models import Account, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment +from govoplan_core.db.base import Base + + +class AdminBatchHelperTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=self.engine) + self.Session = sessionmaker(bind=self.engine) + + def tearDown(self) -> None: + Base.metadata.drop_all(bind=self.engine) + + def test_access_admin_batch_maps_related_rows(self) -> None: + session = self.Session() + account = Account(id="account-1", email="ada@example.test", normalized_email="ada@example.test") + user = User(id="user-1", tenant_id="tenant-1", account_id=account.id, email=account.email) + group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks") + role = Role(id="role-1", tenant_id="tenant-1", slug="reader", name="Reader", permissions=["admin:users:read"]) + session.add_all([account, user, group, role]) + session.flush() + session.add(UserGroupMembership(tenant_id="tenant-1", user_id=user.id, group_id=group.id)) + session.add(UserRoleAssignment(tenant_id="tenant-1", user_id=user.id, role_id=role.id)) + session.add(GroupRoleAssignment(tenant_id="tenant-1", group_id=group.id, role_id=role.id)) + session.commit() + + accounts_by_user = _accounts_by_user_id(session, [user.id]) + group_member_ids = _group_member_ids_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id]) + groups_by_user = _groups_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id]) + roles_by_group = _roles_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id]) + roles_by_user = _roles_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id]) + role_counts = _tenant_role_assignment_counts(session, [role.id]) + + self.assertEqual(accounts_by_user[user.id].id, account.id) + self.assertEqual(group_member_ids, {group.id: [user.id]}) + self.assertEqual([item.id for item in groups_by_user[user.id]], [group.id]) + self.assertEqual([item.id for item in roles_by_group[group.id]], [role.id]) + self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id]) + self.assertEqual(role_counts, {role.id: (1, 1)}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_admin_pagination.py b/tests/test_admin_pagination.py new file mode 100644 index 0000000..12af330 --- /dev/null +++ b/tests/test_admin_pagination.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import unittest + +from fastapi import HTTPException + +from govoplan_access.backend.api.v1.routes import ( + _decode_full_delta_cursor, + _encode_full_delta_cursor, + _full_delta_page, + _page_query, +) + + +class FakeQuery: + def __init__(self, rows: list[int]) -> None: + self._rows = rows + self._offset = 0 + self._limit: int | None = None + + def order_by(self, *_args: object) -> FakeQuery: + return self + + def count(self) -> int: + return len(self._rows) + + def offset(self, value: int) -> FakeQuery: + clone = FakeQuery(self._rows) + clone._offset = value + clone._limit = self._limit + return clone + + def limit(self, value: int) -> FakeQuery: + clone = FakeQuery(self._rows) + clone._offset = self._offset + clone._limit = value + return clone + + def all(self) -> list[int]: + end = None if self._limit is None else self._offset + self._limit + return self._rows[self._offset:end] + + +class AdminPaginationTests(unittest.TestCase): + def test_page_query_returns_page_and_metadata(self) -> None: + rows, metadata = _page_query(FakeQuery(list(range(7))), page=2, page_size=3) + + self.assertEqual(rows, [3, 4, 5]) + self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3}) + + def test_full_delta_page_returns_cursor_until_last_page(self) -> None: + rows, metadata, watermark, has_more = _full_delta_page( + FakeQuery(list(range(7))), + page=2, + page_size=3, + scope="users", + snapshot_sequence=42, + ) + + self.assertEqual(rows, [3, 4, 5]) + self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3}) + self.assertEqual(watermark, "full:users:3:42") + self.assertTrue(has_more) + + def test_full_delta_page_returns_sequence_watermark_on_last_page(self) -> None: + rows, metadata, watermark, has_more = _full_delta_page( + FakeQuery(list(range(7))), + page=3, + page_size=3, + scope="users", + snapshot_sequence=42, + ) + + self.assertEqual(rows, [6]) + self.assertEqual(metadata, {"total": 7, "page": 3, "page_size": 3, "pages": 3}) + self.assertEqual(watermark, "seq:42") + self.assertFalse(has_more) + + def test_full_delta_cursor_round_trips_and_rejects_wrong_scope(self) -> None: + cursor = _encode_full_delta_cursor("users", page=3, snapshot_sequence=42) + + self.assertEqual(_decode_full_delta_cursor(cursor, scope="users"), (3, 42)) + self.assertIsNone(_decode_full_delta_cursor("seq:42", scope="users")) + with self.assertRaises(HTTPException): + _decode_full_delta_cursor(cursor, scope="groups") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_auth_dependencies.py b/tests/test_auth_dependencies.py new file mode 100644 index 0000000..8c39904 --- /dev/null +++ b/tests/test_auth_dependencies.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import unittest +from typing import Iterable + +from fastapi import HTTPException +from starlette.requests import Request + +from govoplan_access.backend.auth.dependencies import _extract_token, _requires_csrf, _resolve_legacy_principal_ref +from govoplan_core.settings import settings + + +def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request: + return Request( + { + "type": "http", + "method": method, + "path": "/", + "headers": [(key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in headers], + } + ) + + +class AuthDependencyTests(unittest.TestCase): + def test_extract_token_prefers_explicit_api_key(self) -> None: + request = request_for(headers=[("authorization", "Bearer session-token")]) + + self.assertEqual(_extract_token(request, "Bearer session-token", "api-key-token"), ("api-key-token", "api_key")) + + def test_extract_token_supports_bearer_and_cookie_sources(self) -> None: + self.assertEqual(_extract_token(request_for(), "Bearer session-token", None), ("session-token", "bearer")) + + cookie_request = request_for(headers=[("cookie", f"{settings.auth_session_cookie_name}=cookie-token")]) + self.assertEqual(_extract_token(cookie_request, None, None), ("cookie-token", "cookie")) + + def test_requires_csrf_only_for_mutating_methods(self) -> None: + self.assertFalse(_requires_csrf(request_for(method="GET"))) + self.assertTrue(_requires_csrf(request_for(method="POST"))) + + def test_legacy_principal_resolver_rejects_missing_token_before_db_lookup(self) -> None: + with self.assertRaises(HTTPException) as raised: + _resolve_legacy_principal_ref(request_for(), None, authorization=None, x_api_key=None) # type: ignore[arg-type] + + self.assertEqual(raised.exception.status_code, 401) + self.assertEqual(raised.exception.detail, "Missing API key or session token") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_optional_tenancy_contract.py b/tests/test_optional_tenancy_contract.py index 2cce6f8..f348cc5 100644 --- a/tests/test_optional_tenancy_contract.py +++ b/tests/test_optional_tenancy_contract.py @@ -15,8 +15,8 @@ class OptionalTenancyContractTests(unittest.TestCase): dependencies = tuple(project["dependencies"]) - self.assertIn("govoplan-core>=0.1.6", dependencies) - self.assertNotIn("govoplan-tenancy>=0.1.6", dependencies) + self.assertIn("govoplan-core>=0.1.8", dependencies) + self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies) self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies)) def test_tenancy_is_declared_as_optional_module_integration(self) -> None: diff --git a/tests/test_permission_catalog_contract.py b/tests/test_permission_catalog_contract.py new file mode 100644 index 0000000..2d2b4c9 --- /dev/null +++ b/tests/test_permission_catalog_contract.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import unittest + +from govoplan_access.backend.permissions import catalog as access_catalog +from govoplan_core.security import permissions as core_permissions +from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES + + +class PermissionCatalogContractTests(unittest.TestCase): + def test_access_reuses_core_legacy_scope_aliases(self) -> None: + self.assertIs(access_catalog.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES) + self.assertIs(core_permissions.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES) + + def test_legacy_alias_grants_match_in_core_and_access(self) -> None: + for legacy_scope, aliases in LEGACY_SCOPE_ALIASES.items(): + for alias in aliases: + self.assertTrue(core_permissions.scope_grants(legacy_scope, alias)) + self.assertTrue(access_catalog.scope_grants(legacy_scope, alias)) + + def test_access_legacy_catalog_includes_non_access_platform_scopes(self) -> None: + scopes = {permission.scope for permission in access_catalog.permission_catalog(include_legacy=True)} + self.assertIn("campaign:queue", scopes) + self.assertIn("files:upload", scopes) + self.assertIn("mail_servers:test", scopes) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts index 87c847e..a89cf3c 100644 --- a/webui/src/api/admin.ts +++ b/webui/src/api/admin.ts @@ -1,47 +1,16 @@ -import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui"; -import { apiFetch } from "@govoplan/core-webui"; - -export type PermissionItem = { - scope: string; - label: string; - description: string; - category: string; - level: "tenant" | "system"; - system_template_id?: string | null; - system_required?: boolean; -}; - -export type AdminOverview = { - active_tenant_id: string; - active_tenant_name: string; - tenant_count?: number | null; - system_account_count?: number | null; - system_group_template_count?: number | null; - system_role_template_count?: number | null; - user_count: number; - active_user_count: number; - group_count: number; - role_count: number; - active_api_key_count: number; - capabilities: string[]; -}; - -export type TenantAdminItem = { - id: string; - slug: string; - name: string; - description?: string | null; - default_locale: string; - settings: Record; - allow_custom_groups?: boolean | null; - allow_custom_roles?: boolean | null; - allow_api_keys?: boolean | null; - effective_governance: Record; - is_active: boolean; - counts: Record; - created_at: string; - updated_at: string; -}; +import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy, TenantAdminItem } from "@govoplan/core-webui"; +import { apiFetch, apiGetList, apiPath, apiQuery } from "@govoplan/core-webui"; +export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui"; +export type { + AdminOverview, + PrivacyRetentionLimitPermissionPatch, + PrivacyRetentionLimitPermissions, + PrivacyRetentionPolicy, + PrivacyRetentionPolicyFieldKey, + PrivacyRetentionPolicyPatch, + PermissionItem, + TenantAdminItem +} from "@govoplan/core-webui"; export type TenantOwnerCandidate = { account_id: string; @@ -195,28 +164,6 @@ export type SystemMembershipDraft = { is_last_active_owner?: boolean; }; -export type PrivacyRetentionPolicyFieldKey = - | "store_raw_campaign_json" - | "raw_campaign_json_retention_days" - | "generated_eml_retention_days" - | "stored_report_detail_retention_days" - | "mock_mailbox_retention_days" - | "audit_detail_retention_days" - | "audit_detail_level"; - -export type PrivacyRetentionLimitPermissions = Record; - -export type PrivacyRetentionPolicy = { - store_raw_campaign_json: boolean; - raw_campaign_json_retention_days?: number | null; - generated_eml_retention_days?: number | null; - stored_report_detail_retention_days?: number | null; - mock_mailbox_retention_days?: number | null; - audit_detail_retention_days?: number | null; - audit_detail_level: "full" | "redacted" | "minimal"; - allow_lower_level_limits: PrivacyRetentionLimitPermissions; -}; - export type SystemSettingsItem = { default_locale: string; allow_tenant_custom_groups: boolean; @@ -317,36 +264,18 @@ export type TenantSettingsDeltaResponse = { } & DeltaResponseFields; function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string { - const params = new URLSearchParams(); - if (options.since) params.set("since", options.since); - if (options.limit) params.set("limit", String(options.limit)); - return params.toString() ? `?${params.toString()}` : ""; + return apiQuery(options); } -export function fetchAdminOverview(settings: ApiSettings): Promise { - return apiFetch(settings, "/api/v1/admin/overview"); -} - -export async function fetchPermissionCatalog(settings: ApiSettings): Promise { - const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions"); - return response.permissions; -} - -export async function fetchTenants(settings: ApiSettings): Promise { - const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants"); - return response.tenants; -} - export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { const suffix = deltaSuffix(options); return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`); } export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise { - const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates"); - return response.accounts; + return apiGetList(settings, "/api/v1/admin/tenants/owner-candidates", "accounts"); } export function createTenant(settings: ApiSettings, payload: { @@ -429,19 +358,17 @@ export function fetchResourceAccessExplanation( settings: ApiSettings, options: { userId: string; resourceType: string; resourceId: string; action: string; tenantId?: string | null } ): Promise { - const params = new URLSearchParams({ + return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation", { user_id: options.userId, resource_type: options.resourceType, resource_id: options.resourceId, - action: options.action - }); - if (options.tenantId) params.set("tenant_id", options.tenantId); - return apiFetch(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`); + action: options.action, + tenant_id: options.tenantId + })); } export async function fetchGroups(settings: ApiSettings): Promise { - const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups"); - return response.groups; + return apiGetList(settings, "/api/v1/admin/groups", "groups"); } export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { @@ -471,8 +398,7 @@ export function updateGroup(settings: ApiSettings, groupId: string, payload: Par } export async function fetchRoles(settings: ApiSettings): Promise { - const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles"); - return response.roles; + return apiGetList(settings, "/api/v1/admin/roles", "roles"); } export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { @@ -528,8 +454,7 @@ export function deleteExternalFunctionRoleMapping(settings: ApiSettings, mapping } export async function fetchSystemRoles(settings: ApiSettings): Promise { - const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles"); - return response.roles; + return apiGetList(settings, "/api/v1/admin/system/roles", "roles"); } export function fetchSystemRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { @@ -587,20 +512,15 @@ export function updateSystemAccountRoles(settings: ApiSettings, accountId: strin } export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise { - const params = new URLSearchParams(); - if (includeRevoked) params.set("include_revoked", "true"); - const suffix = params.toString() ? `?${params.toString()}` : ""; - const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`); - return response.api_keys; + return apiGetList(settings, "/api/v1/admin/api-keys", "api_keys", { include_revoked: includeRevoked ? true : undefined }); } export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise { - const params = new URLSearchParams(); - if (includeRevoked) params.set("include_revoked", "true"); - if (options.since) params.set("since", options.since); - if (options.limit) params.set("limit", String(options.limit)); - const suffix = params.toString() ? `?${params.toString()}` : ""; - return apiFetch(settings, `/api/v1/admin/api-keys/delta${suffix}`); + return apiFetch(settings, apiPath("/api/v1/admin/api-keys/delta", { + include_revoked: includeRevoked ? true : undefined, + since: options.since, + limit: options.limit + })); } export function createApiKey(settings: ApiSettings, payload: { @@ -653,9 +573,7 @@ export function updateSystemSettings(settings: ApiSettings, payload: SystemSetti } export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise { - const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; - const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`); - return response.templates; + return apiGetList(settings, "/api/v1/admin/system/governance-templates", "templates", { kind }); } export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise { diff --git a/webui/src/features/admin/AdminPage.tsx b/webui/src/features/admin/AdminPage.tsx index 07e9e57..b15d758 100644 --- a/webui/src/features/admin/AdminPage.tsx +++ b/webui/src/features/admin/AdminPage.tsx @@ -5,11 +5,12 @@ import type { AdminSectionsUiCapability, ApiSettings, AuthInfo, + AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, OrganizationFunctionPickerUiCapability } from "@govoplan/core-webui"; -import { fetchMe } from "@govoplan/core-webui"; +import { fetchShellAuth } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui"; import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui"; import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui"; @@ -63,7 +64,7 @@ export default function AdminPage({ }: { settings: ApiSettings; auth: AuthInfo; - onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void; + onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void; }) { const mailProfilesUi = usePlatformUiCapability("mail.profiles"); const fileConnectorsUi = usePlatformUiCapability("files.connectors"); @@ -131,13 +132,9 @@ export default function AdminPage({ }, [requestedSection, available, fallbackSection]); async function refreshAuth() { - onAuthChange(await fetchMe(settings)); + onAuthChange(await fetchShellAuth(settings)); } - useEffect(() => { - void refreshAuth().catch(() => undefined); - }, [settings.accessToken, settings.apiBaseUrl]); - if (!hasAnyScope(auth, adminReadScopes)) { return (
diff --git a/webui/src/features/admin/SystemUsersPanel.tsx b/webui/src/features/admin/SystemUsersPanel.tsx index 21bca7f..f9a642a 100644 --- a/webui/src/features/admin/SystemUsersPanel.tsx +++ b/webui/src/features/admin/SystemUsersPanel.tsx @@ -83,8 +83,17 @@ export default function SystemUsersPanel({ let hasMore = false; do { const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark }); - nextAccounts = response.full ? response.accounts : mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts }); - nextRoles = response.full ? response.roles : mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles }); + const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:"); + nextAccounts = response.full + ? continuingFullSnapshot + ? mergeDeltaRows(nextAccounts, response.accounts, [], (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts }) + : response.accounts + : mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts }); + nextRoles = response.full + ? continuingFullSnapshot + ? mergeDeltaRows(nextRoles, response.roles, [], (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles }) + : response.roles + : mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles }); nextWatermark = response.watermark ?? null; hasMore = response.has_more; } while (hasMore); diff --git a/webui/src/features/admin/utils/deltaRows.ts b/webui/src/features/admin/utils/deltaRows.ts index c9d2e5b..0131080 100644 --- a/webui/src/features/admin/utils/deltaRows.ts +++ b/webui/src/features/admin/utils/deltaRows.ts @@ -24,7 +24,12 @@ export async function loadDeltaRows do { const response = await fetchDelta(nextWatermark); const rows = rowsFromResponse(response); - merged = response.full ? rows : mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort }); + const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:"); + merged = response.full + ? continuingFullSnapshot + ? mergeDeltaRows(merged, rows, [], getKey, { deletedResourceType, sort }) + : rows + : mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort }); nextWatermark = response.watermark ?? null; hasMore = response.has_more; } while (hasMore);