perf: batch authorization and activity updates

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent 3df4fc5bff
commit 984a015704
9 changed files with 613 additions and 85 deletions

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence from collections.abc import Iterable, Mapping, Sequence
from sqlalchemy import func from sqlalchemy import case, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User 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): class SqlAccessAdministration(AccessAdministration):
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]: def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
db = _session(session) 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 { return {
"users": db.query(User).filter(User.tenant_id == tenant_id).count(), "users": int(users),
"active_users": db.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(), "active_users": int(active_users),
"groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(), "groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(),
"api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id).count(), "api_keys": int(api_keys),
"active_api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(), "active_api_keys": int(active_api_keys),
} }
def system_account_count(self, session: object) -> int: def system_account_count(self, session: object) -> int:

View File

@@ -24,7 +24,6 @@ from govoplan_access.backend.api.v1.admin_schemas import (
) )
from govoplan_access.backend.security.sessions import ( from govoplan_access.backend.security.sessions import (
collect_direct_user_roles, collect_direct_user_roles,
collect_system_roles,
collect_user_groups, collect_user_groups,
collect_user_scopes, 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.idm import OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import OrganizationDirectory 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: def _http_admin_error(exc: Exception) -> HTTPException:
@@ -358,44 +361,249 @@ def _user_item(
) )
def _system_account_item(session: Session, account: Account) -> SystemAccountItem: def _system_membership_rows(
memberships = ( session: Session,
account_ids: list[str],
) -> list[tuple[User, Tenant]]:
return (
session.query(User, Tenant) session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id) .join(Tenant, Tenant.id == User.tenant_id)
.filter(User.account_id == account.id) .filter(User.account_id.in_(account_ids))
.order_by(Tenant.name.asc()) .order_by(User.account_id.asc(), Tenant.name.asc(), User.id.asc())
.all() .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( return SystemAccountItem(
account_id=account.id, account_id=account.id,
email=account.email, email=account.email,
display_name=account.display_name, display_name=account.display_name,
is_active=account.is_active, is_active=account.is_active,
memberships=[ memberships=[
{ _system_membership_item(
"tenant_id": tenant.id, user,
"tenant_name": tenant.name, tenant,
"user_id": user.id, roles_by_user=roles_by_user,
"is_active": user.is_active and tenant.is_active, groups_by_user=groups_by_user,
"role_ids": [role.id for role in collect_direct_user_roles(session, user)], owner_ids_by_tenant=owner_ids_by_tenant,
"group_ids": [group.id for group in collect_user_groups(session, user)], )
"is_owner": user.id in owner_ids_by_tenant[tenant.id],
"is_last_active_owner": (
user.id in owner_ids_by_tenant[tenant.id]
and len(owner_ids_by_tenant[tenant.id]) == 1
),
}
for user, tenant in memberships 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, 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: 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: if accounts_by_user_id is not None:
account = accounts_by_user_id.get(item.user_id) account = accounts_by_user_id.get(item.user_id)

View File

@@ -193,7 +193,7 @@ class OrganizationUnitItem(BaseModel):
updated_at: datetime updated_at: datetime
class OrganizationUnitListResponse(BaseModel): class OrganizationUnitListResponse(PagedListResponse):
organization_units: list[OrganizationUnitItem] organization_units: list[OrganizationUnitItem]
@@ -232,7 +232,7 @@ class FunctionAdminItem(BaseModel):
updated_at: datetime updated_at: datetime
class FunctionListResponse(BaseModel): class FunctionListResponse(PagedListResponse):
functions: list[FunctionAdminItem] functions: list[FunctionAdminItem]
@@ -247,7 +247,7 @@ class ExternalFunctionRoleMappingItem(BaseModel):
updated_at: datetime updated_at: datetime
class ExternalFunctionRoleMappingListResponse(BaseModel): class ExternalFunctionRoleMappingListResponse(PagedListResponse):
mappings: list[ExternalFunctionRoleMappingItem] mappings: list[ExternalFunctionRoleMappingItem]
@@ -318,7 +318,7 @@ class FunctionAssignmentAdminItem(BaseModel):
updated_at: datetime updated_at: datetime
class FunctionAssignmentListResponse(BaseModel): class FunctionAssignmentListResponse(PagedListResponse):
assignments: list[FunctionAssignmentAdminItem] assignments: list[FunctionAssignmentAdminItem]
@@ -368,7 +368,7 @@ class FunctionDelegationAdminItem(BaseModel):
updated_at: datetime updated_at: datetime
class FunctionDelegationListResponse(BaseModel): class FunctionDelegationListResponse(PagedListResponse):
delegations: list[FunctionDelegationAdminItem] delegations: list[FunctionDelegationAdminItem]

View File

@@ -57,6 +57,7 @@ from govoplan_access.backend.api.v1.admin_common import (
_set_system_memberships, _set_system_memberships,
_system_role_assignment_counts, _system_role_assignment_counts,
_system_account_item, _system_account_item,
_system_account_items,
_tenant_role_assignment_counts, _tenant_role_assignment_counts,
_user_item, _user_item,
) )
@@ -484,14 +485,42 @@ def _validate_parent_organization_unit(
raise AdminValidationError("Parent organization unit does not belong to the tenant.") raise AdminValidationError("Parent organization unit does not belong to the tenant.")
def _function_item(session: Session, item: Function) -> FunctionAdminItem: def _function_role_ids_by_function_id(
role_ids = [ session: Session,
row[0] function_ids: Iterable[str],
for row in session.query(FunctionRoleAssignment.role_id) ) -> dict[str, list[str]]:
.filter(FunctionRoleAssignment.function_id == item.id) requested = tuple(dict.fromkeys(function_ids))
.order_by(FunctionRoleAssignment.created_at.asc()) 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() .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( return FunctionAdminItem(
id=item.id, id=item.id,
tenant_id=item.tenant_id, tenant_id=item.tenant_id,
@@ -499,7 +528,7 @@ def _function_item(session: Session, item: Function) -> FunctionAdminItem:
slug=item.slug, slug=item.slug,
name=item.name, name=item.name,
description=item.description, description=item.description,
role_ids=role_ids, role_ids=role_ids_by_function_id.get(item.id, []),
delegable=item.delegable, delegable=item.delegable,
act_in_place_allowed=item.act_in_place_allowed, act_in_place_allowed=item.act_in_place_allowed,
is_active=item.is_active, is_active=item.is_active,
@@ -1266,17 +1295,26 @@ def deactivate_identity(
@router.get("/organization-units", response_model=OrganizationUnitListResponse) @router.get("/organization-units", response_model=OrganizationUnitListResponse)
def list_organization_units( def list_organization_units(
tenant_id: str | None = None, 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), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
items = ( query = (
session.query(OrganizationUnit) session.query(OrganizationUnit)
.filter(OrganizationUnit.tenant_id == tenant.id) .filter(OrganizationUnit.tenant_id == tenant.id)
.order_by(OrganizationUnit.name.asc()) .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) @router.post("/organization-units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED)
@@ -1394,6 +1432,8 @@ def deactivate_organization_unit(
def list_functions( def list_functions(
tenant_id: str | None = None, tenant_id: str | None = None,
organization_unit_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), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")), 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) query = session.query(Function).filter(Function.tenant_id == tenant.id)
if organization_unit_id: if organization_unit_id:
query = query.filter(Function.organization_unit_id == organization_unit_id) query = query.filter(Function.organization_unit_id == organization_unit_id)
functions = query.order_by(Function.name.asc()).all() functions, pagination = _page_query(
return FunctionListResponse(functions=[_function_item(session, item) for item in functions]) 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) @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, tenant_id: str | None = None,
source_module: str | None = None, source_module: str | None = None,
function_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), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")), 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()) query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip())
if function_id: if function_id:
query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip()) query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip())
items = query.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()).all() items, pagination = _page_query(
return ExternalFunctionRoleMappingListResponse(mappings=[_external_function_role_mapping_item(item) for item in items]) 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: def _full_external_function_role_mappings_delta_response(
items = ( 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) session.query(ExternalFunctionRoleAssignment)
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id) .filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()) .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( return ExternalFunctionRoleMappingListDeltaResponse(
mappings=[_external_function_role_mapping_item(item) for item in items], mappings=[_external_function_role_mapping_item(item) for item in items],
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, 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")), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
if since is None: full_cursor = _decode_full_delta_cursor(
return _full_external_function_role_mappings_delta_response(session, tenant) 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) 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, tenant_id: str | None = None,
account_id: str | None = None, account_id: str | None = None,
function_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), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:assign")), 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) query = query.filter(FunctionAssignment.account_id == account_id)
if function_id: if function_id:
query = query.filter(FunctionAssignment.function_id == function_id) query = query.filter(FunctionAssignment.function_id == function_id)
assignments = query.order_by(FunctionAssignment.created_at.desc()).all() assignments, pagination = _page_query(
return FunctionAssignmentListResponse(assignments=[_function_assignment_item(item) for item in assignments]) 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) @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( def list_function_delegations(
tenant_id: str | None = None, tenant_id: str | None = None,
account_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), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:delegate")), 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) query = session.query(FunctionDelegation).filter(FunctionDelegation.tenant_id == tenant.id)
if account_id: if account_id:
query = query.filter((FunctionDelegation.delegator_account_id == account_id) | (FunctionDelegation.delegate_account_id == 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() delegations, pagination = _page_query(
return FunctionDelegationListResponse(delegations=[_function_delegation_item(item) for item in delegations]) 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) @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] 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: def _full_system_roles_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse:
ensure_default_roles(session, None) ensure_default_roles(session, None)
session.commit() 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: def _full_system_accounts_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> SystemAccountListDeltaResponse:
ensure_default_roles(session, None) ensure_default_roles(session, None)
session.commit() 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) 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 page = cursor[0] if cursor is not None else 1
query = session.query(Account).order_by(Account.email.asc(), Account.id.asc()) 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) accounts, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-accounts", snapshot_sequence=snapshot_sequence)
return SystemAccountListDeltaResponse( 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), roles=_system_role_summaries_for_response(session, system_roles),
deleted=[], deleted=[],
watermark=watermark, watermark=watermark,
@@ -3223,7 +3363,7 @@ def _system_accounts_delta_response(session: Session, *, since: str, limit: int)
) )
] ]
return SystemAccountListDeltaResponse( 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())), roles=_system_role_summaries_for_response(session, list(visible_roles.values())),
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more), 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) ensure_default_roles(session, None)
session.commit() 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()) query = session.query(Account).order_by(Account.email.asc())
accounts, pagination = _page_query(query, page=page, page_size=page_size) accounts, pagination = _page_query(query, page=page, page_size=page_size)
return SystemAccountListResponse( 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), roles=_system_role_summaries_for_response(session, system_roles),
**pagination, **pagination,
) )

View File

@@ -249,11 +249,19 @@ def _resolve_api_key_principal_context(
) -> ResolvedPrincipalContext | None: ) -> ResolvedPrincipalContext | None:
# API keys remain supported for CLI/automation. Their permissions are the # API keys remain supported for CLI/automation. Their permissions are the
# intersection of the key grant and the owner's current tenant roles. # 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: if api_key is None:
return None return None
user = session.get(User, api_key.user_id) activity_touch_pending = session.is_modified(
account = session.get(Account, user.account_id) if user else None api_key,
include_collections=False,
)
user = api_key.user
account = user.account if user else None
tenant = session.get(Tenant, api_key.tenant_id) tenant = session.get(Tenant, api_key.tenant_id)
if ( if (
not user or not account or not tenant not user or not account or not tenant
@@ -277,7 +285,6 @@ def _resolve_api_key_principal_context(
extra_roles=idm_roles, extra_roles=idm_roles,
) )
effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or []) effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
session.commit()
principal = _build_principal_ref( principal = _build_principal_ref(
session, session,
api_key=api_key, api_key=api_key,
@@ -291,6 +298,8 @@ def _resolve_api_key_principal_context(
extra_roles=idm_roles, extra_roles=idm_roles,
authorization_context=authorization_context, authorization_context=authorization_context,
) )
if activity_touch_pending:
session.commit()
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key) 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, identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None, organization_directory: OrganizationDirectory | None,
) -> ResolvedPrincipalContext | 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: if auth_session is None:
return None return None
user = session.get(User, auth_session.user_id) activity_touch_pending = session.is_modified(
account = session.get(Account, auth_session.account_id) auth_session,
include_collections=False,
)
user = auth_session.user
account = auth_session.account
tenant = session.get(Tenant, auth_session.tenant_id) tenant = session.get(Tenant, auth_session.tenant_id)
if ( if (
not user or not account or not tenant not user or not account or not tenant
@@ -357,7 +374,6 @@ def _resolve_session_principal_context(
extra_roles=idm_roles, extra_roles=idm_roles,
) )
scopes = authorization_context.scopes scopes = authorization_context.scopes
session.commit()
principal = _build_principal_ref( principal = _build_principal_ref(
session, session,
auth_session=auth_session, auth_session=auth_session,
@@ -372,6 +388,8 @@ def _resolve_session_principal_context(
authorization_context=authorization_context, authorization_context=authorization_context,
include_system_roles=True, include_system_roles=True,
) )
if activity_touch_pending:
session.commit()
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session) return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)

View File

@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass 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.auth.tokens import generate_secret, hash_secret, verify_secret
from govoplan_access.backend.db.models import ApiKey, User from govoplan_access.backend.db.models import ApiKey, User
@@ -60,17 +60,36 @@ def create_api_key(
return CreatedApiKey(model=model, secret=secret) 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) 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() now = utc_now()
for candidate in candidates: for candidate in candidates:
expires_at = ensure_aware_utc(candidate.expires_at) expires_at = ensure_aware_utc(candidate.expires_at)
if expires_at and expires_at < now: if expires_at and expires_at < now:
continue continue
if verify_api_key(secret, candidate.key_hash): if verify_api_key(secret, candidate.key_hash):
candidate.last_used_at = now last_used_at = ensure_aware_utc(candidate.last_used_at)
session.add(candidate) 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 candidate
return None 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: def has_scope(api_key: ApiKey, required_scope: str) -> bool:
scopes = set(api_key.scopes or []) scopes = set(api_key.scopes or [])
return "*" in scopes or required_scope in scopes return "*" in scopes or required_scope in scopes

View File

@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from collections.abc import Iterable 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.auth.tokens import generate_secret, hash_secret, verify_secret
from govoplan_access.backend.db.models import ( 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) 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) 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: if not model:
return None return None
now = utc_now() now = utc_now()
expires_at = ensure_aware_utc(model.expires_at) expires_at = ensure_aware_utc(model.expires_at)
if expires_at is None or expires_at < now: if expires_at is None or expires_at < now:
return None return None
model.last_seen_at = now last_seen_at = ensure_aware_utc(model.last_seen_at)
session.add(model) 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 return model

View File

@@ -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()

View File

@@ -17,7 +17,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {