intermittent commit

This commit is contained in:
2026-07-14 13:22:09 +02:00
parent f2afcaa09c
commit ab07075a67
17 changed files with 1821 additions and 586 deletions

View File

@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_access.backend.admin.service import ( from govoplan_access.backend.admin.service import (
@@ -41,6 +44,7 @@ from govoplan_access.backend.db.models import (
Tenant, Tenant,
User, User,
UserGroupMembership, UserGroupMembership,
UserRoleAssignment,
) )
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
@@ -82,13 +86,147 @@ def _resolve_tenant(
return tenant return tenant
def _role_summary(session: Session, role: Role) -> RoleSummary: def _tenant_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, tuple[int, int]]:
if not role_ids:
return {}
user_counts = {
role_id: count
for role_id, count in session.query(UserRoleAssignment.role_id, func.count(UserRoleAssignment.id))
.filter(UserRoleAssignment.role_id.in_(role_ids))
.group_by(UserRoleAssignment.role_id)
.all()
}
group_counts = {
role_id: count
for role_id, count in session.query(GroupRoleAssignment.role_id, func.count(GroupRoleAssignment.id))
.filter(GroupRoleAssignment.role_id.in_(role_ids))
.group_by(GroupRoleAssignment.role_id)
.all()
}
return {role_id: (int(user_counts.get(role_id, 0)), int(group_counts.get(role_id, 0))) for role_id in role_ids}
def _system_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, int]:
if not role_ids:
return {}
return {
role_id: int(count)
for role_id, count in session.query(SystemRoleAssignment.role_id, func.count(SystemRoleAssignment.id))
.filter(SystemRoleAssignment.role_id.in_(role_ids))
.group_by(SystemRoleAssignment.role_id)
.all()
}
def _accounts_by_id(session: Session, account_ids: list[str]) -> dict[str, Account]:
if not account_ids:
return {}
return {
account.id: account
for account in session.query(Account).filter(Account.id.in_(sorted(set(account_ids)))).all()
}
def _accounts_by_user_id(session: Session, user_ids: list[str]) -> dict[str, Account]:
if not user_ids:
return {}
return {
user.id: account
for user, account in session.query(User, Account)
.join(Account, Account.id == User.account_id)
.filter(User.id.in_(sorted(set(user_ids))))
.all()
}
def _group_member_ids_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[str]]:
grouped: dict[str, list[str]] = defaultdict(list)
if not group_ids:
return {}
for group_id, user_id in (
session.query(UserGroupMembership.group_id, UserGroupMembership.user_id)
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.group_id.in_(sorted(set(group_ids))))
.order_by(UserGroupMembership.group_id.asc(), UserGroupMembership.user_id.asc())
.all()
):
grouped[group_id].append(user_id)
return dict(grouped)
def _roles_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[Role]]:
grouped: dict[str, list[Role]] = defaultdict(list)
if not group_ids:
return {}
for group_id, role in (
session.query(GroupRoleAssignment.group_id, Role)
.join(Role, Role.id == GroupRoleAssignment.role_id)
.filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id.in_(sorted(set(group_ids))), Role.tenant_id == tenant_id)
.order_by(GroupRoleAssignment.group_id.asc(), Role.name.asc())
.all()
):
grouped[group_id].append(role)
return dict(grouped)
def _groups_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Group]]:
grouped: dict[str, list[Group]] = defaultdict(list)
if not user_ids:
return {}
for user_id, group in (
session.query(UserGroupMembership.user_id, Group)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.tenant_id == tenant_id,
UserGroupMembership.user_id.in_(sorted(set(user_ids))),
Group.is_active.is_(True),
)
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
.all()
):
grouped[user_id].append(group)
return dict(grouped)
def _roles_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Role]]:
grouped: dict[str, list[Role]] = defaultdict(list)
if not user_ids:
return {}
for user_id, role in (
session.query(UserRoleAssignment.user_id, Role)
.join(Role, Role.id == UserRoleAssignment.role_id)
.filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.user_id.in_(sorted(set(user_ids))),
Role.tenant_id == tenant_id,
)
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
.all()
):
grouped[user_id].append(role)
return dict(grouped)
def _role_summary(
session: Session,
role: Role,
*,
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
system_role_assignment_counts: dict[str, int] | None = None,
) -> RoleSummary:
group_count = 0 group_count = 0
if role.tenant_id is None: if role.tenant_id is None:
user_count = session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count() user_count = (
system_role_assignment_counts.get(role.id, 0)
if system_role_assignment_counts is not None
else session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
)
permission_level = "system" permission_level = "system"
else: else:
user_count, group_count = role_assignment_counts(session, role.id) user_count, group_count = (
tenant_role_assignment_counts.get(role.id, (0, 0))
if tenant_role_assignment_counts is not None
else role_assignment_counts(session, role.id)
)
permission_level = "tenant" permission_level = "tenant"
permissions = list(role.permissions or []) permissions = list(role.permissions or [])
return RoleSummary( return RoleSummary(
@@ -108,19 +246,35 @@ def _role_summary(session: Session, role: Role) -> RoleSummary:
) )
def _group_summary(session: Session, group: Group, *, include_members: bool = True) -> GroupSummary: def _group_summary(
member_ids = [ session: Session,
row[0] group: Group,
for row in session.query(UserGroupMembership.user_id) *,
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id) include_members: bool = True,
.all() member_ids_by_group: dict[str, list[str]] | None = None,
] roles_by_group: dict[str, list[Role]] | None = None,
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
) -> GroupSummary:
member_ids = (
member_ids_by_group.get(group.id, [])
if member_ids_by_group is not None
else [
row[0]
for row in session.query(UserGroupMembership.user_id)
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id)
.all()
]
)
roles = ( roles = (
session.query(Role) roles_by_group.get(group.id, [])
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id) if roles_by_group is not None
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id) else (
.order_by(Role.name.asc()) session.query(Role)
.all() .join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id)
.order_by(Role.name.asc())
.all()
)
) )
return GroupSummary( return GroupSummary(
id=group.id, id=group.id,
@@ -130,7 +284,7 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr
is_active=group.is_active, is_active=group.is_active,
member_count=len(member_ids), member_count=len(member_ids),
member_ids=member_ids if include_members else [], member_ids=member_ids if include_members else [],
roles=[_role_summary(session, role) for role in roles], roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
created_at=group.created_at, created_at=group.created_at,
updated_at=group.updated_at, updated_at=group.updated_at,
system_template_id=group.system_template_id, system_template_id=group.system_template_id,
@@ -145,12 +299,18 @@ def _user_item(
owner_ids: set[str] | None = None, owner_ids: set[str] | None = None,
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
organization_directory: OrganizationDirectory | None = None, organization_directory: OrganizationDirectory | None = None,
accounts_by_id: dict[str, Account] | None = None,
groups_by_user: dict[str, list[Group]] | None = None,
roles_by_user: dict[str, list[Role]] | None = None,
group_member_ids_by_group: dict[str, list[str]] | None = None,
group_roles_by_group: dict[str, list[Role]] | None = None,
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
) -> UserAdminItem: ) -> UserAdminItem:
account = session.get(Account, user.account_id) account = accounts_by_id.get(user.account_id) if accounts_by_id is not None else session.get(Account, user.account_id)
if account is None: if account is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
groups = collect_user_groups(session, user) groups = groups_by_user.get(user.id, []) if groups_by_user is not None else collect_user_groups(session, user)
roles = collect_direct_user_roles(session, user) roles = roles_by_user.get(user.id, []) if roles_by_user is not None else collect_direct_user_roles(session, user)
external_roles = ( external_roles = (
collect_external_function_roles( collect_external_function_roles(
session, session,
@@ -176,8 +336,18 @@ def _user_item(
account_is_active=account.is_active, account_is_active=account.is_active,
password_reset_required=account.password_reset_required, password_reset_required=account.password_reset_required,
last_login_at=account.last_login_at, last_login_at=account.last_login_at,
groups=[_group_summary(session, group, include_members=False) for group in groups], groups=[
roles=[_role_summary(session, role) for role in roles], _group_summary(
session,
group,
include_members=False,
member_ids_by_group=group_member_ids_by_group,
roles_by_group=group_roles_by_group,
tenant_role_assignment_counts=tenant_role_assignment_counts,
)
for group in groups
],
roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)), function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)),
function_delegation_ids=collect_function_delegation_ids(session, user), function_delegation_ids=collect_function_delegation_ids(session, user),
effective_scopes=expand_scopes(effective_scopes), effective_scopes=expand_scopes(effective_scopes),
@@ -226,9 +396,12 @@ def _system_account_item(session: Session, account: Account) -> SystemAccountIte
) )
def _api_key_item(session: Session, item: ApiKey) -> ApiKeyAdminItem: def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
user = session.get(User, item.user_id) if accounts_by_user_id is not None:
account = session.get(Account, user.account_id) if user else None account = accounts_by_user_id.get(item.user_id)
else:
user = session.get(User, item.user_id)
account = session.get(Account, user.account_id) if user else None
return ApiKeyAdminItem( return ApiKeyAdminItem(
id=item.id, id=item.id,
user_id=item.user_id, user_id=item.user_id,

View File

@@ -3,41 +3,10 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
class PermissionItem(BaseModel): class PermissionItem(BaseModel):
@@ -98,6 +67,13 @@ class TenantOwnerCandidateListResponse(BaseModel):
accounts: list[TenantOwnerCandidate] accounts: list[TenantOwnerCandidate]
class PagedListResponse(BaseModel):
total: int = 0
page: int = 1
page_size: int = 500
pages: int = 1
class TenantCreateRequest(BaseModel): class TenantCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -179,7 +155,7 @@ class IdentityAdminItem(BaseModel):
updated_at: datetime updated_at: datetime
class IdentityListResponse(BaseModel): class IdentityListResponse(PagedListResponse):
identities: list[IdentityAdminItem] identities: list[IdentityAdminItem]
@@ -529,7 +505,7 @@ class ResourceAccessExplanationResponse(BaseModel):
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list) provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
class UserListResponse(BaseModel): class UserListResponse(PagedListResponse):
users: list[UserAdminItem] users: list[UserAdminItem]
@@ -567,7 +543,7 @@ class UserUpdateRequest(BaseModel):
role_ids: list[str] | None = None role_ids: list[str] | None = None
class GroupListResponse(BaseModel): class GroupListResponse(PagedListResponse):
groups: list[GroupSummary] groups: list[GroupSummary]
@@ -599,7 +575,7 @@ class GroupUpdateRequest(BaseModel):
role_ids: list[str] | None = None role_ids: list[str] | None = None
class RoleListResponse(BaseModel): class RoleListResponse(PagedListResponse):
roles: list[RoleSummary] roles: list[RoleSummary]
@@ -638,7 +614,7 @@ class SystemAccountItem(BaseModel):
last_login_at: datetime | None = None last_login_at: datetime | None = None
class SystemAccountListResponse(BaseModel): class SystemAccountListResponse(PagedListResponse):
accounts: list[SystemAccountItem] accounts: list[SystemAccountItem]
roles: list[RoleSummary] roles: list[RoleSummary]
@@ -704,42 +680,6 @@ class PolicySourceStepItem(BaseModel):
policy: dict[str, Any] = Field(default_factory=dict) policy: dict[str, Any] = Field(default_factory=dict)
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatchItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=False)
class PrivacyRetentionPolicyScopeRequest(BaseModel): class PrivacyRetentionPolicyScopeRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -912,7 +852,7 @@ class ApiKeyAdminItem(BaseModel):
created_at: datetime created_at: datetime
class ApiKeyListResponse(BaseModel): class ApiKeyListResponse(PagedListResponse):
api_keys: list[ApiKeyAdminItem] api_keys: list[ApiKeyAdminItem]

View File

@@ -1,9 +1,17 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import ( from govoplan_core.api.v1.schemas import (
AuthGroupsResponse,
AuthProfileResponse,
AuthRolesResponse,
AuthShellResponse,
AuthSessionResponse,
AuthSessionUserInfo,
GroupInfo, GroupInfo,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
@@ -22,9 +30,9 @@ from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityD
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
from govoplan_core.admin.settings import get_system_settings from govoplan_core.admin.settings import get_system_settings
from govoplan_core.audit.logging import audit_from_principal from govoplan_core.audit.logging import audit_event
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
from govoplan_access.backend.db.models import Account, Tenant, User from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Group, Role, Tenant, User
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_core.i18n import ( from govoplan_core.i18n import (
i18n_settings, i18n_settings,
@@ -36,24 +44,39 @@ from govoplan_core.i18n import (
tenant_enabled_language_codes, tenant_enabled_language_codes,
user_enabled_language_codes, user_enabled_language_codes,
) )
from govoplan_access.backend.permissions.catalog import normalize_email, scopes_grant from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes, normalize_email, scopes_grant
from govoplan_core.security.time import utc_now from govoplan_core.security.time import utc_now
from govoplan_core.settings import settings from govoplan_core.settings import settings
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.passwords import verify_password from govoplan_access.backend.security.passwords import verify_password
from govoplan_access.backend.security.sessions import ( from govoplan_access.backend.security.sessions import (
authenticate_session_token,
collect_user_authorization_context,
collect_system_roles, collect_system_roles,
collect_tenant_memberships, collect_tenant_memberships,
collect_user_groups, collect_user_groups,
collect_user_roles, collect_user_roles,
collect_user_scopes, collect_user_scopes,
create_auth_session, create_auth_session,
verify_auth_session_csrf,
) )
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@dataclass(slots=True)
class AuthContext:
account: Account
user: User
tenant: Tenant
auth_method: str
source: str
auth_session: AuthSession | None = None
api_key: ApiKey | None = None
def _cookie_samesite() -> str: def _cookie_samesite() -> str:
value = settings.auth_cookie_samesite.lower().strip() value = settings.auth_cookie_samesite.lower().strip()
if value not in {"lax", "strict", "none"}: if value not in {"lax", "strict", "none"}:
@@ -125,6 +148,18 @@ def _user_info(
) )
def _session_user_info(user: User, account: Account) -> AuthSessionUserInfo:
return AuthSessionUserInfo(
id=user.id,
account_id=account.id,
email=account.email,
display_name=account.display_name or user.display_name,
tenant_display_name=user.display_name,
is_tenant_admin=user.is_tenant_admin,
password_reset_required=account.password_reset_required,
)
def _roles_info(roles, *, level: str = "tenant") -> list[RoleInfo]: def _roles_info(roles, *, level: str = "tenant") -> list[RoleInfo]:
return [ return [
RoleInfo( RoleInfo(
@@ -142,10 +177,20 @@ def _groups_info(groups) -> list[GroupInfo]:
return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups] return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups]
def _tenant_memberships(session: Session, account: Account) -> list[TenantMembershipInfo]: def _tenant_memberships(
session: Session,
account: Account,
*,
active_user_id: str | None = None,
active_tenant_roles: list[Role] | None = None,
) -> list[TenantMembershipInfo]:
memberships: list[TenantMembershipInfo] = [] memberships: list[TenantMembershipInfo] = []
for user, tenant in collect_tenant_memberships(session, account): for user, tenant in collect_tenant_memberships(session, account):
roles = collect_user_roles(session, user) roles = (
active_tenant_roles
if active_tenant_roles is not None and user.id == active_user_id
else collect_user_roles(session, user)
)
memberships.append( memberships.append(
TenantMembershipInfo( TenantMembershipInfo(
id=tenant.id, id=tenant.id,
@@ -159,6 +204,20 @@ def _tenant_memberships(session: Session, account: Account) -> list[TenantMember
return memberships return memberships
def _tenant_membership_summaries(session: Session, account: Account) -> list[TenantMembershipInfo]:
return [
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active and user.is_active,
default_locale=tenant.default_locale,
roles=[],
)
for user, tenant in collect_tenant_memberships(session, account)
]
def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[str, object]: def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[str, object]:
system_settings = get_system_settings(session) system_settings = get_system_settings(session)
system_payload = system_i18n_payload(system_settings) system_payload = system_i18n_payload(system_settings)
@@ -214,6 +273,254 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
return account, row[0], row[1] return account, row[0], row[1]
def _extract_auth_token(request: Request) -> tuple[str | None, str]:
x_api_key = request.headers.get("x-api-key")
if x_api_key:
return x_api_key.strip(), "api_key"
authorization = request.headers.get("authorization")
if authorization and authorization.lower().startswith("bearer "):
return authorization[7:].strip(), "bearer"
cookie_token = request.cookies.get(settings.auth_session_cookie_name)
if cookie_token:
return cookie_token.strip(), "cookie"
return None, "none"
def _active_context_or_401(
*,
user: User | None,
account: Account | None,
tenant: Tenant | None,
expected_tenant_id: str,
) -> tuple[Account, User, Tenant]:
if (
not user or not account or not tenant
or not user.is_active or not account.is_active or not tenant.is_active
or user.account_id != account.id
or user.tenant_id != expected_tenant_id
or tenant.id != expected_tenant_id
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session")
return account, user, tenant
def _session_response(
*,
account: Account,
user: User,
tenant: Tenant,
auth_method: str,
auth_session: AuthSession | None = None,
api_key: ApiKey | None = None,
) -> AuthSessionResponse:
active_tenant = _tenant_info(tenant)
return AuthSessionResponse(
authenticated=True,
auth_method=auth_method, # type: ignore[arg-type]
user=_session_user_info(user, account),
tenant=active_tenant,
active_tenant=active_tenant,
session_id=auth_session.id if auth_session else None,
api_key_id=api_key.id if api_key else None,
expires_at=auth_session.expires_at if auth_session else api_key.expires_at if api_key else None,
)
def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
token, source = _extract_auth_token(request)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
api_key = authenticate_api_key(session, token) if source != "cookie" else None
if api_key is not None:
user = session.get(User, api_key.user_id)
account = session.get(Account, user.account_id) if user else None
tenant = session.get(Tenant, api_key.tenant_id)
account, user, tenant = _active_context_or_401(
user=user,
account=account,
tenant=tenant,
expected_tenant_id=api_key.tenant_id,
)
return AuthContext(
account=account,
user=user,
tenant=tenant,
auth_method="api_key",
source=source,
api_key=api_key,
)
auth_session = authenticate_session_token(session, token)
if auth_session is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
user = session.get(User, auth_session.user_id)
account = session.get(Account, auth_session.account_id)
tenant = session.get(Tenant, auth_session.tenant_id)
account, user, tenant = _active_context_or_401(
user=user,
account=account,
tenant=tenant,
expected_tenant_id=auth_session.tenant_id,
)
return AuthContext(
account=account,
user=user,
tenant=tenant,
auth_method="session",
source=source,
auth_session=auth_session,
)
def _shell_response(
session: Session,
*,
account: Account,
user: User,
tenant: Tenant,
scopes: list[str],
auth_method: str,
auth_session: AuthSession | None = None,
api_key: ApiKey | None = None,
) -> AuthShellResponse:
active_tenant = _tenant_info(tenant)
memberships = (
_tenant_membership_summaries(session, account)
if auth_session is not None
else [
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active and user.is_active,
default_locale=tenant.default_locale,
roles=[],
)
]
)
return AuthShellResponse(
user=_user_info(user, account),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=memberships,
scopes=scopes,
principal=PrincipalContextInfo(
account_id=account.id,
membership_id=user.id,
tenant_id=tenant.id,
scopes=scopes,
auth_method=auth_method, # type: ignore[arg-type]
api_key_id=api_key.id if api_key else None,
session_id=auth_session.id if auth_session else None,
email=account.email,
display_name=account.display_name or user.display_name,
),
)
def _resolve_lightweight_session(request: Request, session: Session) -> AuthSessionResponse:
context = _resolve_auth_context(request, session)
return _session_response(
account=context.account,
user=context.user,
tenant=context.tenant,
auth_method=context.auth_method,
auth_session=context.auth_session,
api_key=context.api_key,
)
def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse:
context = _resolve_auth_context(request, session)
if context.api_key is not None:
user_scopes = collect_user_scopes(session, context.user, include_system=False)
scopes = intersect_api_key_scopes(user_scopes, context.api_key.scopes or [])
return _shell_response(
session,
account=context.account,
user=context.user,
tenant=context.tenant,
scopes=scopes,
auth_method="api_key",
api_key=context.api_key,
)
scopes = collect_user_scopes(session, context.user, include_system=True)
return _shell_response(
session,
account=context.account,
user=context.user,
tenant=context.tenant,
scopes=scopes,
auth_method="session",
auth_session=context.auth_session,
)
def _profile_response(session: Session, context: AuthContext) -> AuthProfileResponse:
languages = _language_context(session, tenant=context.tenant, user=context.user)
tenant_enabled = list(languages["tenant_enabled_language_codes"])
user_enabled = list(languages["user_enabled_language_codes"])
preferred_language = str(languages["preferred_language"])
active_tenant = _tenant_info(context.tenant, enabled_language_codes=tenant_enabled)
return AuthProfileResponse(
user=_user_info(
context.user,
context.account,
preferred_language=preferred_language,
enabled_language_codes=user_enabled,
),
tenant=active_tenant,
active_tenant=active_tenant,
available_languages=languages["available_languages"],
enabled_language_codes=tenant_enabled,
default_language=preferred_language,
)
def _roles_response(session: Session, context: AuthContext) -> AuthRolesResponse:
tenant_roles = collect_user_roles(session, context.user)
system_roles = collect_system_roles(session, context.account) if context.auth_session is not None else []
return AuthRolesResponse(roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"))
def _groups_response(session: Session, context: AuthContext) -> AuthGroupsResponse:
return AuthGroupsResponse(groups=_groups_info(collect_user_groups(session, context.user)))
def _verify_profile_mutation_allowed(request: Request, context: AuthContext) -> None:
if context.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile")
if context.source != "cookie":
return
header_token = request.headers.get("x-csrf-token")
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name)
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(context.auth_session, header_token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
def _roles_from_principal(session: Session, principal: ApiPrincipal, *, tenant_id: str) -> tuple[list[Role], list[Role]]:
roles = (
session.query(Role)
.filter(Role.id.in_(sorted(principal.role_ids)))
.order_by(Role.name.asc())
.all()
)
tenant_roles = [role for role in roles if role.tenant_id == tenant_id]
system_roles = [role for role in roles if role.tenant_id is None and principal.auth_session is not None]
return tenant_roles, system_roles
def _groups_from_principal(session: Session, principal: ApiPrincipal, *, tenant_id: str) -> list[Group]:
return (
session.query(Group)
.filter(Group.tenant_id == tenant_id, Group.id.in_(sorted(principal.group_ids)))
.order_by(Group.name.asc())
.all()
)
def _me_response( def _me_response(
session: Session, session: Session,
*, *,
@@ -229,17 +536,42 @@ def _me_response(
include_all_memberships: bool = True, include_all_memberships: bool = True,
identity_directory: IdentityDirectory | None = None, identity_directory: IdentityDirectory | None = None,
identity_id: str | None = None, identity_id: str | None = None,
tenant_roles: list[Role] | None = None,
system_roles: list[Role] | None = None,
groups: list[Group] | None = None,
function_assignment_ids: tuple[str, ...] | None = None,
delegation_ids: tuple[str, ...] | None = None,
) -> MeResponse: ) -> MeResponse:
tenant_roles = collect_user_roles(session, user) if tenant_roles is None:
system_roles = collect_system_roles(session, account) if include_system else [] tenant_roles = collect_user_roles(session, user)
groups = collect_user_groups(session, user) if system_roles is None:
system_roles = collect_system_roles(session, account) if include_system else []
elif not include_system:
system_roles = []
if groups is None:
groups = collect_user_groups(session, user)
scopes = effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system) scopes = effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system)
resolved_function_assignment_ids = (
function_assignment_ids
if function_assignment_ids is not None
else tuple(collect_function_assignment_ids(session, user))
)
resolved_delegation_ids = (
delegation_ids
if delegation_ids is not None
else tuple(collect_function_delegation_ids(session, user))
)
languages = _language_context(session, tenant=tenant, user=user) languages = _language_context(session, tenant=tenant, user=user)
tenant_enabled = list(languages["tenant_enabled_language_codes"]) tenant_enabled = list(languages["tenant_enabled_language_codes"])
user_enabled = list(languages["user_enabled_language_codes"]) user_enabled = list(languages["user_enabled_language_codes"])
preferred_language = str(languages["preferred_language"]) preferred_language = str(languages["preferred_language"])
active_tenant = _tenant_info(tenant, enabled_language_codes=tenant_enabled) active_tenant = _tenant_info(tenant, enabled_language_codes=tenant_enabled)
memberships = _tenant_memberships(session, account) if include_all_memberships else [ memberships = _tenant_memberships(
session,
account,
active_user_id=user.id,
active_tenant_roles=tenant_roles,
) if include_all_memberships else [
TenantMembershipInfo( TenantMembershipInfo(
id=tenant.id, id=tenant.id,
slug=tenant.slug, slug=tenant.slug,
@@ -266,8 +598,8 @@ def _me_response(
scopes=frozenset(scopes), scopes=frozenset(scopes),
group_ids=frozenset(group.id for group in groups), group_ids=frozenset(group.id for group in groups),
role_ids=frozenset(role.id for role in tenant_roles + system_roles), role_ids=frozenset(role.id for role in tenant_roles + system_roles),
function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)), function_assignment_ids=frozenset(resolved_function_assignment_ids),
delegation_ids=frozenset(collect_function_delegation_ids(session, user)), delegation_ids=frozenset(resolved_delegation_ids),
auth_method=auth_method, auth_method=auth_method,
api_key_id=api_key_id, api_key_id=api_key_id,
session_id=session_id, session_id=session_id,
@@ -286,9 +618,18 @@ def _me_response(
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)): def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
account, user, tenant = _resolve_login_user(session, payload) account, user, tenant = _resolve_login_user(session, payload)
identity_directory = _identity_directory_from_request(request) identity_directory = _identity_directory_from_request(request)
me_payload = _me_response(session, account=account, user=user, tenant=tenant, identity_directory=identity_directory) authorization_context = collect_user_authorization_context(
session,
user,
account=account,
include_system=True,
)
tenant_roles = authorization_context.tenant_roles
system_roles = authorization_context.system_roles
groups = authorization_context.groups
effective_scopes = authorization_context.scopes
maintenance_mode = saved_maintenance_mode(session) maintenance_mode = saved_maintenance_mode(session)
if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE): if maintenance_mode.enabled and not scopes_grant(effective_scopes, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=maintenance_response_detail(maintenance_mode), detail=maintenance_response_detail(maintenance_mode),
@@ -312,19 +653,66 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
account=account, account=account,
user=user, user=user,
tenant=tenant, tenant=tenant,
effective_scopes=me_payload.scopes, effective_scopes=effective_scopes,
auth_method="session", auth_method="session",
session_id=created.model.id, session_id=created.model.id,
identity_directory=identity_directory, identity_directory=identity_directory,
tenant_roles=tenant_roles,
system_roles=system_roles,
groups=groups,
function_assignment_ids=authorization_context.function_assignment_ids,
delegation_ids=authorization_context.function_delegation_ids,
).model_dump(), ).model_dump(),
) )
@router.get("/session", response_model=AuthSessionResponse)
def auth_session(request: Request, session: Session = Depends(get_session)):
return _resolve_lightweight_session(request, session)
@router.get("/shell", response_model=AuthShellResponse)
def auth_shell(request: Request, session: Session = Depends(get_session)):
return _resolve_shell_auth(request, session)
@router.get("/profile", response_model=AuthProfileResponse)
def auth_profile(request: Request, session: Session = Depends(get_session)):
return _profile_response(session, _resolve_auth_context(request, session))
@router.get("/roles", response_model=AuthRolesResponse)
def auth_roles(request: Request, session: Session = Depends(get_session)):
context = _resolve_auth_context(request, session)
authorization_context = collect_user_authorization_context(
session,
context.user,
account=context.account,
include_system=context.auth_session is not None,
)
return AuthRolesResponse(
roles=_roles_info(authorization_context.tenant_roles)
+ _roles_info(authorization_context.system_roles, level="system")
)
@router.get("/groups", response_model=AuthGroupsResponse)
def auth_groups(request: Request, session: Session = Depends(get_session)):
return _groups_response(session, _resolve_auth_context(request, session))
@router.get("/me", response_model=MeResponse) @router.get("/me", response_model=MeResponse)
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)): def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
tenant = session.get(Tenant, principal.tenant_id) tenant = session.get(Tenant, principal.tenant_id)
if tenant is None: if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
tenant_roles: list[Role] | None = None
system_roles: list[Role] | None = None
groups: list[Group] | None = None
if principal.role_ids:
tenant_roles, system_roles = _roles_from_principal(session, principal, tenant_id=tenant.id)
if principal.group_ids:
groups = _groups_from_principal(session, principal, tenant_id=tenant.id)
return _me_response( return _me_response(
session, session,
account=principal.account, account=principal.account,
@@ -338,33 +726,42 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
include_system=principal.auth_session is not None, include_system=principal.auth_session is not None,
include_all_memberships=principal.auth_session is not None, include_all_memberships=principal.auth_session is not None,
identity_id=principal.principal.identity_id, identity_id=principal.principal.identity_id,
tenant_roles=tenant_roles,
system_roles=system_roles,
groups=groups,
function_assignment_ids=tuple(principal.function_assignment_ids),
delegation_ids=tuple(principal.delegation_ids),
) )
@router.patch("/profile", response_model=MeResponse) @router.patch("/profile", response_model=AuthProfileResponse)
def update_profile( def update_profile(
payload: ProfileUpdateRequest, payload: ProfileUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal), request: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
if principal.auth_session is None: context = _resolve_auth_context(request, session)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile") _verify_profile_mutation_allowed(request, context)
if "display_name" in payload.model_fields_set: if "display_name" in payload.model_fields_set:
principal.account.display_name = payload.display_name.strip() if payload.display_name else None context.account.display_name = payload.display_name.strip() if payload.display_name else None
session.add(principal.account) session.add(context.account)
if "tenant_display_name" in payload.model_fields_set: if "tenant_display_name" in payload.model_fields_set:
principal.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None context.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
session.add(principal.user) session.add(context.user)
next_settings = dict(principal.user.settings or {}) next_settings = dict(context.user.settings or {})
settings_changed = False settings_changed = False
if {"preferred_language", "enabled_language_codes"}.intersection(payload.model_fields_set): if {"preferred_language", "enabled_language_codes"}.intersection(payload.model_fields_set):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
system_settings = get_system_settings(session) system_settings = get_system_settings(session)
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale) system_enabled = system_enabled_language_codes(
tenant_enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale) system_settings.settings,
default_locale=system_settings.default_locale,
)
tenant_enabled = tenant_enabled_language_codes(
context.tenant.settings,
system_enabled,
default_locale=context.tenant.default_locale,
)
next_i18n = i18n_settings(next_settings) next_i18n = i18n_settings(next_settings)
if "preferred_language" in payload.model_fields_set: if "preferred_language" in payload.model_fields_set:
preferred = normalize_language_code(payload.preferred_language) preferred = normalize_language_code(payload.preferred_language)
@@ -394,35 +791,23 @@ def update_profile(
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump() next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
settings_changed = True settings_changed = True
if settings_changed: if settings_changed:
principal.user.settings = next_settings context.user.settings = next_settings
session.add(principal.user) session.add(context.user)
audit_from_principal( audit_event(
session, session,
principal, tenant_id=context.tenant.id,
user_id=context.user.id,
action="profile.updated", action="profile.updated",
object_type="account", object_type="account",
object_id=principal.account.id, object_id=context.account.id,
details={"fields": sorted(payload.model_fields_set)}, details={"fields": sorted(payload.model_fields_set)},
) )
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
session.commit() session.commit()
return _me_response( return _profile_response(session, context)
session,
account=principal.account,
user=principal.user,
tenant=tenant,
auth_method=principal.auth_method,
session_id=principal.session_id,
include_system=True,
include_all_memberships=True,
identity_id=principal.principal.identity_id,
)
@router.post("/switch-tenant", response_model=MeResponse) @router.post("/switch-tenant", response_model=AuthShellResponse)
def switch_tenant( def switch_tenant(
payload: SwitchTenantRequest, payload: SwitchTenantRequest,
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
@@ -441,14 +826,20 @@ def switch_tenant(
if membership is None: if membership is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found")
session.commit() session.commit()
return _me_response( authorization_context = collect_user_authorization_context(
session,
membership,
account=principal.account,
include_system=True,
)
return _shell_response(
session, session,
account=principal.account, account=principal.account,
user=membership, user=membership,
tenant=tenant, tenant=tenant,
scopes=authorization_context.scopes,
auth_method="session", auth_method="session",
session_id=principal.session_id, auth_session=principal.auth_session,
identity_id=principal.principal.identity_id,
) )

View File

@@ -41,15 +41,23 @@ from govoplan_access.backend.admin.service import (
update_system_role, update_system_role,
) )
from govoplan_access.backend.api.v1.admin_common import ( from govoplan_access.backend.api.v1.admin_common import (
_accounts_by_id,
_accounts_by_user_id,
_api_key_item, _api_key_item,
_group_member_ids_by_group_id,
_group_summary, _group_summary,
_groups_by_user_id,
_http_admin_error, _http_admin_error,
_require_permission, _require_permission,
_require_system_role_assignment, _require_system_role_assignment,
_resolve_tenant, _resolve_tenant,
_roles_by_group_id,
_roles_by_user_id,
_role_summary, _role_summary,
_set_system_memberships, _set_system_memberships,
_system_role_assignment_counts,
_system_account_item, _system_account_item,
_tenant_role_assignment_counts,
_user_item, _user_item,
) )
from govoplan_access.backend.api.v1.admin_schemas import ( from govoplan_access.backend.api.v1.admin_schemas import (
@@ -213,6 +221,45 @@ ACCESS_FUNCTION_DELEGATIONS_COLLECTION = "access.admin.function_delegations"
ACCESS_SYSTEM_ROLES_COLLECTION = "access.admin.system_roles" ACCESS_SYSTEM_ROLES_COLLECTION = "access.admin.system_roles"
ACCESS_SYSTEM_ACCOUNTS_COLLECTION = "access.admin.system_accounts" ACCESS_SYSTEM_ACCOUNTS_COLLECTION = "access.admin.system_accounts"
ACCESS_API_KEYS_COLLECTION = "access.admin.api_keys" ACCESS_API_KEYS_COLLECTION = "access.admin.api_keys"
ACCESS_FULL_CURSOR_PREFIX = "full:"
def _page_query(query, *, page: int, page_size: int):
total = query.order_by(None).count()
pages = max(1, (total + page_size - 1) // page_size)
items = query.offset((page - 1) * page_size).limit(page_size).all()
return items, {"total": total, "page": page, "page_size": page_size, "pages": pages}
def _encode_full_delta_cursor(scope: str, *, page: int, snapshot_sequence: int) -> str:
return f"{ACCESS_FULL_CURSOR_PREFIX}{scope}:{int(page)}:{int(snapshot_sequence)}"
def _decode_full_delta_cursor(value: str | None, *, scope: str) -> tuple[int, int] | None:
if not value or not value.startswith(ACCESS_FULL_CURSOR_PREFIX):
return None
parts = value.split(":", 3)
if len(parts) != 4 or parts[1] != scope:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid full snapshot cursor")
try:
page = int(parts[2])
snapshot_sequence = int(parts[3])
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid full snapshot cursor") from exc
if page < 1 or snapshot_sequence < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid full snapshot cursor")
return page, snapshot_sequence
def _full_delta_page(query, *, page: int, page_size: int, scope: str, snapshot_sequence: int):
items, pagination = _page_query(query, page=page, page_size=page_size)
has_more = page < pagination["pages"]
watermark = (
_encode_full_delta_cursor(scope, page=page + 1, snapshot_sequence=snapshot_sequence)
if has_more
else encode_sequence_watermark(snapshot_sequence)
)
return items, pagination, watermark, has_more
def _access_delta_watermark(session: Session, tenant_id: str | None, collections: tuple[str, ...]) -> str: def _access_delta_watermark(session: Session, tenant_id: str | None, collections: tuple[str, ...]) -> str:
@@ -318,14 +365,35 @@ def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {', '.join(scopes)}") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {', '.join(scopes)}")
def _identity_item(session: Session, identity: Identity) -> IdentityAdminItem: def _identity_account_links_by_identity(session: Session, identities: list[Identity]) -> dict[str, list[tuple[IdentityAccountLink, Account]]]:
identity_ids = [identity.id for identity in identities]
if not identity_ids:
return {}
grouped: dict[str, list[tuple[IdentityAccountLink, Account]]] = {}
rows = ( rows = (
session.query(IdentityAccountLink, Account) session.query(IdentityAccountLink, Account)
.join(Account, Account.id == IdentityAccountLink.account_id) .join(Account, Account.id == IdentityAccountLink.account_id)
.filter(IdentityAccountLink.identity_id == identity.id) .filter(IdentityAccountLink.identity_id.in_(identity_ids))
.order_by(IdentityAccountLink.is_primary.desc(), Account.email.asc()) .order_by(IdentityAccountLink.identity_id.asc(), IdentityAccountLink.is_primary.desc(), Account.email.asc())
.all() .all()
) )
for link, account in rows:
grouped.setdefault(link.identity_id, []).append((link, account))
return grouped
def _identity_item(session: Session, identity: Identity, *, account_links_by_identity: dict[str, list[tuple[IdentityAccountLink, Account]]] | None = None) -> IdentityAdminItem:
rows = (
account_links_by_identity.get(identity.id, [])
if account_links_by_identity is not None
else (
session.query(IdentityAccountLink, Account)
.join(Account, Account.id == IdentityAccountLink.account_id)
.filter(IdentityAccountLink.identity_id == identity.id)
.order_by(IdentityAccountLink.is_primary.desc(), Account.email.asc())
.all()
)
)
return IdentityAdminItem( return IdentityAdminItem(
id=identity.id, id=identity.id,
display_name=identity.display_name, display_name=identity.display_name,
@@ -1073,12 +1141,16 @@ def approve_configuration_change_request_endpoint(
@router.get("/identities", response_model=IdentityListResponse) @router.get("/identities", response_model=IdentityListResponse)
def list_identities( def list_identities(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "access:account:read")), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "access:account:read")),
): ):
del principal del principal
identities = session.query(Identity).order_by(Identity.display_name.asc(), Identity.created_at.asc()).all() query = session.query(Identity).order_by(Identity.display_name.asc(), Identity.created_at.asc())
return IdentityListResponse(identities=[_identity_item(session, item) for item in identities]) identities, pagination = _page_query(query, page=page, page_size=page_size)
account_links_by_identity = _identity_account_links_by_identity(session, identities)
return IdentityListResponse(identities=[_identity_item(session, item, account_links_by_identity=account_links_by_identity) for item in identities], **pagination)
@router.post("/identities", response_model=IdentityAdminItem, status_code=status.HTTP_201_CREATED) @router.post("/identities", response_model=IdentityAdminItem, status_code=status.HTTP_201_CREATED)
@@ -1952,24 +2024,67 @@ def revoke_function_delegation(
return None return None
def _full_users_delta_response(session: Session, tenant: Tenant) -> UserListDeltaResponse: def _user_items_for_response(session: Session, tenant: Tenant, users: list[User]) -> list[UserAdminItem]:
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all() user_ids = [user.id for user in users]
accounts_by_id = _accounts_by_id(session, [user.account_id for user in users])
groups_by_user = _groups_by_user_id(session, tenant_id=tenant.id, user_ids=user_ids)
roles_by_user = _roles_by_user_id(session, tenant_id=tenant.id, user_ids=user_ids)
group_ids = sorted({group.id for groups in groups_by_user.values() for group in groups})
group_member_ids_by_group = _group_member_ids_by_group_id(session, tenant_id=tenant.id, group_ids=group_ids)
group_roles_by_group = _roles_by_group_id(session, tenant_id=tenant.id, group_ids=group_ids)
role_ids = sorted(
{
role.id
for roles in roles_by_user.values()
for role in roles
}
| {
role.id
for roles in group_roles_by_group.values()
for role in roles
}
)
tenant_role_counts = _tenant_role_assignment_counts(session, role_ids)
owner_ids = tenant_owner_user_ids(session, tenant.id) owner_ids = tenant_owner_user_ids(session, tenant.id)
idm_directory = _optional_idm_directory() idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory() organization_directory = _optional_organization_directory()
return [
_user_item_for_response(
session,
user,
owner_ids=owner_ids,
idm_directory=idm_directory,
organization_directory=organization_directory,
accounts_by_id=accounts_by_id,
groups_by_user=groups_by_user,
roles_by_user=roles_by_user,
group_member_ids_by_group=group_member_ids_by_group,
group_roles_by_group=group_roles_by_group,
tenant_role_assignment_counts=tenant_role_counts,
)
for user in users
]
def _full_users_delta_response(session: Session, tenant: Tenant, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> UserListDeltaResponse:
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_USERS_COLLECTION,))
page = cursor[0] if cursor is not None else 1
query = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc(), User.id.asc())
users, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="users", snapshot_sequence=snapshot_sequence)
return UserListDeltaResponse( return UserListDeltaResponse(
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in users], users=_user_items_for_response(session, tenant, users),
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> UserListDeltaResponse: def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> UserListDeltaResponse:
entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), since=since, limit=limit) entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), since=since, limit=limit)
if entries is None: if entries is None:
return _full_users_delta_response(session, tenant) return _full_users_delta_response(session, tenant, limit=limit)
changed_ids = _changed_ids(entries, "access_user") changed_ids = _changed_ids(entries, "access_user")
visible = { visible = {
user.id: user user.id: user
@@ -1978,16 +2093,13 @@ def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit
if changed_ids else [] if changed_ids else []
) )
} }
owner_ids = tenant_owner_user_ids(session, tenant.id)
idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory()
deleted = [ deleted = [
_delta_deleted_item(entry) _delta_deleted_item(entry)
for entry in entries for entry in entries
if entry.resource_type == "access_user" and entry.resource_id not in visible if entry.resource_type == "access_user" and entry.resource_id not in visible
] ]
return UserListDeltaResponse( return UserListDeltaResponse(
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in visible.values()], users=_user_items_for_response(session, tenant, list(visible.values())),
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -2002,6 +2114,12 @@ def _user_item_for_response(
owner_ids: set[str] | None = None, owner_ids: set[str] | None = None,
idm_directory: IdmDirectory | None = None, idm_directory: IdmDirectory | None = None,
organization_directory: OrganizationDirectory | None = None, organization_directory: OrganizationDirectory | None = None,
accounts_by_id: dict[str, Account] | None = None,
groups_by_user: dict[str, list[Group]] | None = None,
roles_by_user: dict[str, list[Role]] | None = None,
group_member_ids_by_group: dict[str, list[str]] | None = None,
group_roles_by_group: dict[str, list[Role]] | None = None,
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
) -> UserAdminItem: ) -> UserAdminItem:
idm_assignments = _idm_assignments_for_user(idm_directory, user) idm_assignments = _idm_assignments_for_user(idm_directory, user)
return _user_item( return _user_item(
@@ -2010,6 +2128,12 @@ def _user_item_for_response(
owner_ids=owner_ids, owner_ids=owner_ids,
idm_assignments=idm_assignments, idm_assignments=idm_assignments,
organization_directory=organization_directory, organization_directory=organization_directory,
accounts_by_id=accounts_by_id,
groups_by_user=groups_by_user,
roles_by_user=roles_by_user,
group_member_ids_by_group=group_member_ids_by_group,
group_roles_by_group=group_roles_by_group,
tenant_role_assignment_counts=tenant_role_assignment_counts,
) )
@@ -2022,23 +2146,27 @@ def list_users_delta(
principal: ApiPrincipal = Depends(require_scope("admin:users:read")), principal: ApiPrincipal = Depends(require_scope("admin:users: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(since, scope="users")
return _full_users_delta_response(session, tenant) if since is None or full_cursor is not None:
return _full_users_delta_response(session, tenant, cursor=full_cursor, limit=limit)
return _users_delta_response(session, tenant, since=since, limit=limit) return _users_delta_response(session, tenant, since=since, limit=limit)
@router.get("/users", response_model=UserListResponse) @router.get("/users", response_model=UserListResponse)
def list_users( def list_users(
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:users:read")), principal: ApiPrincipal = Depends(require_scope("admin:users:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all() query = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc())
owner_ids = tenant_owner_user_ids(session, tenant.id) users, pagination = _page_query(query, page=page, page_size=page_size)
idm_directory = _optional_idm_directory() return UserListResponse(
organization_directory = _optional_organization_directory() users=_user_items_for_response(session, tenant, users),
return UserListResponse(users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in users]) **pagination,
)
@router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse) @router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse)
@@ -2198,14 +2326,7 @@ def create_user(
) )
@router.patch("/users/{user_id}", response_model=UserAdminItem) def _require_user_update_permissions(principal: ApiPrincipal, payload: UserUpdateRequest) -> None:
def update_user(
user_id: str,
payload: UserUpdateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
if "display_name" in payload.model_fields_set: if "display_name" in payload.model_fields_set:
_require_permission(principal, "admin:users:update") _require_permission(principal, "admin:users:update")
if payload.is_active is not None: if payload.is_active is not None:
@@ -2214,10 +2335,16 @@ def update_user(
_require_permission(principal, "admin:groups:manage_members") _require_permission(principal, "admin:groups:manage_members")
if payload.role_ids is not None: if payload.role_ids is not None:
_require_permission(principal, "admin:roles:assign") _require_permission(principal, "admin:roles:assign")
tenant = _resolve_tenant(session, principal, tenant_id)
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
if user is None: def _apply_user_update(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") session: Session,
*,
tenant: Tenant,
user: User,
payload: UserUpdateRequest,
principal: ApiPrincipal,
) -> tuple[set[str], set[str]]:
previous_group_ids = _current_user_group_ids(session, user) if payload.group_ids is not None else set() previous_group_ids = _current_user_group_ids(session, user) if payload.group_ids is not None else set()
previous_role_ids = _current_user_role_ids(session, user) if payload.role_ids is not None else set() previous_role_ids = _current_user_role_ids(session, user) if payload.role_ids is not None else set()
try: try:
@@ -2234,6 +2361,19 @@ def update_user(
assert_tenant_owner_exists(session, tenant.id) assert_tenant_owner_exists(session, tenant.id)
except (AdminConflictError, AdminValidationError) as exc: except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc raise _http_admin_error(exc) from exc
return previous_group_ids, previous_role_ids
def _record_user_update_changes(
session: Session,
*,
tenant: Tenant,
user: User,
payload: UserUpdateRequest,
principal: ApiPrincipal,
previous_group_ids: set[str],
previous_role_ids: set[str],
) -> None:
_record_access_change( _record_access_change(
session, session,
collection=ACCESS_USERS_COLLECTION, collection=ACCESS_USERS_COLLECTION,
@@ -2263,6 +2403,37 @@ def update_user(
tenant_id=tenant.id, tenant_id=tenant.id,
principal=principal, principal=principal,
) )
@router.patch("/users/{user_id}", response_model=UserAdminItem)
def update_user(
user_id: str,
payload: UserUpdateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
_require_user_update_permissions(principal, payload)
tenant = _resolve_tenant(session, principal, tenant_id)
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
previous_group_ids, previous_role_ids = _apply_user_update(
session,
tenant=tenant,
user=user,
payload=payload,
principal=principal,
)
_record_user_update_changes(
session,
tenant=tenant,
user=user,
payload=payload,
principal=principal,
previous_group_ids=previous_group_ids,
previous_role_ids=previous_role_ids,
)
audit_event( audit_event(
session, session,
tenant_id=tenant.id, tenant_id=tenant.id,
@@ -2281,21 +2452,43 @@ def update_user(
) )
def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse: def _group_summaries_for_response(session: Session, tenant: Tenant, groups: list[Group]) -> list[GroupSummary]:
groups = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()).all() group_ids = [group.id for group in groups]
member_ids_by_group = _group_member_ids_by_group_id(session, tenant_id=tenant.id, group_ids=group_ids)
roles_by_group = _roles_by_group_id(session, tenant_id=tenant.id, group_ids=group_ids)
role_ids = sorted({role.id for roles in roles_by_group.values() for role in roles})
tenant_role_counts = _tenant_role_assignment_counts(session, role_ids)
return [
_group_summary(
session,
group,
member_ids_by_group=member_ids_by_group,
roles_by_group=roles_by_group,
tenant_role_assignment_counts=tenant_role_counts,
)
for group in groups
]
def _full_groups_delta_response(session: Session, tenant: Tenant, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> GroupListDeltaResponse:
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_GROUPS_COLLECTION,))
page = cursor[0] if cursor is not None else 1
query = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc(), Group.id.asc())
groups, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="groups", snapshot_sequence=snapshot_sequence)
return GroupListDeltaResponse( return GroupListDeltaResponse(
groups=[_group_summary(session, group) for group in groups], groups=_group_summaries_for_response(session, tenant, groups),
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_GROUPS_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
def _groups_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> GroupListDeltaResponse: def _groups_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> GroupListDeltaResponse:
entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), since=since, limit=limit) entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), since=since, limit=limit)
if entries is None: if entries is None:
return _full_groups_delta_response(session, tenant) return _full_groups_delta_response(session, tenant, limit=limit)
changed_ids = _changed_ids(entries, "access_group") changed_ids = _changed_ids(entries, "access_group")
visible = { visible = {
group.id: group group.id: group
@@ -2310,7 +2503,7 @@ def _groups_delta_response(session: Session, tenant: Tenant, *, since: str, limi
if entry.resource_type == "access_group" and entry.resource_id not in visible if entry.resource_type == "access_group" and entry.resource_id not in visible
] ]
return GroupListDeltaResponse( return GroupListDeltaResponse(
groups=[_group_summary(session, group) for group in visible.values()], groups=_group_summaries_for_response(session, tenant, list(visible.values())),
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -2327,20 +2520,27 @@ def list_groups_delta(
principal: ApiPrincipal = Depends(require_scope("admin:groups:read")), principal: ApiPrincipal = Depends(require_scope("admin:groups: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(since, scope="groups")
return _full_groups_delta_response(session, tenant) if since is None or full_cursor is not None:
return _full_groups_delta_response(session, tenant, cursor=full_cursor, limit=limit)
return _groups_delta_response(session, tenant, since=since, limit=limit) return _groups_delta_response(session, tenant, since=since, limit=limit)
@router.get("/groups", response_model=GroupListResponse) @router.get("/groups", response_model=GroupListResponse)
def list_groups( def list_groups(
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:groups:read")), principal: ApiPrincipal = Depends(require_scope("admin:groups:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
groups = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()).all() query = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc())
return GroupListResponse(groups=[_group_summary(session, group) for group in groups]) groups, pagination = _page_query(query, page=page, page_size=page_size)
return GroupListResponse(
groups=_group_summaries_for_response(session, tenant, groups),
**pagination,
)
@router.post("/groups", response_model=GroupSummary, status_code=status.HTTP_201_CREATED) @router.post("/groups", response_model=GroupSummary, status_code=status.HTTP_201_CREATED)
@@ -2419,24 +2619,23 @@ def create_group(
return _group_summary(session, group) return _group_summary(session, group)
@router.patch("/groups/{group_id}", response_model=GroupSummary) def _require_group_update_permissions(principal: ApiPrincipal, payload: GroupUpdateRequest) -> None:
def update_group(
group_id: str,
payload: GroupUpdateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
if any(field in payload.model_fields_set for field in ("name", "description", "is_active")): if any(field in payload.model_fields_set for field in ("name", "description", "is_active")):
_require_permission(principal, "admin:groups:write") _require_permission(principal, "admin:groups:write")
if payload.member_ids is not None: if payload.member_ids is not None:
_require_permission(principal, "admin:groups:manage_members") _require_permission(principal, "admin:groups:manage_members")
if payload.role_ids is not None: if payload.role_ids is not None:
_require_permission(principal, "admin:roles:assign") _require_permission(principal, "admin:roles:assign")
tenant = _resolve_tenant(session, principal, tenant_id)
group = session.query(Group).filter(Group.id == group_id, Group.tenant_id == tenant.id).one_or_none()
if group is None: def _apply_group_update(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") session: Session,
*,
tenant: Tenant,
group: Group,
payload: GroupUpdateRequest,
principal: ApiPrincipal,
) -> tuple[set[str], set[str]]:
previous_member_ids = _current_group_member_ids(session, group) if payload.member_ids is not None else set() previous_member_ids = _current_group_member_ids(session, group) if payload.member_ids is not None else set()
previous_role_ids = _current_group_role_ids(session, group) if payload.role_ids is not None else set() previous_role_ids = _current_group_role_ids(session, group) if payload.role_ids is not None else set()
try: try:
@@ -2456,6 +2655,19 @@ def update_group(
assert_tenant_owner_exists(session, tenant.id) assert_tenant_owner_exists(session, tenant.id)
except (AdminConflictError, AdminValidationError) as exc: except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc raise _http_admin_error(exc) from exc
return previous_member_ids, previous_role_ids
def _record_group_update_changes(
session: Session,
*,
tenant: Tenant,
group: Group,
payload: GroupUpdateRequest,
principal: ApiPrincipal,
previous_member_ids: set[str],
previous_role_ids: set[str],
) -> None:
_record_access_change( _record_access_change(
session, session,
collection=ACCESS_GROUPS_COLLECTION, collection=ACCESS_GROUPS_COLLECTION,
@@ -2485,6 +2697,37 @@ def update_group(
tenant_id=tenant.id, tenant_id=tenant.id,
principal=principal, principal=principal,
) )
@router.patch("/groups/{group_id}", response_model=GroupSummary)
def update_group(
group_id: str,
payload: GroupUpdateRequest,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
_require_group_update_permissions(principal, payload)
tenant = _resolve_tenant(session, principal, tenant_id)
group = session.query(Group).filter(Group.id == group_id, Group.tenant_id == tenant.id).one_or_none()
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
previous_member_ids, previous_role_ids = _apply_group_update(
session,
tenant=tenant,
group=group,
payload=payload,
principal=principal,
)
_record_group_update_changes(
session,
tenant=tenant,
group=group,
payload=payload,
principal=principal,
previous_member_ids=previous_member_ids,
previous_role_ids=previous_role_ids,
)
audit_event( audit_event(
session, session,
tenant_id=tenant.id, tenant_id=tenant.id,
@@ -2498,23 +2741,32 @@ def update_group(
return _group_summary(session, group) return _group_summary(session, group)
def _full_roles_delta_response(session: Session, tenant: Tenant) -> RoleListDeltaResponse: def _tenant_role_summaries_for_response(session: Session, roles: list[Role]) -> list[RoleSummary]:
tenant_role_counts = _tenant_role_assignment_counts(session, [role.id for role in roles])
return [_role_summary(session, role, tenant_role_assignment_counts=tenant_role_counts) for role in roles]
def _full_roles_delta_response(session: Session, tenant: Tenant, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse:
ensure_default_roles(session, tenant) ensure_default_roles(session, tenant)
session.commit() session.commit()
roles = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc()).all() snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_ROLES_COLLECTION,))
page = cursor[0] if cursor is not None else 1
query = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc(), Role.id.asc())
roles, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="roles", snapshot_sequence=snapshot_sequence)
return RoleListDeltaResponse( return RoleListDeltaResponse(
roles=[_role_summary(session, role) for role in roles], roles=_tenant_role_summaries_for_response(session, roles),
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_ROLES_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
def _roles_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> RoleListDeltaResponse: def _roles_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> RoleListDeltaResponse:
entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), since=since, limit=limit) entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), since=since, limit=limit)
if entries is None: if entries is None:
return _full_roles_delta_response(session, tenant) return _full_roles_delta_response(session, tenant, limit=limit)
changed_ids = _changed_ids(entries, "access_role") changed_ids = _changed_ids(entries, "access_role")
visible = { visible = {
role.id: role role.id: role
@@ -2529,7 +2781,7 @@ def _roles_delta_response(session: Session, tenant: Tenant, *, since: str, limit
if entry.resource_type == "access_role" and entry.resource_id not in visible if entry.resource_type == "access_role" and entry.resource_id not in visible
] ]
return RoleListDeltaResponse( return RoleListDeltaResponse(
roles=[_role_summary(session, role) for role in visible.values()], roles=_tenant_role_summaries_for_response(session, list(visible.values())),
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -2546,22 +2798,29 @@ def list_roles_delta(
principal: ApiPrincipal = Depends(require_scope("admin:roles:read")), principal: ApiPrincipal = Depends(require_scope("admin:roles: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(since, scope="roles")
return _full_roles_delta_response(session, tenant) if since is None or full_cursor is not None:
return _full_roles_delta_response(session, tenant, cursor=full_cursor, limit=limit)
return _roles_delta_response(session, tenant, since=since, limit=limit) return _roles_delta_response(session, tenant, since=since, limit=limit)
@router.get("/roles", response_model=RoleListResponse) @router.get("/roles", response_model=RoleListResponse)
def list_roles( def list_roles(
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:roles:read")), principal: ApiPrincipal = Depends(require_scope("admin:roles:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
ensure_default_roles(session, tenant) ensure_default_roles(session, tenant)
session.commit() session.commit()
roles = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc()).all() query = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc())
return RoleListResponse(roles=[_role_summary(session, role) for role in roles]) roles, pagination = _page_query(query, page=page, page_size=page_size)
return RoleListResponse(
roles=_tenant_role_summaries_for_response(session, roles),
**pagination,
)
@router.post("/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED) @router.post("/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED)
@@ -2718,23 +2977,32 @@ def delete_role(
return None return None
def _full_system_roles_delta_response(session: Session) -> RoleListDeltaResponse: def _system_role_summaries_for_response(session: Session, roles: list[Role]) -> list[RoleSummary]:
system_role_counts = _system_role_assignment_counts(session, [role.id for role in roles])
return [_role_summary(session, role, system_role_assignment_counts=system_role_counts) for role in roles]
def _full_system_roles_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse:
ensure_default_roles(session, None) ensure_default_roles(session, None)
session.commit() session.commit()
roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=None, module_id=ACCESS_MODULE_ID, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,))
page = cursor[0] if cursor is not None else 1
query = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc(), Role.id.asc())
roles, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-roles", snapshot_sequence=snapshot_sequence)
return RoleListDeltaResponse( return RoleListDeltaResponse(
roles=[_role_summary(session, role) for role in roles], roles=_system_role_summaries_for_response(session, roles),
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, None, (ACCESS_SYSTEM_ROLES_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
def _system_roles_delta_response(session: Session, *, since: str, limit: int) -> RoleListDeltaResponse: def _system_roles_delta_response(session: Session, *, since: str, limit: int) -> RoleListDeltaResponse:
entries, has_more = _access_delta_entries(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), since=since, limit=limit) entries, has_more = _access_delta_entries(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), since=since, limit=limit)
if entries is None: if entries is None:
return _full_system_roles_delta_response(session) return _full_system_roles_delta_response(session, limit=limit)
changed_ids = _changed_ids(entries, "access_system_role") changed_ids = _changed_ids(entries, "access_system_role")
visible = { visible = {
role.id: role role.id: role
@@ -2749,7 +3017,7 @@ def _system_roles_delta_response(session: Session, *, since: str, limit: int) ->
if entry.resource_type == "access_system_role" and entry.resource_id not in visible if entry.resource_type == "access_system_role" and entry.resource_id not in visible
] ]
return RoleListDeltaResponse( return RoleListDeltaResponse(
roles=[_role_summary(session, role) for role in visible.values()], roles=_system_role_summaries_for_response(session, list(visible.values())),
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -2765,20 +3033,27 @@ def list_system_roles_delta(
principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")), principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")),
): ):
del principal del principal
if since is None: full_cursor = _decode_full_delta_cursor(since, scope="system-roles")
return _full_system_roles_delta_response(session) if since is None or full_cursor is not None:
return _full_system_roles_delta_response(session, cursor=full_cursor, limit=limit)
return _system_roles_delta_response(session, since=since, limit=limit) return _system_roles_delta_response(session, since=since, limit=limit)
@router.get("/system/roles", response_model=RoleListResponse) @router.get("/system/roles", response_model=RoleListResponse)
def list_system_roles( def list_system_roles(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")), principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")),
): ):
ensure_default_roles(session, None) ensure_default_roles(session, None)
session.commit() session.commit()
roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() query = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc())
return RoleListResponse(roles=[_role_summary(session, role) for role in roles]) roles, pagination = _page_query(query, page=page, page_size=page_size)
return RoleListResponse(
roles=_system_role_summaries_for_response(session, roles),
**pagination,
)
@router.post("/system/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED) @router.post("/system/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED)
@@ -2901,25 +3176,29 @@ def delete_system_role_endpoint(
_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_SYSTEM_ROLES_COLLECTION) _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_SYSTEM_ROLES_COLLECTION)
def _full_system_accounts_delta_response(session: Session) -> SystemAccountListDeltaResponse: def _full_system_accounts_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> SystemAccountListDeltaResponse:
ensure_default_roles(session, None) 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 = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
accounts = session.query(Account).order_by(Account.email.asc()).all() snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=None, module_id=ACCESS_MODULE_ID, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS)
page = cursor[0] if cursor is not None else 1
query = session.query(Account).order_by(Account.email.asc(), Account.id.asc())
accounts, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-accounts", snapshot_sequence=snapshot_sequence)
return SystemAccountListDeltaResponse( return SystemAccountListDeltaResponse(
accounts=[_system_account_item(session, account) for account in accounts], accounts=[_system_account_item(session, account) for account in accounts],
roles=[_role_summary(session, role) for role in system_roles], roles=_system_role_summaries_for_response(session, system_roles),
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, None, _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
def _system_accounts_delta_response(session: Session, *, since: str, limit: int) -> SystemAccountListDeltaResponse: def _system_accounts_delta_response(session: Session, *, since: str, limit: int) -> SystemAccountListDeltaResponse:
entries, has_more = _access_delta_entries(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, since=since, limit=limit) entries, has_more = _access_delta_entries(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, since=since, limit=limit)
if entries is None: if entries is None:
return _full_system_accounts_delta_response(session) return _full_system_accounts_delta_response(session, limit=limit)
account_ids = _changed_ids(entries, "access_system_account") account_ids = _changed_ids(entries, "access_system_account")
role_ids = _changed_ids(entries, "access_system_role") role_ids = _changed_ids(entries, "access_system_role")
visible_accounts = { visible_accounts = {
@@ -2946,7 +3225,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_item(session, account) for account in visible_accounts.values()],
roles=[_role_summary(session, role) for role in visible_roles.values()], roles=_system_role_summaries_for_response(session, list(visible_roles.values())),
deleted=deleted, 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),
has_more=has_more, has_more=has_more,
@@ -2962,42 +3241,58 @@ def list_system_accounts_delta(
principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")),
): ):
del principal del principal
if since is None: full_cursor = _decode_full_delta_cursor(since, scope="system-accounts")
return _full_system_accounts_delta_response(session) if since is None or full_cursor is not None:
return _full_system_accounts_delta_response(session, cursor=full_cursor, limit=limit)
return _system_accounts_delta_response(session, since=since, limit=limit) return _system_accounts_delta_response(session, since=since, limit=limit)
@router.get("/system/accounts", response_model=SystemAccountListResponse) @router.get("/system/accounts", response_model=SystemAccountListResponse)
def list_system_accounts( def list_system_accounts(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")),
): ):
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 = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
accounts = session.query(Account).order_by(Account.email.asc()).all() query = session.query(Account).order_by(Account.email.asc())
accounts, pagination = _page_query(query, page=page, page_size=page_size)
return SystemAccountListResponse( return SystemAccountListResponse(
accounts=[_system_account_item(session, account) for account in accounts], accounts=[_system_account_item(session, account) for account in accounts],
roles=[_role_summary(session, role) for role in system_roles], roles=_system_role_summaries_for_response(session, system_roles),
**pagination,
) )
@router.patch("/system/accounts/{account_id}", response_model=SystemAccountItem) def _require_system_account_update_permissions(principal: ApiPrincipal, payload: SystemAccountUpdateRequest) -> None:
def update_system_account(
account_id: str,
payload: SystemAccountUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
if "display_name" in payload.model_fields_set and not has_scope(principal, "system:accounts:update"): if "display_name" in payload.model_fields_set and not has_scope(principal, "system:accounts:update"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:update") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:update")
if payload.is_active is not None and not has_scope(principal, "system:accounts:suspend"): if payload.is_active is not None and not has_scope(principal, "system:accounts:suspend"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:suspend") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:suspend")
if payload.role_ids is not None: if payload.role_ids is not None:
_require_system_role_assignment(principal) _require_system_role_assignment(principal)
account = session.get(Account, account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") def _active_tenant_ids_for_account(session: Session, account: Account) -> list[str]:
return [
row[0]
for row in session.query(User.tenant_id)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.account_id == account.id, User.is_active.is_(True), Tenant.is_active.is_(True))
.distinct()
.all()
]
def _apply_system_account_update(
session: Session,
*,
account: Account,
payload: SystemAccountUpdateRequest,
principal: ApiPrincipal,
) -> set[str]:
previous_role_ids = _current_system_role_ids(session, account) if payload.role_ids is not None else set() previous_role_ids = _current_system_role_ids(session, account) if payload.role_ids is not None else set()
if "display_name" in payload.model_fields_set: if "display_name" in payload.model_fields_set:
account.display_name = payload.display_name.strip() if payload.display_name else None account.display_name = payload.display_name.strip() if payload.display_name else None
@@ -3013,18 +3308,21 @@ def update_system_account(
session.flush() session.flush()
assert_system_owner_exists(session) assert_system_owner_exists(session)
if not account.is_active: if not account.is_active:
tenant_ids = [ for tenant_id in _active_tenant_ids_for_account(session, account):
row[0]
for row in session.query(User.tenant_id)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.account_id == account.id, User.is_active.is_(True), Tenant.is_active.is_(True))
.distinct()
.all()
]
for tenant_id in tenant_ids:
assert_tenant_owner_exists(session, tenant_id) assert_tenant_owner_exists(session, tenant_id)
except (AdminConflictError, AdminValidationError) as exc: except (AdminConflictError, AdminValidationError) as exc:
raise _http_admin_error(exc) from exc raise _http_admin_error(exc) from exc
return previous_role_ids
def _record_system_account_update_changes(
session: Session,
*,
account: Account,
payload: SystemAccountUpdateRequest,
principal: ApiPrincipal,
previous_role_ids: set[str],
) -> None:
_record_access_change( _record_access_change(
session, session,
collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION,
@@ -3044,6 +3342,27 @@ def update_system_account(
tenant_id=None, tenant_id=None,
principal=principal, principal=principal,
) )
@router.patch("/system/accounts/{account_id}", response_model=SystemAccountItem)
def update_system_account(
account_id: str,
payload: SystemAccountUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
_require_system_account_update_permissions(principal, payload)
account = session.get(Account, account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
previous_role_ids = _apply_system_account_update(session, account=account, payload=payload, principal=principal)
_record_system_account_update_changes(
session,
account=account,
payload=payload,
principal=principal,
previous_role_ids=previous_role_ids,
)
audit_from_principal( audit_from_principal(
session, session,
principal, principal,
@@ -3266,24 +3585,38 @@ def update_system_account_memberships(
return _system_account_item(session, account) return _system_account_item(session, account)
def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool) -> ApiKeyListDeltaResponse: def _api_key_items_for_response(session: Session, keys: list[ApiKey]) -> list[ApiKeyAdminItem]:
accounts_by_user_id = _accounts_by_user_id(session, [item.user_id for item in keys])
return [_api_key_item(session, item, accounts_by_user_id=accounts_by_user_id) for item in keys]
def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, cursor: tuple[int, int] | None = None, limit: int = 500) -> ApiKeyListDeltaResponse:
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
if not include_revoked: if not include_revoked:
query = query.filter(ApiKey.revoked_at.is_(None)) query = query.filter(ApiKey.revoked_at.is_(None))
keys = query.order_by(ApiKey.created_at.desc()).all() snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_API_KEYS_COLLECTION,))
page = cursor[0] if cursor is not None else 1
keys, pagination, watermark, has_more = _full_delta_page(
query.order_by(ApiKey.created_at.desc(), ApiKey.id.asc()),
page=page,
page_size=limit,
scope="api-keys",
snapshot_sequence=snapshot_sequence,
)
return ApiKeyListDeltaResponse( return ApiKeyListDeltaResponse(
api_keys=[_api_key_item(session, item) for item in keys], api_keys=_api_key_items_for_response(session, keys),
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_API_KEYS_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, since: str, limit: int) -> ApiKeyListDeltaResponse: def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, since: str, limit: int) -> ApiKeyListDeltaResponse:
entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), since=since, limit=limit) entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), since=since, limit=limit)
if entries is None: if entries is None:
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked) return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, limit=limit)
changed_ids = _changed_ids(entries, "access_api_key") changed_ids = _changed_ids(entries, "access_api_key")
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
if not include_revoked: if not include_revoked:
@@ -3301,7 +3634,7 @@ def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoke
if entry.resource_type == "access_api_key" and entry.resource_id not in visible if entry.resource_type == "access_api_key" and entry.resource_id not in visible
] ]
return ApiKeyListDeltaResponse( return ApiKeyListDeltaResponse(
api_keys=[_api_key_item(session, item) for item in visible.values()], api_keys=_api_key_items_for_response(session, list(visible.values())),
deleted=deleted, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -3319,8 +3652,9 @@ def list_api_keys_delta(
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")), principal: ApiPrincipal = Depends(require_scope("admin:api_keys: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(since, scope="api-keys")
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked) if since is None or full_cursor is not None:
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, cursor=full_cursor, limit=limit)
return _api_keys_delta_response(session, tenant, include_revoked=include_revoked, since=since, limit=limit) return _api_keys_delta_response(session, tenant, include_revoked=include_revoked, since=since, limit=limit)
@@ -3328,6 +3662,8 @@ def list_api_keys_delta(
def list_api_keys( def list_api_keys(
tenant_id: str | None = Query(default=None), tenant_id: str | None = Query(default=None),
include_revoked: bool = Query(default=False), include_revoked: bool = Query(default=False),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")), principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
): ):
@@ -3335,8 +3671,8 @@ def list_api_keys(
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
if not include_revoked: if not include_revoked:
query = query.filter(ApiKey.revoked_at.is_(None)) query = query.filter(ApiKey.revoked_at.is_(None))
keys = query.order_by(ApiKey.created_at.desc()).all() keys, pagination = _page_query(query.order_by(ApiKey.created_at.desc()), page=page, page_size=page_size)
return ApiKeyListResponse(api_keys=[_api_key_item(session, item) for item in keys]) return ApiKeyListResponse(api_keys=_api_key_items_for_response(session, keys), **pagination)
@router.post("/api-keys", response_model=AdminApiKeyCreateResponse, status_code=status.HTTP_201_CREATED) @router.post("/api-keys", response_model=AdminApiKeyCreateResponse, status_code=status.HTTP_201_CREATED)

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -21,20 +23,29 @@ from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
from govoplan_core.db.session import get_database, get_session from govoplan_core.db.session import get_database, get_session
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User
from govoplan_access.backend.semantic import collect_external_function_roles, collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
from govoplan_access.backend.security.api_keys import authenticate_api_key from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.sessions import ( from govoplan_access.backend.security.sessions import (
authenticate_session_token, authenticate_session_token,
collect_user_groups, collect_user_authorization_context,
collect_user_roles, UserAuthorizationContext,
collect_user_scopes,
verify_auth_session_csrf, verify_auth_session_csrf,
) )
from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_access.backend.permissions.catalog import expand_scopes, intersect_api_key_scopes from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes
from govoplan_core.settings import settings from govoplan_core.settings import settings
@dataclass(slots=True)
class ResolvedPrincipalContext:
principal: PrincipalRef
account: Account
user: User
tenant: Tenant
api_key: ApiKey | None = None
auth_session: AuthSession | None = None
def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]: def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]:
if x_api_key: if x_api_key:
return x_api_key.strip(), "api_key" return x_api_key.strip(), "api_key"
@@ -50,10 +61,6 @@ def _requires_csrf(request: Request) -> bool:
return request.method.upper() not in {"GET", "HEAD", "OPTIONS", "TRACE"} return request.method.upper() not in {"GET", "HEAD", "OPTIONS", "TRACE"}
def _principal_group_ids(session: Session, user: User) -> frozenset[str]:
return frozenset(group.id for group in collect_user_groups(session, user))
def _build_principal_ref( def _build_principal_ref(
session: Session, session: Session,
*, *,
@@ -67,22 +74,32 @@ def _build_principal_ref(
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
identity_directory: IdentityDirectory | None = None, identity_directory: IdentityDirectory | None = None,
extra_roles: tuple[Role, ...] = (), extra_roles: tuple[Role, ...] = (),
authorization_context: UserAuthorizationContext | None = None,
include_system_roles: bool = False,
) -> PrincipalRef: ) -> PrincipalRef:
function_assignment_ids = list(collect_function_assignment_ids(session, user)) if authorization_context is None:
authorization_context = collect_user_authorization_context(
session,
user,
account=account,
include_system=include_system_roles,
extra_roles=extra_roles,
)
function_assignment_ids = list(authorization_context.function_assignment_ids)
function_assignment_ids.extend(item.id for item in idm_assignments) function_assignment_ids.extend(item.id for item in idm_assignments)
roles = collect_user_roles(session, user) role_ids = [role.id for role in authorization_context.tenant_roles]
role_ids = [role.id for role in roles] if include_system_roles:
role_ids.extend(role.id for role in extra_roles) role_ids.extend(role.id for role in authorization_context.system_roles)
return PrincipalRef( return PrincipalRef(
account_id=account.id, account_id=account.id,
membership_id=user.id, membership_id=user.id,
tenant_id=tenant_id, tenant_id=tenant_id,
identity_id=identity_id_for_account(session, account.id, identity_directory=identity_directory), identity_id=identity_id_for_account(session, account.id, identity_directory=identity_directory),
scopes=frozenset(scopes), scopes=frozenset(scopes),
group_ids=_principal_group_ids(session, user), group_ids=frozenset(group.id for group in authorization_context.groups),
role_ids=frozenset(sorted(dict.fromkeys(role_ids))), role_ids=frozenset(sorted(dict.fromkeys(role_ids))),
function_assignment_ids=frozenset(sorted(dict.fromkeys(function_assignment_ids))), function_assignment_ids=frozenset(sorted(dict.fromkeys(function_assignment_ids))),
delegation_ids=frozenset(collect_function_delegation_ids(session, user)), delegation_ids=frozenset(authorization_context.function_delegation_ids),
auth_method=auth_method, # type: ignore[arg-type] auth_method=auth_method, # type: ignore[arg-type]
api_key_id=api_key.id if api_key else None, api_key_id=api_key.id if api_key else None,
session_id=auth_session.id if auth_session else None, session_id=auth_session.id if auth_session else None,
@@ -124,6 +141,21 @@ def _api_principal_from_ref(
) )
def _api_principal_from_context(
context: ResolvedPrincipalContext,
*,
permission_evaluator: PermissionEvaluator | None = None,
) -> ApiPrincipal:
return ApiPrincipal(
principal=context.principal,
account=context.account,
user=context.user,
api_key=context.api_key,
auth_session=context.auth_session,
permission_evaluator=permission_evaluator,
)
def _resolve_legacy_principal_ref( def _resolve_legacy_principal_ref(
request: Request, request: Request,
session: Session, session: Session,
@@ -134,76 +166,232 @@ def _resolve_legacy_principal_ref(
identity_directory: IdentityDirectory | None = None, identity_directory: IdentityDirectory | None = None,
organization_directory: OrganizationDirectory | None = None, organization_directory: OrganizationDirectory | None = None,
) -> PrincipalRef: ) -> PrincipalRef:
return _resolve_legacy_principal_context(
request,
session,
authorization=authorization,
x_api_key=x_api_key,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
).principal
def _resolve_legacy_principal_context(
request: Request,
session: Session,
*,
authorization: str | None,
x_api_key: str | None,
idm_directory: IdmDirectory | None = None,
identity_directory: IdentityDirectory | None = None,
organization_directory: OrganizationDirectory | None = None,
) -> ResolvedPrincipalContext:
token, source = _extract_token(request, authorization, x_api_key) token, source = _extract_token(request, authorization, x_api_key)
if not token: if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
if source != "cookie":
context = _resolve_api_key_principal_context(
session,
token=token,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
if context is not None:
return context
context = _resolve_session_principal_context(
request,
session,
token=token,
source=source,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
if context is not None:
return context
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
def _resolve_api_key_principal_ref(
session: Session,
*,
token: str,
idm_directory: IdmDirectory | None,
identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> PrincipalRef | None:
context = _resolve_api_key_principal_context(
session,
token=token,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
return context.principal if context is not None else None
def _resolve_api_key_principal_context(
session: Session,
*,
token: str,
idm_directory: IdmDirectory | None,
identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> ResolvedPrincipalContext | None:
# API keys remain supported for CLI/automation. Their permissions are the # 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)
if api_key: if api_key is None:
user = session.get(User, api_key.user_id) return None
account = session.get(Account, user.account_id) if user else None user = session.get(User, api_key.user_id)
tenant = session.get(Tenant, api_key.tenant_id) account = session.get(Account, user.account_id) if user else None
if ( tenant = session.get(Tenant, api_key.tenant_id)
not user or not account or not tenant if (
or not user.is_active or not account.is_active or not tenant.is_active not user or not account or not tenant
or user.tenant_id != api_key.tenant_id or not user.is_active or not account.is_active or not tenant.is_active
): or user.tenant_id != api_key.tenant_id
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal") ):
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=api_key.tenant_id) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory)) idm_assignments, idm_roles = _principal_idm_context(
user_scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=False), idm_roles) session,
effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or []) user=user,
session.commit() account=account,
return _build_principal_ref( tenant_id=api_key.tenant_id,
session, idm_directory=idm_directory,
api_key=api_key, organization_directory=organization_directory,
account=account, )
user=user, authorization_context = collect_user_authorization_context(
tenant_id=api_key.tenant_id, session,
scopes=effective_scopes, user,
auth_method="api_key", account=account,
idm_assignments=idm_assignments, include_system=False,
identity_directory=identity_directory, extra_roles=idm_roles,
extra_roles=idm_roles, )
) effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
session.commit()
principal = _build_principal_ref(
session,
api_key=api_key,
account=account,
user=user,
tenant_id=api_key.tenant_id,
scopes=effective_scopes,
auth_method="api_key",
idm_assignments=idm_assignments,
identity_directory=identity_directory,
extra_roles=idm_roles,
authorization_context=authorization_context,
)
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
def _resolve_session_principal_ref(
request: Request,
session: Session,
*,
token: str,
source: str,
idm_directory: IdmDirectory | None,
identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> PrincipalRef | None:
context = _resolve_session_principal_context(
request,
session,
token=token,
source=source,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
return context.principal if context is not None else None
def _resolve_session_principal_context(
request: Request,
session: Session,
*,
token: str,
source: str,
idm_directory: IdmDirectory | None,
identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> ResolvedPrincipalContext | None:
auth_session = authenticate_session_token(session, token) auth_session = authenticate_session_token(session, token)
if auth_session: if auth_session is None:
user = session.get(User, auth_session.user_id) return None
account = session.get(Account, auth_session.account_id) user = session.get(User, auth_session.user_id)
tenant = session.get(Tenant, auth_session.tenant_id) account = session.get(Account, auth_session.account_id)
if ( tenant = session.get(Tenant, auth_session.tenant_id)
not user or not account or not tenant if (
or not user.is_active or not account.is_active or not tenant.is_active not user or not account or not tenant
or user.account_id != account.id or not user.is_active or not account.is_active or not tenant.is_active
or user.tenant_id != tenant.id or user.account_id != account.id
): or user.tenant_id != tenant.id
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal") ):
if source == "cookie" and _requires_csrf(request): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal")
header_token = request.headers.get("x-csrf-token") if source == "cookie":
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name) _verify_session_csrf(request, auth_session)
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token): idm_assignments, idm_roles = _principal_idm_context(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token") session,
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=user.tenant_id) user=user,
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory)) account=account,
scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=True), idm_roles) tenant_id=user.tenant_id,
session.commit() idm_directory=idm_directory,
return _build_principal_ref( organization_directory=organization_directory,
session, )
auth_session=auth_session, authorization_context = collect_user_authorization_context(
account=account, session,
user=user, user,
tenant_id=user.tenant_id, account=account,
scopes=scopes, include_system=True,
auth_method="session", extra_roles=idm_roles,
idm_assignments=idm_assignments, )
identity_directory=identity_directory, scopes = authorization_context.scopes
extra_roles=idm_roles, session.commit()
) principal = _build_principal_ref(
session,
auth_session=auth_session,
account=account,
user=user,
tenant_id=user.tenant_id,
scopes=scopes,
auth_method="session",
idm_assignments=idm_assignments,
identity_directory=identity_directory,
extra_roles=idm_roles,
authorization_context=authorization_context,
include_system_roles=True,
)
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
def _verify_session_csrf(request: Request, auth_session: AuthSession) -> None:
if not _requires_csrf(request):
return
header_token = request.headers.get("x-csrf-token")
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name)
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
def _principal_idm_context(
session: Session,
*,
user: User,
account: Account,
tenant_id: str,
idm_directory: IdmDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> tuple[tuple[OrganizationFunctionAssignmentRef, ...], tuple[Role, ...]]:
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=tenant_id)
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
return idm_assignments, idm_roles
def _idm_assignments_for_account( def _idm_assignments_for_account(
@@ -217,13 +405,6 @@ def _idm_assignments_for_account(
return tuple(idm_directory.organization_function_assignments_for_account(account_id, tenant_id=tenant_id)) return tuple(idm_directory.organization_function_assignments_for_account(account_id, tenant_id=tenant_id))
def _scopes_with_extra_roles(scopes: list[str], roles: tuple[Role, ...]) -> list[str]:
merged = set(scopes)
for role in roles:
merged.update(role.permissions or [])
return expand_scopes(merged)
def _registry_from_request(request: Request) -> PlatformRegistry | None: def _registry_from_request(request: Request) -> PlatformRegistry | None:
registry = getattr(request.app.state, "govoplan_registry", None) registry = getattr(request.app.state, "govoplan_registry", None)
return registry if isinstance(registry, PlatformRegistry) else None return registry if isinstance(registry, PlatformRegistry) else None
@@ -326,19 +507,32 @@ def resolve_api_principal(
) -> ApiPrincipal: ) -> ApiPrincipal:
resolver = _principal_resolver_from_request(request) resolver = _principal_resolver_from_request(request)
permission_evaluator = _permission_evaluator_from_request(request) permission_evaluator = _permission_evaluator_from_request(request)
if isinstance(resolver, LegacyPrincipalResolver):
context = _resolve_legacy_principal_context(
request,
session,
authorization=authorization,
x_api_key=x_api_key,
idm_directory=resolver._idm_directory,
identity_directory=resolver._identity_directory,
organization_directory=resolver._organization_directory,
)
api_principal = _api_principal_from_context(context, permission_evaluator=permission_evaluator)
_enforce_maintenance_mode(session, api_principal)
return api_principal
if resolver is not None: if resolver is not None:
principal = resolver.resolve_request(request, session=session) principal = resolver.resolve_request(request, session=session)
api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator) api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator)
_enforce_maintenance_mode(session, api_principal) _enforce_maintenance_mode(session, api_principal)
return api_principal return api_principal
principal = _resolve_legacy_principal_ref( context = _resolve_legacy_principal_context(
request, request,
session, session,
authorization=authorization, authorization=authorization,
x_api_key=x_api_key, x_api_key=x_api_key,
) )
api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator) api_principal = _api_principal_from_context(context, permission_evaluator=permission_evaluator)
_enforce_maintenance_mode(session, api_principal) _enforce_maintenance_mode(session, api_principal)
return api_principal return api_principal

View File

@@ -3,71 +3,26 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants
from govoplan_access.backend.permissions.evaluator import scopes_grant as _scopes_grant
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
from govoplan_core.security.module_permissions import compatible_required_scopes from govoplan_core.security.module_permissions import compatible_required_scopes
from govoplan_core.security.permissions import ALL_PERMISSIONS as CORE_LEGACY_PERMISSION_DEFINITIONS
from govoplan_core.security.permissions import PermissionDefinition as CoreLegacyPermissionDefinition
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = { def _legacy_permission(permission: CoreLegacyPermissionDefinition) -> PermissionDefinition:
"campaign:write": frozenset({ parts = permission.scope.split(":", 2)
"campaign:create", if len(parts) == 2:
"campaign:update", module_id, action = parts
"campaign:copy", resource = module_id
"campaign:archive", else:
"campaign:delete", module_id, resource, action = parts
"campaign:share",
"recipients:read",
"recipients:write",
"recipients:import",
}),
"attachments:read": frozenset({"files:read", "files:download"}),
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
"admin:users": frozenset({
"admin:users:read",
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:groups:read",
"admin:groups:write",
"admin:groups:manage_members",
"admin:roles:read",
"admin:roles:write",
"admin:roles:assign",
}),
"admin:users:write": frozenset({
"admin:users:create",
"admin:users:update",
"admin:users:suspend",
"admin:roles:assign",
"admin:groups:manage_members",
}),
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
"admin:settings": frozenset({
"admin:settings:read",
"admin:settings:write",
"admin:api_keys:read",
"admin:api_keys:create",
"admin:api_keys:revoke",
}),
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
"system:access:write": frozenset({
"system:access:assign",
"system:roles:assign",
"system:accounts:create",
"system:accounts:update",
"system:accounts:suspend",
}),
}
def _permission(scope: str, label: str, description: str, category: str, level: PermissionLevel) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition( return PermissionDefinition(
scope=scope, scope=permission.scope,
label=label, label=permission.label,
description=description, description=permission.description,
category=category, category=permission.category,
level=level, level=permission.level, # type: ignore[arg-type]
module_id=module_id, module_id=module_id,
resource=resource, resource=resource,
action=action, action=action,
@@ -75,43 +30,9 @@ def _permission(scope: str, label: str, description: str, category: str, level:
) )
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = ( LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = tuple(
_permission("admin:users:read", "View users", "List tenant users and their effective access.", "Tenant administration", "tenant"), _legacy_permission(permission)
_permission("admin:users:create", "Create users", "Create tenant memberships for accounts.", "Tenant administration", "tenant"), for permission in CORE_LEGACY_PERMISSION_DEFINITIONS
_permission("admin:users:update", "Update users", "Edit non-access membership metadata.", "Tenant administration", "tenant"),
_permission("admin:users:suspend", "Suspend users", "Activate or suspend tenant memberships.", "Tenant administration", "tenant"),
_permission("admin:groups:read", "View groups", "List tenant groups, members and assigned roles.", "Tenant administration", "tenant"),
_permission("admin:groups:write", "Manage groups", "Create or edit tenant group definitions.", "Tenant administration", "tenant"),
_permission("admin:groups:manage_members", "Manage group members", "Add and remove users from tenant groups.", "Tenant administration", "tenant"),
_permission("admin:roles:read", "View roles", "Inspect role definitions and the permission catalogue.", "Tenant administration", "tenant"),
_permission("admin:roles:write", "Define roles", "Create and edit assignable tenant role definitions.", "Tenant administration", "tenant"),
_permission("admin:roles:assign", "Assign roles", "Assign tenant roles to users or groups within delegation limits.", "Tenant administration", "tenant"),
_permission("admin:api_keys:read", "View API keys", "List tenant API keys without revealing their secret.", "Tenant administration", "tenant"),
_permission("admin:api_keys:create", "Create API keys", "Create tenant-local API keys within the owner's delegation limits.", "Tenant administration", "tenant"),
_permission("admin:api_keys:revoke", "Revoke API keys", "Revoke tenant-local API keys.", "Tenant administration", "tenant"),
_permission("admin:settings:read", "View tenant settings", "Read tenant defaults and non-policy settings.", "Tenant administration", "tenant"),
_permission("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration", "tenant"),
_permission("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration", "tenant"),
_permission("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration", "tenant"),
_permission("system:tenants:read", "View all tenants", "List tenants and system-wide membership counts.", "System administration", "system"),
_permission("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
_permission("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
_permission("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
_permission("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
_permission("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
_permission("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
_permission("system:accounts:suspend", "Suspend accounts", "Activate or suspend global login accounts while preserving a system owner.", "System administration", "system"),
_permission("system:roles:read", "View system roles", "Inspect instance-wide role definitions and their permissions.", "System administration", "system"),
_permission("system:roles:write", "Define system roles", "Create and edit instance-wide role definitions within delegation limits.", "System administration", "system"),
_permission("system:roles:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "System administration", "system"),
_permission("system:access:read", "View system access", "Compatibility scope for listing accounts with instance-wide roles.", "System administration", "system"),
_permission("system:access:assign", "Assign system access", "Compatibility scope for assigning instance-wide roles.", "System administration", "system"),
_permission("system:audit:read", "View system audit", "Read audit records across tenants.", "System administration", "system"),
_permission("system:settings:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "System administration", "system"),
_permission("system:settings:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "System administration", "system"),
_permission("system:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "System administration", "system"),
_permission("system:governance:read", "View governance templates", "Inspect centrally managed group and role definitions.", "System administration", "system"),
_permission("system:governance:write", "Manage governance templates", "Create and assign centrally managed group and role definitions.", "System administration", "system"),
) )
@@ -146,12 +67,12 @@ def role_templates_for_level(level: PermissionLevel) -> tuple[RoleTemplate, ...]
return tuple(template for template in role_templates() if template.level == level) return tuple(template for template in role_templates() if template.level == level)
def scope_grants(granted: str, required: str) -> bool: def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
catalog = permission_map(include_legacy=True) catalog = catalog if catalog is not None else permission_map(include_legacy=True)
if _scope_grants(granted, required, catalog=catalog): if _scope_grants(granted, required, catalog=catalog):
return True return True
for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()): for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()):
if scope_grants(alias, required): if scope_grants(alias, required, catalog=catalog):
return True return True
return any( return any(
_scope_grants(granted, alias, catalog=catalog) _scope_grants(granted, alias, catalog=catalog)
@@ -160,8 +81,9 @@ def scope_grants(granted: str, required: str) -> bool:
) )
def scopes_grant(scopes: Iterable[str], required: str) -> bool: def scopes_grant(scopes: Iterable[str], required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
return any(scope_grants(scope, required) for scope in scopes) catalog = catalog if catalog is not None else permission_map(include_legacy=True)
return any(scope_grants(scope, required, catalog=catalog) for scope in scopes)
def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]: def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]:
@@ -173,7 +95,7 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis
expanded.add(scope) expanded.add(scope)
for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()): for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()):
expanded.add(alias) expanded.add(alias)
matched = {candidate for candidate in catalog if scope_grants(scope, candidate)} matched = {candidate for candidate in catalog if scope_grants(scope, candidate, catalog=catalog)}
expanded.update(matched) expanded.update(matched)
for alias in compatible_required_scopes(scope): for alias in compatible_required_scopes(scope):
if alias != scope: if alias != scope:
@@ -186,7 +108,8 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis
def effective_permission_scopes(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> set[str]: def effective_permission_scopes(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> set[str]:
candidates = _effective_permission_candidates(level=level) candidates = _effective_permission_candidates(level=level)
granted = list(scopes) granted = list(scopes)
return {scope for scope in candidates if scopes_grant(granted, scope)} catalog = permission_map(include_legacy=True)
return {scope for scope in candidates if scopes_grant(granted, scope, catalog=catalog)}
def effective_permission_count(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> int: def effective_permission_count(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> int:
@@ -207,7 +130,11 @@ def _effective_permission_candidates(*, level: PermissionLevel | None = None) ->
continue continue
if level is not None and definition.level != level: if level is not None and definition.level != level:
continue continue
if any(scope_grants(active_scope, scope) or scope_grants(scope, active_scope) for active_scope in active_scopes): if any(
scope_grants(active_scope, scope, catalog=full_catalog)
or scope_grants(scope, active_scope, catalog=full_catalog)
for active_scope in active_scopes
):
continue continue
candidates.add(scope) candidates.add(scope)
return candidates return candidates
@@ -231,7 +158,7 @@ def validate_permissions(scopes: Iterable[str], *, level: PermissionLevel) -> li
expanded.add(alias) expanded.add(alias)
continue continue
if scope.endswith(":*"): if scope.endswith(":*"):
matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate)} matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate, catalog=catalog)}
if matching: if matching:
expanded.add(scope) expanded.add(scope)
continue continue
@@ -272,8 +199,9 @@ def delegateable_system_scopes(scopes: Iterable[str]) -> set[str]:
def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]: def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]:
user = list(user_scopes) user = list(user_scopes)
key = list(key_scopes) key = list(key_scopes)
tenant_scopes = {scope for scope, definition in permission_map(include_legacy=True).items() if definition.level == "tenant"} catalog = permission_map(include_legacy=True)
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope) and scopes_grant(key, scope)} tenant_scopes = {scope for scope, definition in catalog.items() if definition.level == "tenant"}
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope, catalog=catalog) and scopes_grant(key, scope, catalog=catalog)}
user_raw = set(expand_scopes(user)) user_raw = set(expand_scopes(user))
key_raw = set(expand_scopes(key)) key_raw = set(expand_scopes(key))
allowed.update( allowed.update(

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from collections.abc import Iterable
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -18,7 +19,7 @@ from govoplan_access.backend.db.models import (
UserGroupMembership, UserGroupMembership,
UserRoleAssignment, UserRoleAssignment,
) )
from govoplan_access.backend.semantic import collect_function_roles from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles
from govoplan_access.backend.permissions.catalog import expand_scopes from govoplan_access.backend.permissions.catalog import expand_scopes
from govoplan_core.security.time import ensure_aware_utc, utc_now from govoplan_core.security.time import ensure_aware_utc, utc_now
@@ -33,6 +34,16 @@ class CreatedSession:
csrf_token: str csrf_token: str
@dataclass(slots=True)
class UserAuthorizationContext:
tenant_roles: list[Role]
system_roles: list[Role]
groups: list[Group]
function_assignment_ids: tuple[str, ...]
function_delegation_ids: tuple[str, ...]
scopes: list[str]
def generate_session_token() -> str: def generate_session_token() -> str:
return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES) return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES)
@@ -199,6 +210,56 @@ def collect_user_groups(session: Session, user: User) -> list[Group]:
) )
def collect_user_authorization_context(
session: Session,
user: User,
*,
account: Account | None = None,
include_system: bool = True,
extra_roles: Iterable[Role] = (),
) -> UserAuthorizationContext:
account = account or user.account
roles_by_id: dict[str, Role] = {role.id: role for role in collect_direct_user_roles(session, user)}
groups = collect_user_groups(session, user)
group_ids = [group.id for group in groups]
if group_ids:
group_roles = (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.filter(
GroupRoleAssignment.tenant_id == user.tenant_id,
GroupRoleAssignment.group_id.in_(sorted(group_ids)),
Role.tenant_id == user.tenant_id,
)
.order_by(Role.name.asc())
.all()
)
for role in group_roles:
roles_by_id[role.id] = role
function_context = collect_function_authorization_context(session, user)
for role in function_context.roles:
roles_by_id[role.id] = role
for role in extra_roles:
roles_by_id[role.id] = role
tenant_roles = list(roles_by_id.values())
system_roles = collect_system_roles(session, account) if include_system and account is not None else []
scopes = {
scope
for role in [*tenant_roles, *system_roles]
for scope in (role.permissions or [])
}
return UserAuthorizationContext(
tenant_roles=tenant_roles,
system_roles=system_roles,
groups=groups,
function_assignment_ids=function_context.assignment_ids,
function_delegation_ids=function_context.delegation_ids,
scopes=expand_scopes(scopes),
)
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]: def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
scopes: set[str] = set() scopes: set[str] = set()
for role in collect_user_roles(session, user): for role in collect_user_roles(session, user):

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -23,6 +24,13 @@ from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, Organizati
from govoplan_core.security.time import utc_now from govoplan_core.security.time import utc_now
@dataclass(slots=True)
class FunctionAuthorizationContext:
assignment_ids: tuple[str, ...]
delegation_ids: tuple[str, ...]
roles: tuple[Role, ...]
def primary_identity_for_account(session: Session, account_id: str) -> Identity | None: def primary_identity_for_account(session: Session, account_id: str) -> Identity | None:
return ( return (
session.query(Identity) session.query(Identity)
@@ -169,6 +177,56 @@ def collect_function_roles(session: Session, user: User) -> list[Role]:
) )
def collect_function_authorization_context(session: Session, user: User) -> FunctionAuthorizationContext:
assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
delegations = active_function_delegations_for_account(
session,
user.account_id,
tenant_id=user.tenant_id,
modes=("delegate", "act_in_place"),
)
direct_assignment_ids = {assignment.id for assignment in assignments}
delegated_role_assignment_ids = {
delegation.function_assignment_id
for delegation in delegations
if delegation.mode == "delegate"
}
role_assignment_ids = direct_assignment_ids | delegated_role_assignment_ids
function_ids = {assignment.function_id for assignment in assignments}
missing_role_assignment_ids = delegated_role_assignment_ids - direct_assignment_ids
if missing_role_assignment_ids:
function_ids.update(
row[0]
for row in session.query(FunctionAssignment.function_id)
.filter(
FunctionAssignment.tenant_id == user.tenant_id,
FunctionAssignment.id.in_(sorted(missing_role_assignment_ids)),
)
.all()
)
roles: tuple[Role, ...] = ()
if function_ids:
roles = tuple(
session.query(Role)
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
.filter(
FunctionRoleAssignment.tenant_id == user.tenant_id,
FunctionRoleAssignment.function_id.in_(sorted(function_ids)),
Role.tenant_id == user.tenant_id,
)
.order_by(Role.name.asc())
.all()
)
return FunctionAuthorizationContext(
assignment_ids=tuple(sorted(role_assignment_ids)),
delegation_ids=tuple(sorted(delegation.id for delegation in delegations)),
roles=roles,
)
def collect_external_function_roles( def collect_external_function_roles(
session: Session, session: Session,
user: User, user: User,

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.api.v1.admin_common import (
_accounts_by_user_id,
_group_member_ids_by_group_id,
_groups_by_user_id,
_roles_by_group_id,
_roles_by_user_id,
_tenant_role_assignment_counts,
)
from govoplan_access.backend.db.models import Account, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
from govoplan_core.db.base import Base
class AdminBatchHelperTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
def tearDown(self) -> None:
Base.metadata.drop_all(bind=self.engine)
def test_access_admin_batch_maps_related_rows(self) -> None:
session = self.Session()
account = Account(id="account-1", email="ada@example.test", normalized_email="ada@example.test")
user = User(id="user-1", tenant_id="tenant-1", account_id=account.id, email=account.email)
group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks")
role = Role(id="role-1", tenant_id="tenant-1", slug="reader", name="Reader", permissions=["admin:users:read"])
session.add_all([account, user, group, role])
session.flush()
session.add(UserGroupMembership(tenant_id="tenant-1", user_id=user.id, group_id=group.id))
session.add(UserRoleAssignment(tenant_id="tenant-1", user_id=user.id, role_id=role.id))
session.add(GroupRoleAssignment(tenant_id="tenant-1", group_id=group.id, role_id=role.id))
session.commit()
accounts_by_user = _accounts_by_user_id(session, [user.id])
group_member_ids = _group_member_ids_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id])
groups_by_user = _groups_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id])
roles_by_group = _roles_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id])
roles_by_user = _roles_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id])
role_counts = _tenant_role_assignment_counts(session, [role.id])
self.assertEqual(accounts_by_user[user.id].id, account.id)
self.assertEqual(group_member_ids, {group.id: [user.id]})
self.assertEqual([item.id for item in groups_by_user[user.id]], [group.id])
self.assertEqual([item.id for item in roles_by_group[group.id]], [role.id])
self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id])
self.assertEqual(role_counts, {role.id: (1, 1)})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import unittest
from fastapi import HTTPException
from govoplan_access.backend.api.v1.routes import (
_decode_full_delta_cursor,
_encode_full_delta_cursor,
_full_delta_page,
_page_query,
)
class FakeQuery:
def __init__(self, rows: list[int]) -> None:
self._rows = rows
self._offset = 0
self._limit: int | None = None
def order_by(self, *_args: object) -> FakeQuery:
return self
def count(self) -> int:
return len(self._rows)
def offset(self, value: int) -> FakeQuery:
clone = FakeQuery(self._rows)
clone._offset = value
clone._limit = self._limit
return clone
def limit(self, value: int) -> FakeQuery:
clone = FakeQuery(self._rows)
clone._offset = self._offset
clone._limit = value
return clone
def all(self) -> list[int]:
end = None if self._limit is None else self._offset + self._limit
return self._rows[self._offset:end]
class AdminPaginationTests(unittest.TestCase):
def test_page_query_returns_page_and_metadata(self) -> None:
rows, metadata = _page_query(FakeQuery(list(range(7))), page=2, page_size=3)
self.assertEqual(rows, [3, 4, 5])
self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3})
def test_full_delta_page_returns_cursor_until_last_page(self) -> None:
rows, metadata, watermark, has_more = _full_delta_page(
FakeQuery(list(range(7))),
page=2,
page_size=3,
scope="users",
snapshot_sequence=42,
)
self.assertEqual(rows, [3, 4, 5])
self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3})
self.assertEqual(watermark, "full:users:3:42")
self.assertTrue(has_more)
def test_full_delta_page_returns_sequence_watermark_on_last_page(self) -> None:
rows, metadata, watermark, has_more = _full_delta_page(
FakeQuery(list(range(7))),
page=3,
page_size=3,
scope="users",
snapshot_sequence=42,
)
self.assertEqual(rows, [6])
self.assertEqual(metadata, {"total": 7, "page": 3, "page_size": 3, "pages": 3})
self.assertEqual(watermark, "seq:42")
self.assertFalse(has_more)
def test_full_delta_cursor_round_trips_and_rejects_wrong_scope(self) -> None:
cursor = _encode_full_delta_cursor("users", page=3, snapshot_sequence=42)
self.assertEqual(_decode_full_delta_cursor(cursor, scope="users"), (3, 42))
self.assertIsNone(_decode_full_delta_cursor("seq:42", scope="users"))
with self.assertRaises(HTTPException):
_decode_full_delta_cursor(cursor, scope="groups")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
import unittest
from typing import Iterable
from fastapi import HTTPException
from starlette.requests import Request
from govoplan_access.backend.auth.dependencies import _extract_token, _requires_csrf, _resolve_legacy_principal_ref
from govoplan_core.settings import settings
def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request:
return Request(
{
"type": "http",
"method": method,
"path": "/",
"headers": [(key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in headers],
}
)
class AuthDependencyTests(unittest.TestCase):
def test_extract_token_prefers_explicit_api_key(self) -> None:
request = request_for(headers=[("authorization", "Bearer session-token")])
self.assertEqual(_extract_token(request, "Bearer session-token", "api-key-token"), ("api-key-token", "api_key"))
def test_extract_token_supports_bearer_and_cookie_sources(self) -> None:
self.assertEqual(_extract_token(request_for(), "Bearer session-token", None), ("session-token", "bearer"))
cookie_request = request_for(headers=[("cookie", f"{settings.auth_session_cookie_name}=cookie-token")])
self.assertEqual(_extract_token(cookie_request, None, None), ("cookie-token", "cookie"))
def test_requires_csrf_only_for_mutating_methods(self) -> None:
self.assertFalse(_requires_csrf(request_for(method="GET")))
self.assertTrue(_requires_csrf(request_for(method="POST")))
def test_legacy_principal_resolver_rejects_missing_token_before_db_lookup(self) -> None:
with self.assertRaises(HTTPException) as raised:
_resolve_legacy_principal_ref(request_for(), None, authorization=None, x_api_key=None) # type: ignore[arg-type]
self.assertEqual(raised.exception.status_code, 401)
self.assertEqual(raised.exception.detail, "Missing API key or session token")
if __name__ == "__main__":
unittest.main()

View File

@@ -15,8 +15,8 @@ class OptionalTenancyContractTests(unittest.TestCase):
dependencies = tuple(project["dependencies"]) dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.6", dependencies) self.assertIn("govoplan-core>=0.1.8", dependencies)
self.assertNotIn("govoplan-tenancy>=0.1.6", dependencies) self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies)) self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
def test_tenancy_is_declared_as_optional_module_integration(self) -> None: def test_tenancy_is_declared_as_optional_module_integration(self) -> None:

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
import unittest
from govoplan_access.backend.permissions import catalog as access_catalog
from govoplan_core.security import permissions as core_permissions
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
class PermissionCatalogContractTests(unittest.TestCase):
def test_access_reuses_core_legacy_scope_aliases(self) -> None:
self.assertIs(access_catalog.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES)
self.assertIs(core_permissions.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES)
def test_legacy_alias_grants_match_in_core_and_access(self) -> None:
for legacy_scope, aliases in LEGACY_SCOPE_ALIASES.items():
for alias in aliases:
self.assertTrue(core_permissions.scope_grants(legacy_scope, alias))
self.assertTrue(access_catalog.scope_grants(legacy_scope, alias))
def test_access_legacy_catalog_includes_non_access_platform_scopes(self) -> None:
scopes = {permission.scope for permission in access_catalog.permission_catalog(include_legacy=True)}
self.assertIn("campaign:queue", scopes)
self.assertIn("files:upload", scopes)
self.assertIn("mail_servers:test", scopes)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,47 +1,16 @@
import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui"; import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy, TenantAdminItem } from "@govoplan/core-webui";
import { apiFetch } from "@govoplan/core-webui"; import { apiFetch, apiGetList, apiPath, apiQuery } from "@govoplan/core-webui";
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
export type PermissionItem = { export type {
scope: string; AdminOverview,
label: string; PrivacyRetentionLimitPermissionPatch,
description: string; PrivacyRetentionLimitPermissions,
category: string; PrivacyRetentionPolicy,
level: "tenant" | "system"; PrivacyRetentionPolicyFieldKey,
system_template_id?: string | null; PrivacyRetentionPolicyPatch,
system_required?: boolean; PermissionItem,
}; TenantAdminItem
} from "@govoplan/core-webui";
export type AdminOverview = {
active_tenant_id: string;
active_tenant_name: string;
tenant_count?: number | null;
system_account_count?: number | null;
system_group_template_count?: number | null;
system_role_template_count?: number | null;
user_count: number;
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export type TenantOwnerCandidate = { export type TenantOwnerCandidate = {
account_id: string; account_id: string;
@@ -195,28 +164,6 @@ export type SystemMembershipDraft = {
is_last_active_owner?: boolean; is_last_active_owner?: boolean;
}; };
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days"
| "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level: "full" | "redacted" | "minimal";
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
};
export type SystemSettingsItem = { export type SystemSettingsItem = {
default_locale: string; default_locale: string;
allow_tenant_custom_groups: boolean; allow_tenant_custom_groups: boolean;
@@ -317,36 +264,18 @@ export type TenantSettingsDeltaResponse = {
} & DeltaResponseFields; } & DeltaResponseFields;
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string { function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
const params = new URLSearchParams(); return apiQuery(options);
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
return params.toString() ? `?${params.toString()}` : "";
} }
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
return response.permissions;
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
return response.tenants;
}
export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantListDeltaResponse> { export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantListDeltaResponse> {
const suffix = deltaSuffix(options); const suffix = deltaSuffix(options);
return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`); return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`);
} }
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> { export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates"); return apiGetList<TenantOwnerCandidate, "accounts">(settings, "/api/v1/admin/tenants/owner-candidates", "accounts");
return response.accounts;
} }
export function createTenant(settings: ApiSettings, payload: { export function createTenant(settings: ApiSettings, payload: {
@@ -429,19 +358,17 @@ export function fetchResourceAccessExplanation(
settings: ApiSettings, settings: ApiSettings,
options: { userId: string; resourceType: string; resourceId: string; action: string; tenantId?: string | null } options: { userId: string; resourceType: string; resourceId: string; action: string; tenantId?: string | null }
): Promise<ResourceAccessExplanationResponse> { ): Promise<ResourceAccessExplanationResponse> {
const params = new URLSearchParams({ return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation", {
user_id: options.userId, user_id: options.userId,
resource_type: options.resourceType, resource_type: options.resourceType,
resource_id: options.resourceId, resource_id: options.resourceId,
action: options.action action: options.action,
}); tenant_id: options.tenantId
if (options.tenantId) params.set("tenant_id", options.tenantId); }));
return apiFetch(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
} }
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> { export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups"); return apiGetList<GroupSummary, "groups">(settings, "/api/v1/admin/groups", "groups");
return response.groups;
} }
export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> { export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> {
@@ -471,8 +398,7 @@ export function updateGroup(settings: ApiSettings, groupId: string, payload: Par
} }
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> { export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles"); return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/roles", "roles");
return response.roles;
} }
export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> { export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
@@ -528,8 +454,7 @@ export function deleteExternalFunctionRoleMapping(settings: ApiSettings, mapping
} }
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> { export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles"); return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/system/roles", "roles");
return response.roles;
} }
export function fetchSystemRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> { export function fetchSystemRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
@@ -587,20 +512,15 @@ export function updateSystemAccountRoles(settings: ApiSettings, accountId: strin
} }
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> { export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
const params = new URLSearchParams(); return apiGetList<ApiKeyAdminItem, "api_keys">(settings, "/api/v1/admin/api-keys", "api_keys", { include_revoked: includeRevoked ? true : undefined });
if (includeRevoked) params.set("include_revoked", "true");
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
return response.api_keys;
} }
export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> { export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> {
const params = new URLSearchParams(); return apiFetch(settings, apiPath("/api/v1/admin/api-keys/delta", {
if (includeRevoked) params.set("include_revoked", "true"); include_revoked: includeRevoked ? true : undefined,
if (options.since) params.set("since", options.since); since: options.since,
if (options.limit) params.set("limit", String(options.limit)); limit: options.limit
const suffix = params.toString() ? `?${params.toString()}` : ""; }));
return apiFetch(settings, `/api/v1/admin/api-keys/delta${suffix}`);
} }
export function createApiKey(settings: ApiSettings, payload: { export function createApiKey(settings: ApiSettings, payload: {
@@ -653,9 +573,7 @@ export function updateSystemSettings(settings: ApiSettings, payload: SystemSetti
} }
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> { export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; return apiGetList<GovernanceTemplateItem, "templates">(settings, "/api/v1/admin/system/governance-templates", "templates", { kind });
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
return response.templates;
} }
export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GovernanceTemplateListDeltaResponse> { export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GovernanceTemplateListDeltaResponse> {

View File

@@ -5,11 +5,12 @@ import type {
AdminSectionsUiCapability, AdminSectionsUiCapability,
ApiSettings, ApiSettings,
AuthInfo, AuthInfo,
AuthUpdate,
FilesConnectorsUiCapability, FilesConnectorsUiCapability,
MailProfilesUiCapability, MailProfilesUiCapability,
OrganizationFunctionPickerUiCapability OrganizationFunctionPickerUiCapability
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { fetchMe } from "@govoplan/core-webui"; import { fetchShellAuth } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui"; import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui"; import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
@@ -63,7 +64,7 @@ export default function AdminPage({
}: { }: {
settings: ApiSettings; settings: ApiSettings;
auth: AuthInfo; auth: AuthInfo;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void; onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
}) { }) {
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles"); const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors"); const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
@@ -131,13 +132,9 @@ export default function AdminPage({
}, [requestedSection, available, fallbackSection]); }, [requestedSection, available, fallbackSection]);
async function refreshAuth() { async function refreshAuth() {
onAuthChange(await fetchMe(settings)); onAuthChange(await fetchShellAuth(settings));
} }
useEffect(() => {
void refreshAuth().catch(() => undefined);
}, [settings.accessToken, settings.apiBaseUrl]);
if (!hasAnyScope(auth, adminReadScopes)) { if (!hasAnyScope(auth, adminReadScopes)) {
return ( return (
<div className="content-pad"> <div className="content-pad">

View File

@@ -83,8 +83,17 @@ export default function SystemUsersPanel({
let hasMore = false; let hasMore = false;
do { do {
const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark }); const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark });
nextAccounts = response.full ? response.accounts : mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts }); const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
nextRoles = response.full ? response.roles : mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles }); nextAccounts = response.full
? continuingFullSnapshot
? mergeDeltaRows(nextAccounts, response.accounts, [], (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts })
: response.accounts
: mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts });
nextRoles = response.full
? continuingFullSnapshot
? mergeDeltaRows(nextRoles, response.roles, [], (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles })
: response.roles
: mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles });
nextWatermark = response.watermark ?? null; nextWatermark = response.watermark ?? null;
hasMore = response.has_more; hasMore = response.has_more;
} while (hasMore); } while (hasMore);

View File

@@ -24,7 +24,12 @@ export async function loadDeltaRows<TItem, TResponse extends AdminDeltaResponse>
do { do {
const response = await fetchDelta(nextWatermark); const response = await fetchDelta(nextWatermark);
const rows = rowsFromResponse(response); const rows = rowsFromResponse(response);
merged = response.full ? rows : mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort }); const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
merged = response.full
? continuingFullSnapshot
? mergeDeltaRows(merged, rows, [], getKey, { deletedResourceType, sort })
: rows
: mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
nextWatermark = response.watermark ?? null; nextWatermark = response.watermark ?? null;
hasMore = response.has_more; hasMore = response.has_more;
} while (hasMore); } while (hasMore);