diff --git a/src/govoplan_access/backend/administration.py b/src/govoplan_access/backend/administration.py index 2ea89d2..1683ecc 100644 --- a/src/govoplan_access/backend/administration.py +++ b/src/govoplan_access/backend/administration.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping, Sequence -from sqlalchemy import func +from sqlalchemy import case, func from sqlalchemy.orm import Session from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User @@ -12,12 +12,34 @@ from govoplan_core.core.access import AccessAdministration class SqlAccessAdministration(AccessAdministration): def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]: db = _session(session) + users, active_users = ( + db.query( + func.count(User.id), + func.coalesce( + func.sum(case((User.is_active.is_(True), 1), else_=0)), + 0, + ), + ) + .filter(User.tenant_id == tenant_id) + .one() + ) + api_keys, active_api_keys = ( + db.query( + func.count(ApiKey.id), + func.coalesce( + func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)), + 0, + ), + ) + .filter(ApiKey.tenant_id == tenant_id) + .one() + ) return { - "users": db.query(User).filter(User.tenant_id == tenant_id).count(), - "active_users": db.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(), + "users": int(users), + "active_users": int(active_users), "groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(), - "api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id).count(), - "active_api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(), + "api_keys": int(api_keys), + "active_api_keys": int(active_api_keys), } def system_account_count(self, session: object) -> int: diff --git a/src/govoplan_access/backend/api/v1/admin_common.py b/src/govoplan_access/backend/api/v1/admin_common.py index d5527e2..60156d6 100644 --- a/src/govoplan_access/backend/api/v1/admin_common.py +++ b/src/govoplan_access/backend/api/v1/admin_common.py @@ -24,7 +24,6 @@ from govoplan_access.backend.api.v1.admin_schemas import ( ) from govoplan_access.backend.security.sessions import ( collect_direct_user_roles, - collect_system_roles, collect_user_groups, collect_user_scopes, ) @@ -48,7 +47,11 @@ from govoplan_access.backend.db.models import ( ) from govoplan_core.core.idm import OrganizationFunctionAssignmentRef from govoplan_core.core.organizations import OrganizationDirectory -from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes +from govoplan_access.backend.permissions.catalog import ( + effective_permission_count, + expand_scopes, + scopes_grant, +) def _http_admin_error(exc: Exception) -> HTTPException: @@ -358,44 +361,249 @@ def _user_item( ) -def _system_account_item(session: Session, account: Account) -> SystemAccountItem: - memberships = ( +def _system_membership_rows( + session: Session, + account_ids: list[str], +) -> list[tuple[User, Tenant]]: + return ( session.query(User, Tenant) .join(Tenant, Tenant.id == User.tenant_id) - .filter(User.account_id == account.id) - .order_by(Tenant.name.asc()) + .filter(User.account_id.in_(account_ids)) + .order_by(User.account_id.asc(), Tenant.name.asc(), User.id.asc()) .all() ) - owner_ids_by_tenant = { - tenant.id: tenant_owner_user_ids(session, tenant.id) - for _, tenant in memberships + + +def _memberships_by_account( + membership_rows: list[tuple[User, Tenant]], +) -> dict[str, list[tuple[User, Tenant]]]: + memberships_by_account: dict[str, list[tuple[User, Tenant]]] = defaultdict(list) + for user, tenant in membership_rows: + memberships_by_account[user.account_id].append((user, tenant)) + return memberships_by_account + + +def _system_direct_roles_by_user( + session: Session, + user_ids: list[str], +) -> dict[str, list[Role]]: + roles_by_user: dict[str, list[Role]] = defaultdict(list) + if not user_ids: + return roles_by_user + rows = ( + session.query(UserRoleAssignment.user_id, Role) + .join(Role, Role.id == UserRoleAssignment.role_id) + .filter(UserRoleAssignment.user_id.in_(user_ids)) + .order_by(UserRoleAssignment.user_id.asc(), Role.name.asc()) + .all() + ) + for user_id, role in rows: + roles_by_user[user_id].append(role) + return roles_by_user + + +def _system_groups_by_user( + session: Session, + user_ids: list[str], +) -> dict[str, list[Group]]: + groups_by_user: dict[str, list[Group]] = defaultdict(list) + if not user_ids: + return groups_by_user + rows = ( + session.query(UserGroupMembership.user_id, Group) + .join(Group, Group.id == UserGroupMembership.group_id) + .filter( + UserGroupMembership.user_id.in_(user_ids), + Group.is_active.is_(True), + ) + .order_by(UserGroupMembership.user_id.asc(), Group.name.asc()) + .all() + ) + for user_id, group in rows: + groups_by_user[user_id].append(group) + return groups_by_user + + +def _system_group_roles_by_user( + session: Session, + user_ids: list[str], +) -> dict[str, list[Role]]: + group_roles_by_user: dict[str, list[Role]] = defaultdict(list) + if not user_ids: + return group_roles_by_user + rows = ( + session.query(UserGroupMembership.user_id, Role) + .join( + GroupRoleAssignment, + GroupRoleAssignment.group_id == UserGroupMembership.group_id, + ) + .join(Role, Role.id == GroupRoleAssignment.role_id) + .join(Group, Group.id == UserGroupMembership.group_id) + .filter( + UserGroupMembership.user_id.in_(user_ids), + Group.is_active.is_(True), + ) + .all() + ) + for user_id, role in rows: + group_roles_by_user[user_id].append(role) + return group_roles_by_user + + +def _system_owner_ids_by_tenant( + membership_rows: list[tuple[User, Tenant]], + *, + accounts_by_id: dict[str, Account], + direct_roles_by_user: dict[str, list[Role]], + group_roles_by_user: dict[str, list[Role]], +) -> dict[str, set[str]]: + owner_ids_by_tenant: dict[str, set[str]] = defaultdict(set) + for user, tenant in membership_rows: + account = accounts_by_id[user.account_id] + if not user.is_active or not account.is_active: + continue + effective_permissions = [ + permission + for role in direct_roles_by_user[user.id] + group_roles_by_user[user.id] + for permission in (role.permissions or []) + ] + if ( + scopes_grant(effective_permissions, "admin:roles:write") + and scopes_grant(effective_permissions, "campaign:send") + ): + owner_ids_by_tenant[tenant.id].add(user.id) + return owner_ids_by_tenant + + +def _system_roles_for_accounts( + session: Session, + account_ids: list[str], +) -> tuple[dict[str, list[Role]], dict[str, int]]: + system_roles_by_account: dict[str, list[Role]] = defaultdict(list) + system_role_ids: set[str] = set() + rows = ( + session.query(SystemRoleAssignment.account_id, Role) + .join(Role, Role.id == SystemRoleAssignment.role_id) + .filter( + SystemRoleAssignment.account_id.in_(account_ids), + Role.tenant_id.is_(None), + ) + .order_by(SystemRoleAssignment.account_id.asc(), Role.name.asc()) + .all() + ) + for account_id, role in rows: + system_roles_by_account[account_id].append(role) + system_role_ids.add(role.id) + return ( + system_roles_by_account, + _system_role_assignment_counts(session, sorted(system_role_ids)), + ) + + +def _system_membership_item( + user: User, + tenant: Tenant, + *, + roles_by_user: dict[str, list[Role]], + groups_by_user: dict[str, list[Group]], + owner_ids_by_tenant: dict[str, set[str]], +) -> dict[str, object]: + tenant_owner_ids = owner_ids_by_tenant[tenant.id] + return { + "tenant_id": tenant.id, + "tenant_name": tenant.name, + "user_id": user.id, + "is_active": user.is_active and tenant.is_active, + "role_ids": [role.id for role in roles_by_user[user.id]], + "group_ids": [group.id for group in groups_by_user[user.id]], + "is_owner": user.id in tenant_owner_ids, + "is_last_active_owner": ( + user.id in tenant_owner_ids and len(tenant_owner_ids) == 1 + ), } + + +def _system_account_response_item( + session: Session, + account: Account, + *, + memberships: list[tuple[User, Tenant]], + roles_by_user: dict[str, list[Role]], + groups_by_user: dict[str, list[Group]], + owner_ids_by_tenant: dict[str, set[str]], + system_roles: list[Role], + system_role_counts: dict[str, int], +) -> SystemAccountItem: return SystemAccountItem( account_id=account.id, email=account.email, display_name=account.display_name, is_active=account.is_active, memberships=[ - { - "tenant_id": tenant.id, - "tenant_name": tenant.name, - "user_id": user.id, - "is_active": user.is_active and tenant.is_active, - "role_ids": [role.id for role in collect_direct_user_roles(session, user)], - "group_ids": [group.id for group in collect_user_groups(session, user)], - "is_owner": user.id in owner_ids_by_tenant[tenant.id], - "is_last_active_owner": ( - user.id in owner_ids_by_tenant[tenant.id] - and len(owner_ids_by_tenant[tenant.id]) == 1 - ), - } + _system_membership_item( + user, + tenant, + roles_by_user=roles_by_user, + groups_by_user=groups_by_user, + owner_ids_by_tenant=owner_ids_by_tenant, + ) for user, tenant in memberships ], - roles=[_role_summary(session, role) for role in collect_system_roles(session, account)], + roles=[ + _role_summary( + session, + role, + system_role_assignment_counts=system_role_counts, + ) + for role in system_roles + ], last_login_at=account.last_login_at, ) +def _system_account_items( + session: Session, + accounts: list[Account], +) -> list[SystemAccountItem]: + if not accounts: + return [] + account_ids = [account.id for account in accounts] + accounts_by_id = {account.id: account for account in accounts} + membership_rows = _system_membership_rows(session, account_ids) + memberships_by_account = _memberships_by_account(membership_rows) + user_ids = [user.id for user, _tenant in membership_rows] + roles_by_user = _system_direct_roles_by_user(session, user_ids) + groups_by_user = _system_groups_by_user(session, user_ids) + group_roles_by_user = _system_group_roles_by_user(session, user_ids) + owner_ids_by_tenant = _system_owner_ids_by_tenant( + membership_rows, + accounts_by_id=accounts_by_id, + direct_roles_by_user=roles_by_user, + group_roles_by_user=group_roles_by_user, + ) + system_roles_by_account, system_role_counts = _system_roles_for_accounts( + session, + account_ids, + ) + return [ + _system_account_response_item( + session, + account, + memberships=memberships_by_account[account.id], + roles_by_user=roles_by_user, + groups_by_user=groups_by_user, + owner_ids_by_tenant=owner_ids_by_tenant, + system_roles=system_roles_by_account[account.id], + system_role_counts=system_role_counts, + ) + for account in accounts + ] + + +def _system_account_item(session: Session, account: Account) -> SystemAccountItem: + return _system_account_items(session, [account])[0] + + 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) diff --git a/src/govoplan_access/backend/api/v1/admin_schemas.py b/src/govoplan_access/backend/api/v1/admin_schemas.py index d1123d5..59c5bed 100644 --- a/src/govoplan_access/backend/api/v1/admin_schemas.py +++ b/src/govoplan_access/backend/api/v1/admin_schemas.py @@ -193,7 +193,7 @@ class OrganizationUnitItem(BaseModel): updated_at: datetime -class OrganizationUnitListResponse(BaseModel): +class OrganizationUnitListResponse(PagedListResponse): organization_units: list[OrganizationUnitItem] @@ -232,7 +232,7 @@ class FunctionAdminItem(BaseModel): updated_at: datetime -class FunctionListResponse(BaseModel): +class FunctionListResponse(PagedListResponse): functions: list[FunctionAdminItem] @@ -247,7 +247,7 @@ class ExternalFunctionRoleMappingItem(BaseModel): updated_at: datetime -class ExternalFunctionRoleMappingListResponse(BaseModel): +class ExternalFunctionRoleMappingListResponse(PagedListResponse): mappings: list[ExternalFunctionRoleMappingItem] @@ -318,7 +318,7 @@ class FunctionAssignmentAdminItem(BaseModel): updated_at: datetime -class FunctionAssignmentListResponse(BaseModel): +class FunctionAssignmentListResponse(PagedListResponse): assignments: list[FunctionAssignmentAdminItem] @@ -368,7 +368,7 @@ class FunctionDelegationAdminItem(BaseModel): updated_at: datetime -class FunctionDelegationListResponse(BaseModel): +class FunctionDelegationListResponse(PagedListResponse): delegations: list[FunctionDelegationAdminItem] diff --git a/src/govoplan_access/backend/api/v1/routes.py b/src/govoplan_access/backend/api/v1/routes.py index e88e002..75da56f 100644 --- a/src/govoplan_access/backend/api/v1/routes.py +++ b/src/govoplan_access/backend/api/v1/routes.py @@ -57,6 +57,7 @@ from govoplan_access.backend.api.v1.admin_common import ( _set_system_memberships, _system_role_assignment_counts, _system_account_item, + _system_account_items, _tenant_role_assignment_counts, _user_item, ) @@ -484,14 +485,42 @@ def _validate_parent_organization_unit( raise AdminValidationError("Parent organization unit does not belong to the tenant.") -def _function_item(session: Session, item: Function) -> FunctionAdminItem: - role_ids = [ - row[0] - for row in session.query(FunctionRoleAssignment.role_id) - .filter(FunctionRoleAssignment.function_id == item.id) - .order_by(FunctionRoleAssignment.created_at.asc()) +def _function_role_ids_by_function_id( + session: Session, + function_ids: Iterable[str], +) -> dict[str, list[str]]: + requested = tuple(dict.fromkeys(function_ids)) + result: dict[str, list[str]] = {function_id: [] for function_id in requested} + if not requested: + return result + rows = ( + session.query( + FunctionRoleAssignment.function_id, + FunctionRoleAssignment.role_id, + ) + .filter(FunctionRoleAssignment.function_id.in_(requested)) + .order_by( + FunctionRoleAssignment.function_id.asc(), + FunctionRoleAssignment.created_at.asc(), + ) .all() - ] + ) + for function_id, role_id in rows: + result.setdefault(function_id, []).append(role_id) + return result + + +def _function_item( + session: Session, + item: Function, + *, + role_ids_by_function_id: dict[str, list[str]] | None = None, +) -> FunctionAdminItem: + if role_ids_by_function_id is None: + role_ids_by_function_id = _function_role_ids_by_function_id( + session, + (item.id,), + ) return FunctionAdminItem( id=item.id, tenant_id=item.tenant_id, @@ -499,7 +528,7 @@ def _function_item(session: Session, item: Function) -> FunctionAdminItem: slug=item.slug, name=item.name, description=item.description, - role_ids=role_ids, + role_ids=role_ids_by_function_id.get(item.id, []), delegable=item.delegable, act_in_place_allowed=item.act_in_place_allowed, is_active=item.is_active, @@ -1266,17 +1295,26 @@ def deactivate_identity( @router.get("/organization-units", response_model=OrganizationUnitListResponse) def list_organization_units( tenant_id: str | None = 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_any_scope("admin:roles:read", "access:function:read", "access:role:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) - items = ( + query = ( session.query(OrganizationUnit) .filter(OrganizationUnit.tenant_id == tenant.id) .order_by(OrganizationUnit.name.asc()) - .all() ) - return OrganizationUnitListResponse(organization_units=[_organization_unit_item(item) for item in items]) + items, pagination = _page_query( + query, + page=page, + page_size=page_size, + ) + return OrganizationUnitListResponse( + organization_units=[_organization_unit_item(item) for item in items], + **pagination, + ) @router.post("/organization-units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED) @@ -1394,6 +1432,8 @@ def deactivate_organization_unit( def list_functions( tenant_id: str | None = None, organization_unit_id: str | None = 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_any_scope("admin:roles:read", "access:function:read", "access:role:read")), ): @@ -1401,8 +1441,26 @@ def list_functions( query = session.query(Function).filter(Function.tenant_id == tenant.id) if organization_unit_id: query = query.filter(Function.organization_unit_id == organization_unit_id) - functions = query.order_by(Function.name.asc()).all() - return FunctionListResponse(functions=[_function_item(session, item) for item in functions]) + functions, pagination = _page_query( + query.order_by(Function.name.asc()), + page=page, + page_size=page_size, + ) + role_ids_by_function_id = _function_role_ids_by_function_id( + session, + (item.id for item in functions), + ) + return FunctionListResponse( + functions=[ + _function_item( + session, + item, + role_ids_by_function_id=role_ids_by_function_id, + ) + for item in functions + ], + **pagination, + ) @router.post("/functions", response_model=FunctionAdminItem, status_code=status.HTTP_201_CREATED) @@ -1535,6 +1593,8 @@ def list_external_function_role_mappings( tenant_id: str | None = None, source_module: str | None = None, function_id: str | None = 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_any_scope("admin:roles:read", "access:function:read", "access:role:read")), ): @@ -1544,23 +1604,57 @@ def list_external_function_role_mappings( query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip()) if function_id: query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip()) - items = query.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()).all() - return ExternalFunctionRoleMappingListResponse(mappings=[_external_function_role_mapping_item(item) for item in items]) + items, pagination = _page_query( + query.order_by( + ExternalFunctionRoleAssignment.source_module.asc(), + ExternalFunctionRoleAssignment.function_id.asc(), + ), + page=page, + page_size=page_size, + ) + return ExternalFunctionRoleMappingListResponse( + mappings=[_external_function_role_mapping_item(item) for item in items], + **pagination, + ) -def _full_external_function_role_mappings_delta_response(session: Session, tenant: Tenant) -> ExternalFunctionRoleMappingListDeltaResponse: - items = ( +def _full_external_function_role_mappings_delta_response( + session: Session, + tenant: Tenant, + *, + cursor: tuple[int, int] | None = None, + limit: int = 500, +) -> ExternalFunctionRoleMappingListDeltaResponse: + 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_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,), + ) + ) + page = cursor[0] if cursor is not None else 1 + query = ( session.query(ExternalFunctionRoleAssignment) .filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id) .order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()) - .all() + ) + items, pagination, watermark, has_more = _full_delta_page( + query, + page=page, + page_size=limit, + scope="external-function-role-mappings", + snapshot_sequence=snapshot_sequence, ) return ExternalFunctionRoleMappingListDeltaResponse( mappings=[_external_function_role_mapping_item(item) for item in items], deleted=[], - watermark=_access_delta_watermark(session, tenant.id, (ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,)), - has_more=False, + watermark=watermark, + has_more=has_more, full=True, + **pagination, ) @@ -1620,8 +1714,17 @@ def list_external_function_role_mappings_delta( principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) - if since is None: - return _full_external_function_role_mappings_delta_response(session, tenant) + full_cursor = _decode_full_delta_cursor( + since, + scope="external-function-role-mappings", + ) + if since is None or full_cursor is not None: + return _full_external_function_role_mappings_delta_response( + session, + tenant, + cursor=full_cursor, + limit=limit, + ) return _external_function_role_mappings_delta_response(session, tenant, since=since, limit=limit) @@ -1735,6 +1838,8 @@ def list_function_assignments( tenant_id: str | None = None, account_id: str | None = None, function_id: str | None = 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_any_scope("admin:roles:read", "access:function:read", "access:function:assign")), ): @@ -1744,8 +1849,15 @@ def list_function_assignments( query = query.filter(FunctionAssignment.account_id == account_id) if function_id: query = query.filter(FunctionAssignment.function_id == function_id) - assignments = query.order_by(FunctionAssignment.created_at.desc()).all() - return FunctionAssignmentListResponse(assignments=[_function_assignment_item(item) for item in assignments]) + assignments, pagination = _page_query( + query.order_by(FunctionAssignment.created_at.desc()), + page=page, + page_size=page_size, + ) + return FunctionAssignmentListResponse( + assignments=[_function_assignment_item(item) for item in assignments], + **pagination, + ) @router.post("/function-assignments", response_model=FunctionAssignmentAdminItem, status_code=status.HTTP_201_CREATED) @@ -1888,6 +2000,8 @@ def deactivate_function_assignment( def list_function_delegations( tenant_id: str | None = None, account_id: str | None = 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_any_scope("admin:roles:read", "access:function:read", "access:function:delegate")), ): @@ -1895,8 +2009,15 @@ def list_function_delegations( query = session.query(FunctionDelegation).filter(FunctionDelegation.tenant_id == tenant.id) if account_id: query = query.filter((FunctionDelegation.delegator_account_id == account_id) | (FunctionDelegation.delegate_account_id == account_id)) - delegations = query.order_by(FunctionDelegation.created_at.desc()).all() - return FunctionDelegationListResponse(delegations=[_function_delegation_item(item) for item in delegations]) + delegations, pagination = _page_query( + query.order_by(FunctionDelegation.created_at.desc()), + page=page, + page_size=page_size, + ) + return FunctionDelegationListResponse( + delegations=[_function_delegation_item(item) for item in delegations], + **pagination, + ) @router.post("/function-delegations", response_model=FunctionDelegationAdminItem, status_code=status.HTTP_201_CREATED) @@ -2981,6 +3102,25 @@ def _system_role_summaries_for_response(session: Session, roles: list[Role]) -> return [_role_summary(session, role, system_role_assignment_counts=system_role_counts) for role in roles] +def _system_roles_for_account_catalog(session: Session) -> list[Role]: + roles = ( + session.query(Role) + .filter(Role.tenant_id.is_(None)) + .order_by(Role.name.asc(), Role.id.asc()) + .limit(1001) + .all() + ) + if len(roles) > 1000: + raise HTTPException( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, + detail=( + "The system role catalog exceeds 1000 entries. " + "Use the paginated system roles endpoint." + ), + ) + return 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() @@ -3178,13 +3318,13 @@ _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_ 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() + system_roles = _system_roles_for_account_catalog(session) 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], + accounts=_system_account_items(session, accounts), roles=_system_role_summaries_for_response(session, system_roles), deleted=[], watermark=watermark, @@ -3223,7 +3363,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()], + accounts=_system_account_items(session, list(visible_accounts.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), @@ -3255,11 +3395,11 @@ def list_system_accounts( ): ensure_default_roles(session, None) session.commit() - system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() + system_roles = _system_roles_for_account_catalog(session) 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], + accounts=_system_account_items(session, accounts), roles=_system_role_summaries_for_response(session, system_roles), **pagination, ) diff --git a/src/govoplan_access/backend/auth/dependencies.py b/src/govoplan_access/backend/auth/dependencies.py index b51834c..e962365 100644 --- a/src/govoplan_access/backend/auth/dependencies.py +++ b/src/govoplan_access/backend/auth/dependencies.py @@ -249,11 +249,19 @@ def _resolve_api_key_principal_context( ) -> 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) + api_key = authenticate_api_key( + session, + token, + touch_interval_seconds=settings.auth_activity_touch_interval_seconds, + ) 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 + activity_touch_pending = session.is_modified( + api_key, + include_collections=False, + ) + user = api_key.user + account = user.account if user else None tenant = session.get(Tenant, api_key.tenant_id) if ( not user or not account or not tenant @@ -277,7 +285,6 @@ def _resolve_api_key_principal_context( 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, @@ -291,6 +298,8 @@ def _resolve_api_key_principal_context( extra_roles=idm_roles, authorization_context=authorization_context, ) + if activity_touch_pending: + session.commit() return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key) @@ -326,11 +335,19 @@ def _resolve_session_principal_context( identity_directory: IdentityDirectory | None, organization_directory: OrganizationDirectory | None, ) -> ResolvedPrincipalContext | None: - auth_session = authenticate_session_token(session, token) + auth_session = authenticate_session_token( + session, + token, + touch_interval_seconds=settings.auth_activity_touch_interval_seconds, + ) if auth_session is None: return None - user = session.get(User, auth_session.user_id) - account = session.get(Account, auth_session.account_id) + activity_touch_pending = session.is_modified( + auth_session, + include_collections=False, + ) + user = auth_session.user + account = auth_session.account tenant = session.get(Tenant, auth_session.tenant_id) if ( not user or not account or not tenant @@ -357,7 +374,6 @@ def _resolve_session_principal_context( extra_roles=idm_roles, ) scopes = authorization_context.scopes - session.commit() principal = _build_principal_ref( session, auth_session=auth_session, @@ -372,6 +388,8 @@ def _resolve_session_principal_context( authorization_context=authorization_context, include_system_roles=True, ) + if activity_touch_pending: + session.commit() return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session) diff --git a/src/govoplan_access/backend/security/api_keys.py b/src/govoplan_access/backend/security/api_keys.py index 90a4623..8b0d68e 100644 --- a/src/govoplan_access/backend/security/api_keys.py +++ b/src/govoplan_access/backend/security/api_keys.py @@ -1,9 +1,9 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret from govoplan_access.backend.db.models import ApiKey, User @@ -60,17 +60,36 @@ def create_api_key( return CreatedApiKey(model=model, secret=secret) -def authenticate_api_key(session: Session, secret: str) -> ApiKey | None: +def authenticate_api_key( + session: Session, + secret: str, + *, + touch_interval_seconds: int = 5 * 60, +) -> ApiKey | None: prefix = api_key_prefix(secret) - candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all() + candidates = ( + session.query(ApiKey) + .options(joinedload(ApiKey.user).joinedload(User.account)) + .filter( + ApiKey.prefix == prefix, + ApiKey.revoked_at.is_(None), + ) + .all() + ) now = utc_now() for candidate in candidates: expires_at = ensure_aware_utc(candidate.expires_at) if expires_at and expires_at < now: continue if verify_api_key(secret, candidate.key_hash): - candidate.last_used_at = now - session.add(candidate) + last_used_at = ensure_aware_utc(candidate.last_used_at) + if ( + touch_interval_seconds <= 0 + or last_used_at is None + or now - last_used_at >= timedelta(seconds=touch_interval_seconds) + ): + candidate.last_used_at = now + session.add(candidate) return candidate return None @@ -78,4 +97,3 @@ def authenticate_api_key(session: Session, secret: str) -> ApiKey | None: def has_scope(api_key: ApiKey, required_scope: str) -> bool: scopes = set(api_key.scopes or []) return "*" in scopes or required_scope in scopes - diff --git a/src/govoplan_access/backend/security/sessions.py b/src/govoplan_access/backend/security/sessions.py index c3b0479..f043c0d 100644 --- a/src/govoplan_access/backend/security/sessions.py +++ b/src/govoplan_access/backend/security/sessions.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import timedelta from collections.abc import Iterable -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret from govoplan_access.backend.db.models import ( @@ -104,17 +104,39 @@ def create_auth_session( return CreatedSession(model=model, token=token, csrf_token=csrf_token) -def authenticate_session_token(session: Session, token: str) -> AuthSession | None: +def authenticate_session_token( + session: Session, + token: str, + *, + touch_interval_seconds: int = 5 * 60, +) -> AuthSession | None: token_hash = hash_session_token(token) - model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none() + model = ( + session.query(AuthSession) + .options( + joinedload(AuthSession.user).joinedload(User.account), + joinedload(AuthSession.account), + ) + .filter( + AuthSession.token_hash == token_hash, + AuthSession.revoked_at.is_(None), + ) + .one_or_none() + ) if not model: return None now = utc_now() expires_at = ensure_aware_utc(model.expires_at) if expires_at is None or expires_at < now: return None - model.last_seen_at = now - session.add(model) + last_seen_at = ensure_aware_utc(model.last_seen_at) + if ( + touch_interval_seconds <= 0 + or last_seen_at is None + or now - last_seen_at >= timedelta(seconds=touch_interval_seconds) + ): + model.last_seen_at = now + session.add(model) return model diff --git a/tests/test_auth_activity_touches.py b/tests/test_auth_activity_touches.py new file mode 100644 index 0000000..9afa8d6 --- /dev/null +++ b/tests/test_auth_activity_touches.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from govoplan_access.backend.security.api_keys import authenticate_api_key +from govoplan_access.backend.security.sessions import authenticate_session_token + + +class AuthenticationActivityTouchTests(TestCase): + def test_session_activity_is_touched_only_after_the_interval(self) -> None: + now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc) + for age_seconds, should_touch in ((60, False), (301, True)): + with self.subTest(age_seconds=age_seconds): + model = SimpleNamespace( + expires_at=now + timedelta(hours=1), + last_seen_at=now - timedelta(seconds=age_seconds), + ) + session = MagicMock() + ( + session.query.return_value.options.return_value + .filter.return_value.one_or_none + ).return_value = model + + with ( + patch( + "govoplan_access.backend.security.sessions.hash_session_token", + return_value="hashed", + ), + patch( + "govoplan_access.backend.security.sessions.utc_now", + return_value=now, + ), + ): + result = authenticate_session_token( + session, + "token", + touch_interval_seconds=300, + ) + + self.assertIs(model, result) + if should_touch: + self.assertEqual(now, model.last_seen_at) + session.add.assert_called_once_with(model) + else: + self.assertEqual( + now - timedelta(seconds=age_seconds), + model.last_seen_at, + ) + session.add.assert_not_called() + + def test_api_key_activity_is_touched_only_after_the_interval(self) -> None: + now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc) + for age_seconds, should_touch in ((60, False), (301, True)): + with self.subTest(age_seconds=age_seconds): + model = SimpleNamespace( + expires_at=None, + last_used_at=now - timedelta(seconds=age_seconds), + key_hash="hashed", + ) + session = MagicMock() + ( + session.query.return_value.options.return_value + .filter.return_value.all + ).return_value = [model] + + with ( + patch( + "govoplan_access.backend.security.api_keys.verify_api_key", + return_value=True, + ), + patch( + "govoplan_access.backend.security.api_keys.utc_now", + return_value=now, + ), + ): + result = authenticate_api_key( + session, + "mm_test-token", + touch_interval_seconds=300, + ) + + self.assertIs(model, result) + if should_touch: + self.assertEqual(now, model.last_used_at) + session.add.assert_called_once_with(model) + else: + self.assertEqual( + now - timedelta(seconds=age_seconds), + model.last_used_at, + ) + session.add.assert_not_called() + + +if __name__ == "__main__": + import unittest + + unittest.main() diff --git a/webui/package.json b/webui/package.json index 064277c..b371df9 100644 --- a/webui/package.json +++ b/webui/package.json @@ -17,7 +17,7 @@ "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.1" + "react-router-dom": ">=7.18.2 <8" }, "peerDependenciesMeta": { "@govoplan/core-webui": {