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 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:

View File

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

View File

@@ -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]

View File

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

View File

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

View File

@@ -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,15 +60,34 @@ 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):
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
@@ -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

View File

@@ -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,15 +104,37 @@ 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
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

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",
"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": {