perf: batch authorization and activity updates
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
100
tests/test_auth_activity_touches.py
Normal file
100
tests/test_auth_activity_touches.py
Normal 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()
|
||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user