Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab07075a67 | |||
| f2afcaa09c |
@@ -1,5 +1,9 @@
|
||||
# GovOPlaN Access
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-access` is the platform module for GovOPlaN identity,
|
||||
authentication, sessions, API keys, RBAC, groups, users, and access
|
||||
administration.
|
||||
@@ -98,7 +102,7 @@ available:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./scripts/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply
|
||||
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply
|
||||
```
|
||||
|
||||
## Development Install
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -18,7 +18,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.7",
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-access"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.7",
|
||||
"govoplan-core>=0.1.8",
|
||||
"SQLAlchemy>=2,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.service import (
|
||||
@@ -41,6 +44,7 @@ from govoplan_access.backend.db.models import (
|
||||
Tenant,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import OrganizationDirectory
|
||||
@@ -82,13 +86,147 @@ def _resolve_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
|
||||
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"
|
||||
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"
|
||||
permissions = list(role.permissions or [])
|
||||
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:
|
||||
member_ids = [
|
||||
row[0]
|
||||
for row in session.query(UserGroupMembership.user_id)
|
||||
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id)
|
||||
.all()
|
||||
]
|
||||
def _group_summary(
|
||||
session: Session,
|
||||
group: Group,
|
||||
*,
|
||||
include_members: bool = True,
|
||||
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 = (
|
||||
session.query(Role)
|
||||
.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()
|
||||
roles_by_group.get(group.id, [])
|
||||
if roles_by_group is not None
|
||||
else (
|
||||
session.query(Role)
|
||||
.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(
|
||||
id=group.id,
|
||||
@@ -130,7 +284,7 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr
|
||||
is_active=group.is_active,
|
||||
member_count=len(member_ids),
|
||||
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,
|
||||
updated_at=group.updated_at,
|
||||
system_template_id=group.system_template_id,
|
||||
@@ -145,12 +299,18 @@ def _user_item(
|
||||
owner_ids: set[str] | None = None,
|
||||
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
||||
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:
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
|
||||
groups = collect_user_groups(session, user)
|
||||
roles = collect_direct_user_roles(session, user)
|
||||
groups = groups_by_user.get(user.id, []) if groups_by_user is not None else collect_user_groups(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 = (
|
||||
collect_external_function_roles(
|
||||
session,
|
||||
@@ -176,8 +336,18 @@ def _user_item(
|
||||
account_is_active=account.is_active,
|
||||
password_reset_required=account.password_reset_required,
|
||||
last_login_at=account.last_login_at,
|
||||
groups=[_group_summary(session, group, include_members=False) for group in groups],
|
||||
roles=[_role_summary(session, role) for role in roles],
|
||||
groups=[
|
||||
_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_delegation_ids=collect_function_delegation_ids(session, user),
|
||||
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:
|
||||
user = session.get(User, item.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
|
||||
if accounts_by_user_id is not None:
|
||||
account = accounts_by_user_id.get(item.user_id)
|
||||
else:
|
||||
user = session.get(User, item.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
return ApiKeyAdminItem(
|
||||
id=item.id,
|
||||
user_id=item.user_id,
|
||||
|
||||
@@ -3,41 +3,10 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
|
||||
|
||||
|
||||
class PermissionItem(BaseModel):
|
||||
@@ -98,6 +67,13 @@ class TenantOwnerCandidateListResponse(BaseModel):
|
||||
accounts: list[TenantOwnerCandidate]
|
||||
|
||||
|
||||
class PagedListResponse(BaseModel):
|
||||
total: int = 0
|
||||
page: int = 1
|
||||
page_size: int = 500
|
||||
pages: int = 1
|
||||
|
||||
|
||||
class TenantCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -179,7 +155,7 @@ class IdentityAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class IdentityListResponse(BaseModel):
|
||||
class IdentityListResponse(PagedListResponse):
|
||||
identities: list[IdentityAdminItem]
|
||||
|
||||
|
||||
@@ -529,7 +505,7 @@ class ResourceAccessExplanationResponse(BaseModel):
|
||||
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
class UserListResponse(PagedListResponse):
|
||||
users: list[UserAdminItem]
|
||||
|
||||
|
||||
@@ -567,7 +543,7 @@ class UserUpdateRequest(BaseModel):
|
||||
role_ids: list[str] | None = None
|
||||
|
||||
|
||||
class GroupListResponse(BaseModel):
|
||||
class GroupListResponse(PagedListResponse):
|
||||
groups: list[GroupSummary]
|
||||
|
||||
|
||||
@@ -599,7 +575,7 @@ class GroupUpdateRequest(BaseModel):
|
||||
role_ids: list[str] | None = None
|
||||
|
||||
|
||||
class RoleListResponse(BaseModel):
|
||||
class RoleListResponse(PagedListResponse):
|
||||
roles: list[RoleSummary]
|
||||
|
||||
|
||||
@@ -638,7 +614,7 @@ class SystemAccountItem(BaseModel):
|
||||
last_login_at: datetime | None = None
|
||||
|
||||
|
||||
class SystemAccountListResponse(BaseModel):
|
||||
class SystemAccountListResponse(PagedListResponse):
|
||||
accounts: list[SystemAccountItem]
|
||||
roles: list[RoleSummary]
|
||||
|
||||
@@ -704,42 +680,6 @@ class PolicySourceStepItem(BaseModel):
|
||||
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):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -912,7 +852,7 @@ class ApiKeyAdminItem(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ApiKeyListResponse(BaseModel):
|
||||
class ApiKeyListResponse(PagedListResponse):
|
||||
api_keys: list[ApiKeyAdminItem]
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.api.v1.schemas import (
|
||||
AuthGroupsResponse,
|
||||
AuthProfileResponse,
|
||||
AuthRolesResponse,
|
||||
AuthShellResponse,
|
||||
AuthSessionResponse,
|
||||
AuthSessionUserInfo,
|
||||
GroupInfo,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
@@ -22,9 +30,9 @@ from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityD
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
|
||||
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_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.i18n import (
|
||||
i18n_settings,
|
||||
@@ -36,24 +44,39 @@ from govoplan_core.i18n import (
|
||||
tenant_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.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.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.sessions import (
|
||||
authenticate_session_token,
|
||||
collect_user_authorization_context,
|
||||
collect_system_roles,
|
||||
collect_tenant_memberships,
|
||||
collect_user_groups,
|
||||
collect_user_roles,
|
||||
collect_user_scopes,
|
||||
create_auth_session,
|
||||
verify_auth_session_csrf,
|
||||
)
|
||||
|
||||
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:
|
||||
value = settings.auth_cookie_samesite.lower().strip()
|
||||
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]:
|
||||
return [
|
||||
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]
|
||||
|
||||
|
||||
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] = []
|
||||
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(
|
||||
TenantMembershipInfo(
|
||||
id=tenant.id,
|
||||
@@ -159,6 +204,20 @@ def _tenant_memberships(session: Session, account: Account) -> list[TenantMember
|
||||
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]:
|
||||
system_settings = get_system_settings(session)
|
||||
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]
|
||||
|
||||
|
||||
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(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -229,17 +536,42 @@ def _me_response(
|
||||
include_all_memberships: bool = True,
|
||||
identity_directory: IdentityDirectory | 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:
|
||||
tenant_roles = collect_user_roles(session, user)
|
||||
system_roles = collect_system_roles(session, account) if include_system else []
|
||||
groups = collect_user_groups(session, user)
|
||||
if tenant_roles is None:
|
||||
tenant_roles = collect_user_roles(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)
|
||||
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)
|
||||
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(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(
|
||||
id=tenant.id,
|
||||
slug=tenant.slug,
|
||||
@@ -266,8 +598,8 @@ def _me_response(
|
||||
scopes=frozenset(scopes),
|
||||
group_ids=frozenset(group.id for group in groups),
|
||||
role_ids=frozenset(role.id for role in tenant_roles + system_roles),
|
||||
function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)),
|
||||
delegation_ids=frozenset(collect_function_delegation_ids(session, user)),
|
||||
function_assignment_ids=frozenset(resolved_function_assignment_ids),
|
||||
delegation_ids=frozenset(resolved_delegation_ids),
|
||||
auth_method=auth_method,
|
||||
api_key_id=api_key_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)):
|
||||
account, user, tenant = _resolve_login_user(session, payload)
|
||||
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)
|
||||
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(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=maintenance_response_detail(maintenance_mode),
|
||||
@@ -312,19 +653,66 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
|
||||
account=account,
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
effective_scopes=me_payload.scopes,
|
||||
effective_scopes=effective_scopes,
|
||||
auth_method="session",
|
||||
session_id=created.model.id,
|
||||
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(),
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
|
||||
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")
|
||||
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(
|
||||
session,
|
||||
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_all_memberships=principal.auth_session is not None,
|
||||
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(
|
||||
payload: ProfileUpdateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
if principal.auth_session is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile")
|
||||
context = _resolve_auth_context(request, session)
|
||||
_verify_profile_mutation_allowed(request, context)
|
||||
|
||||
if "display_name" in payload.model_fields_set:
|
||||
principal.account.display_name = payload.display_name.strip() if payload.display_name else None
|
||||
session.add(principal.account)
|
||||
context.account.display_name = payload.display_name.strip() if payload.display_name else None
|
||||
session.add(context.account)
|
||||
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
|
||||
session.add(principal.user)
|
||||
next_settings = dict(principal.user.settings or {})
|
||||
context.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
|
||||
session.add(context.user)
|
||||
next_settings = dict(context.user.settings or {})
|
||||
settings_changed = False
|
||||
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_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
|
||||
tenant_enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale)
|
||||
system_enabled = system_enabled_language_codes(
|
||||
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)
|
||||
if "preferred_language" in payload.model_fields_set:
|
||||
preferred = normalize_language_code(payload.preferred_language)
|
||||
@@ -394,35 +791,23 @@ def update_profile(
|
||||
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
|
||||
settings_changed = True
|
||||
if settings_changed:
|
||||
principal.user.settings = next_settings
|
||||
session.add(principal.user)
|
||||
context.user.settings = next_settings
|
||||
session.add(context.user)
|
||||
|
||||
audit_from_principal(
|
||||
audit_event(
|
||||
session,
|
||||
principal,
|
||||
tenant_id=context.tenant.id,
|
||||
user_id=context.user.id,
|
||||
action="profile.updated",
|
||||
object_type="account",
|
||||
object_id=principal.account.id,
|
||||
object_id=context.account.id,
|
||||
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()
|
||||
return _me_response(
|
||||
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,
|
||||
)
|
||||
return _profile_response(session, context)
|
||||
|
||||
|
||||
@router.post("/switch-tenant", response_model=MeResponse)
|
||||
@router.post("/switch-tenant", response_model=AuthShellResponse)
|
||||
def switch_tenant(
|
||||
payload: SwitchTenantRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
@@ -441,14 +826,20 @@ def switch_tenant(
|
||||
if membership is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found")
|
||||
session.commit()
|
||||
return _me_response(
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
membership,
|
||||
account=principal.account,
|
||||
include_system=True,
|
||||
)
|
||||
return _shell_response(
|
||||
session,
|
||||
account=principal.account,
|
||||
user=membership,
|
||||
tenant=tenant,
|
||||
scopes=authorization_context.scopes,
|
||||
auth_method="session",
|
||||
session_id=principal.session_id,
|
||||
identity_id=principal.principal.identity_id,
|
||||
auth_session=principal.auth_session,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -41,15 +41,23 @@ from govoplan_access.backend.admin.service import (
|
||||
update_system_role,
|
||||
)
|
||||
from govoplan_access.backend.api.v1.admin_common import (
|
||||
_accounts_by_id,
|
||||
_accounts_by_user_id,
|
||||
_api_key_item,
|
||||
_group_member_ids_by_group_id,
|
||||
_group_summary,
|
||||
_groups_by_user_id,
|
||||
_http_admin_error,
|
||||
_require_permission,
|
||||
_require_system_role_assignment,
|
||||
_resolve_tenant,
|
||||
_roles_by_group_id,
|
||||
_roles_by_user_id,
|
||||
_role_summary,
|
||||
_set_system_memberships,
|
||||
_system_role_assignment_counts,
|
||||
_system_account_item,
|
||||
_tenant_role_assignment_counts,
|
||||
_user_item,
|
||||
)
|
||||
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_ACCOUNTS_COLLECTION = "access.admin.system_accounts"
|
||||
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:
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
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 = (
|
||||
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())
|
||||
.filter(IdentityAccountLink.identity_id.in_(identity_ids))
|
||||
.order_by(IdentityAccountLink.identity_id.asc(), IdentityAccountLink.is_primary.desc(), Account.email.asc())
|
||||
.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(
|
||||
id=identity.id,
|
||||
display_name=identity.display_name,
|
||||
@@ -1073,12 +1141,16 @@ def approve_configuration_change_request_endpoint(
|
||||
|
||||
@router.get("/identities", response_model=IdentityListResponse)
|
||||
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),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "access:account:read")),
|
||||
):
|
||||
del principal
|
||||
identities = session.query(Identity).order_by(Identity.display_name.asc(), Identity.created_at.asc()).all()
|
||||
return IdentityListResponse(identities=[_identity_item(session, item) for item in identities])
|
||||
query = session.query(Identity).order_by(Identity.display_name.asc(), Identity.created_at.asc())
|
||||
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)
|
||||
@@ -1952,24 +2024,67 @@ def revoke_function_delegation(
|
||||
return None
|
||||
|
||||
|
||||
def _full_users_delta_response(session: Session, tenant: Tenant) -> UserListDeltaResponse:
|
||||
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all()
|
||||
def _user_items_for_response(session: Session, tenant: Tenant, users: list[User]) -> list[UserAdminItem]:
|
||||
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)
|
||||
idm_directory = _optional_idm_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(
|
||||
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=[],
|
||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)),
|
||||
has_more=False,
|
||||
watermark=watermark,
|
||||
has_more=has_more,
|
||||
full=True,
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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")
|
||||
visible = {
|
||||
user.id: user
|
||||
@@ -1978,16 +2093,13 @@ def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit
|
||||
if changed_ids else []
|
||||
)
|
||||
}
|
||||
owner_ids = tenant_owner_user_ids(session, tenant.id)
|
||||
idm_directory = _optional_idm_directory()
|
||||
organization_directory = _optional_organization_directory()
|
||||
deleted = [
|
||||
_delta_deleted_item(entry)
|
||||
for entry in entries
|
||||
if entry.resource_type == "access_user" and entry.resource_id not in visible
|
||||
]
|
||||
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,
|
||||
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more),
|
||||
has_more=has_more,
|
||||
@@ -2002,6 +2114,12 @@ def _user_item_for_response(
|
||||
owner_ids: set[str] | None = None,
|
||||
idm_directory: IdmDirectory | 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:
|
||||
idm_assignments = _idm_assignments_for_user(idm_directory, user)
|
||||
return _user_item(
|
||||
@@ -2010,6 +2128,12 @@ def _user_item_for_response(
|
||||
owner_ids=owner_ids,
|
||||
idm_assignments=idm_assignments,
|
||||
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")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
if since is None:
|
||||
return _full_users_delta_response(session, tenant)
|
||||
full_cursor = _decode_full_delta_cursor(since, scope="users")
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/users", response_model=UserListResponse)
|
||||
def list_users(
|
||||
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),
|
||||
principal: ApiPrincipal = Depends(require_scope("admin:users:read")),
|
||||
):
|
||||
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()
|
||||
owner_ids = tenant_owner_user_ids(session, tenant.id)
|
||||
idm_directory = _optional_idm_directory()
|
||||
organization_directory = _optional_organization_directory()
|
||||
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])
|
||||
query = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc())
|
||||
users, pagination = _page_query(query, page=page, page_size=page_size)
|
||||
return UserListResponse(
|
||||
users=_user_items_for_response(session, tenant, users),
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
@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 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),
|
||||
):
|
||||
def _require_user_update_permissions(principal: ApiPrincipal, payload: UserUpdateRequest) -> None:
|
||||
if "display_name" in payload.model_fields_set:
|
||||
_require_permission(principal, "admin:users:update")
|
||||
if payload.is_active is not None:
|
||||
@@ -2214,10 +2335,16 @@ def update_user(
|
||||
_require_permission(principal, "admin:groups:manage_members")
|
||||
if payload.role_ids is not None:
|
||||
_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:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
|
||||
def _apply_user_update(
|
||||
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_role_ids = _current_user_role_ids(session, user) if payload.role_ids is not None else set()
|
||||
try:
|
||||
@@ -2234,6 +2361,19 @@ def update_user(
|
||||
assert_tenant_owner_exists(session, tenant.id)
|
||||
except (AdminConflictError, AdminValidationError) as 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(
|
||||
session,
|
||||
collection=ACCESS_USERS_COLLECTION,
|
||||
@@ -2263,6 +2403,37 @@ def update_user(
|
||||
tenant_id=tenant.id,
|
||||
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(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
@@ -2281,21 +2452,43 @@ def update_user(
|
||||
)
|
||||
|
||||
|
||||
def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse:
|
||||
groups = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()).all()
|
||||
def _group_summaries_for_response(session: Session, tenant: Tenant, groups: list[Group]) -> list[GroupSummary]:
|
||||
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(
|
||||
groups=[_group_summary(session, group) for group in groups],
|
||||
groups=_group_summaries_for_response(session, tenant, groups),
|
||||
deleted=[],
|
||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_GROUPS_COLLECTION,)),
|
||||
has_more=False,
|
||||
watermark=watermark,
|
||||
has_more=has_more,
|
||||
full=True,
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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")
|
||||
visible = {
|
||||
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
|
||||
]
|
||||
return GroupListDeltaResponse(
|
||||
groups=[_group_summary(session, group) for group in visible.values()],
|
||||
groups=_group_summaries_for_response(session, tenant, list(visible.values())),
|
||||
deleted=deleted,
|
||||
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), entries=entries, has_more=has_more),
|
||||
has_more=has_more,
|
||||
@@ -2327,20 +2520,27 @@ def list_groups_delta(
|
||||
principal: ApiPrincipal = Depends(require_scope("admin:groups:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
if since is None:
|
||||
return _full_groups_delta_response(session, tenant)
|
||||
full_cursor = _decode_full_delta_cursor(since, scope="groups")
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/groups", response_model=GroupListResponse)
|
||||
def list_groups(
|
||||
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),
|
||||
principal: ApiPrincipal = Depends(require_scope("admin:groups:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
groups = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()).all()
|
||||
return GroupListResponse(groups=[_group_summary(session, group) for group in groups])
|
||||
query = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc())
|
||||
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)
|
||||
@@ -2419,24 +2619,23 @@ def create_group(
|
||||
return _group_summary(session, group)
|
||||
|
||||
|
||||
@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),
|
||||
):
|
||||
def _require_group_update_permissions(principal: ApiPrincipal, payload: GroupUpdateRequest) -> None:
|
||||
if any(field in payload.model_fields_set for field in ("name", "description", "is_active")):
|
||||
_require_permission(principal, "admin:groups:write")
|
||||
if payload.member_ids is not None:
|
||||
_require_permission(principal, "admin:groups:manage_members")
|
||||
if payload.role_ids is not None:
|
||||
_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:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found")
|
||||
|
||||
|
||||
def _apply_group_update(
|
||||
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_role_ids = _current_group_role_ids(session, group) if payload.role_ids is not None else set()
|
||||
try:
|
||||
@@ -2456,6 +2655,19 @@ def update_group(
|
||||
assert_tenant_owner_exists(session, tenant.id)
|
||||
except (AdminConflictError, AdminValidationError) as 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(
|
||||
session,
|
||||
collection=ACCESS_GROUPS_COLLECTION,
|
||||
@@ -2485,6 +2697,37 @@ def update_group(
|
||||
tenant_id=tenant.id,
|
||||
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(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
@@ -2498,23 +2741,32 @@ def update_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)
|
||||
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(
|
||||
roles=[_role_summary(session, role) for role in roles],
|
||||
roles=_tenant_role_summaries_for_response(session, roles),
|
||||
deleted=[],
|
||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_ROLES_COLLECTION,)),
|
||||
has_more=False,
|
||||
watermark=watermark,
|
||||
has_more=has_more,
|
||||
full=True,
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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")
|
||||
visible = {
|
||||
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
|
||||
]
|
||||
return RoleListDeltaResponse(
|
||||
roles=[_role_summary(session, role) for role in visible.values()],
|
||||
roles=_tenant_role_summaries_for_response(session, list(visible.values())),
|
||||
deleted=deleted,
|
||||
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), entries=entries, has_more=has_more),
|
||||
has_more=has_more,
|
||||
@@ -2546,22 +2798,29 @@ def list_roles_delta(
|
||||
principal: ApiPrincipal = Depends(require_scope("admin:roles:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
if since is None:
|
||||
return _full_roles_delta_response(session, tenant)
|
||||
full_cursor = _decode_full_delta_cursor(since, scope="roles")
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/roles", response_model=RoleListResponse)
|
||||
def list_roles(
|
||||
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),
|
||||
principal: ApiPrincipal = Depends(require_scope("admin:roles:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
ensure_default_roles(session, tenant)
|
||||
session.commit()
|
||||
roles = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc()).all()
|
||||
return RoleListResponse(roles=[_role_summary(session, role) for role in roles])
|
||||
query = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc())
|
||||
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)
|
||||
@@ -2718,23 +2977,32 @@ def delete_role(
|
||||
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)
|
||||
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(
|
||||
roles=[_role_summary(session, role) for role in roles],
|
||||
roles=_system_role_summaries_for_response(session, roles),
|
||||
deleted=[],
|
||||
watermark=_access_delta_watermark(session, None, (ACCESS_SYSTEM_ROLES_COLLECTION,)),
|
||||
has_more=False,
|
||||
watermark=watermark,
|
||||
has_more=has_more,
|
||||
full=True,
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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")
|
||||
visible = {
|
||||
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
|
||||
]
|
||||
return RoleListDeltaResponse(
|
||||
roles=[_role_summary(session, role) for role in visible.values()],
|
||||
roles=_system_role_summaries_for_response(session, list(visible.values())),
|
||||
deleted=deleted,
|
||||
watermark=_access_delta_response_watermark(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), entries=entries, 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")),
|
||||
):
|
||||
del principal
|
||||
if since is None:
|
||||
return _full_system_roles_delta_response(session)
|
||||
full_cursor = _decode_full_delta_cursor(since, scope="system-roles")
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/system/roles", response_model=RoleListResponse)
|
||||
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),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")),
|
||||
):
|
||||
ensure_default_roles(session, None)
|
||||
session.commit()
|
||||
roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
|
||||
return RoleListResponse(roles=[_role_summary(session, role) for role in roles])
|
||||
query = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc())
|
||||
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)
|
||||
@@ -2901,25 +3176,29 @@ def delete_system_role_endpoint(
|
||||
_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)
|
||||
session.commit()
|
||||
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(
|
||||
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=[],
|
||||
watermark=_access_delta_watermark(session, None, _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS),
|
||||
has_more=False,
|
||||
watermark=watermark,
|
||||
has_more=has_more,
|
||||
full=True,
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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")
|
||||
role_ids = _changed_ids(entries, "access_system_role")
|
||||
visible_accounts = {
|
||||
@@ -2946,7 +3225,7 @@ def _system_accounts_delta_response(session: Session, *, since: str, limit: int)
|
||||
]
|
||||
return SystemAccountListDeltaResponse(
|
||||
accounts=[_system_account_item(session, account) for account in visible_accounts.values()],
|
||||
roles=[_role_summary(session, role) for role in visible_roles.values()],
|
||||
roles=_system_role_summaries_for_response(session, list(visible_roles.values())),
|
||||
deleted=deleted,
|
||||
watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more),
|
||||
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")),
|
||||
):
|
||||
del principal
|
||||
if since is None:
|
||||
return _full_system_accounts_delta_response(session)
|
||||
full_cursor = _decode_full_delta_cursor(since, scope="system-accounts")
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/system/accounts", response_model=SystemAccountListResponse)
|
||||
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),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")),
|
||||
):
|
||||
ensure_default_roles(session, None)
|
||||
session.commit()
|
||||
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(
|
||||
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 update_system_account(
|
||||
account_id: str,
|
||||
payload: SystemAccountUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
):
|
||||
def _require_system_account_update_permissions(principal: ApiPrincipal, payload: SystemAccountUpdateRequest) -> None:
|
||||
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")
|
||||
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")
|
||||
if payload.role_ids is not None:
|
||||
_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()
|
||||
if "display_name" in payload.model_fields_set:
|
||||
account.display_name = payload.display_name.strip() if payload.display_name else None
|
||||
@@ -3013,18 +3308,21 @@ def update_system_account(
|
||||
session.flush()
|
||||
assert_system_owner_exists(session)
|
||||
if not account.is_active:
|
||||
tenant_ids = [
|
||||
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:
|
||||
for tenant_id in _active_tenant_ids_for_account(session, account):
|
||||
assert_tenant_owner_exists(session, tenant_id)
|
||||
except (AdminConflictError, AdminValidationError) as 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(
|
||||
session,
|
||||
collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION,
|
||||
@@ -3044,6 +3342,27 @@ def update_system_account(
|
||||
tenant_id=None,
|
||||
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(
|
||||
session,
|
||||
principal,
|
||||
@@ -3266,24 +3585,38 @@ def update_system_account_memberships(
|
||||
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)
|
||||
if not include_revoked:
|
||||
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(
|
||||
api_keys=[_api_key_item(session, item) for item in keys],
|
||||
api_keys=_api_key_items_for_response(session, keys),
|
||||
deleted=[],
|
||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_API_KEYS_COLLECTION,)),
|
||||
has_more=False,
|
||||
watermark=watermark,
|
||||
has_more=has_more,
|
||||
full=True,
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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")
|
||||
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
|
||||
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
|
||||
]
|
||||
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,
|
||||
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,
|
||||
@@ -3319,8 +3652,9 @@ def list_api_keys_delta(
|
||||
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
if since is None:
|
||||
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked)
|
||||
full_cursor = _decode_full_delta_cursor(since, scope="api-keys")
|
||||
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)
|
||||
|
||||
|
||||
@@ -3328,6 +3662,8 @@ def list_api_keys_delta(
|
||||
def list_api_keys(
|
||||
tenant_id: str | None = Query(default=None),
|
||||
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),
|
||||
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)
|
||||
if not include_revoked:
|
||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
||||
keys = query.order_by(ApiKey.created_at.desc()).all()
|
||||
return ApiKeyListResponse(api_keys=[_api_key_item(session, item) for item in keys])
|
||||
keys, pagination = _page_query(query.order_by(ApiKey.created_at.desc()), page=page, page_size=page_size)
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
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.db.session import get_database, get_session
|
||||
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.sessions import (
|
||||
authenticate_session_token,
|
||||
collect_user_groups,
|
||||
collect_user_roles,
|
||||
collect_user_scopes,
|
||||
collect_user_authorization_context,
|
||||
UserAuthorizationContext,
|
||||
verify_auth_session_csrf,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@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]:
|
||||
if x_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"}
|
||||
|
||||
|
||||
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(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -67,22 +74,32 @@ def _build_principal_ref(
|
||||
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
extra_roles: tuple[Role, ...] = (),
|
||||
authorization_context: UserAuthorizationContext | None = None,
|
||||
include_system_roles: bool = False,
|
||||
) -> 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)
|
||||
roles = collect_user_roles(session, user)
|
||||
role_ids = [role.id for role in roles]
|
||||
role_ids.extend(role.id for role in extra_roles)
|
||||
role_ids = [role.id for role in authorization_context.tenant_roles]
|
||||
if include_system_roles:
|
||||
role_ids.extend(role.id for role in authorization_context.system_roles)
|
||||
return PrincipalRef(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant_id,
|
||||
identity_id=identity_id_for_account(session, account.id, identity_directory=identity_directory),
|
||||
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))),
|
||||
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]
|
||||
api_key_id=api_key.id if api_key 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(
|
||||
request: Request,
|
||||
session: Session,
|
||||
@@ -134,76 +166,232 @@ def _resolve_legacy_principal_ref(
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> 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)
|
||||
if not 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
|
||||
# intersection of the key grant and the owner's current tenant roles.
|
||||
api_key = authenticate_api_key(session, token)
|
||||
if api_key:
|
||||
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)
|
||||
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.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)
|
||||
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
|
||||
user_scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=False), idm_roles)
|
||||
effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or [])
|
||||
session.commit()
|
||||
return _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,
|
||||
)
|
||||
if api_key is None:
|
||||
return None
|
||||
user = session.get(User, api_key.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
tenant = session.get(Tenant, api_key.tenant_id)
|
||||
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.tenant_id != api_key.tenant_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=api_key.tenant_id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=False,
|
||||
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)
|
||||
if auth_session:
|
||||
user = session.get(User, auth_session.user_id)
|
||||
account = session.get(Account, auth_session.account_id)
|
||||
tenant = session.get(Tenant, auth_session.tenant_id)
|
||||
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 != tenant.id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal")
|
||||
if source == "cookie" and _requires_csrf(request):
|
||||
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")
|
||||
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=user.tenant_id)
|
||||
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
|
||||
scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=True), idm_roles)
|
||||
session.commit()
|
||||
return _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,
|
||||
)
|
||||
if auth_session is None:
|
||||
return None
|
||||
user = session.get(User, auth_session.user_id)
|
||||
account = session.get(Account, auth_session.account_id)
|
||||
tenant = session.get(Tenant, auth_session.tenant_id)
|
||||
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 != tenant.id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session principal")
|
||||
if source == "cookie":
|
||||
_verify_session_csrf(request, auth_session)
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=user.tenant_id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=True,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
scopes = authorization_context.scopes
|
||||
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(
|
||||
@@ -217,13 +405,6 @@ def _idm_assignments_for_account(
|
||||
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:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
return registry if isinstance(registry, PlatformRegistry) else None
|
||||
@@ -326,19 +507,32 @@ def resolve_api_principal(
|
||||
) -> ApiPrincipal:
|
||||
resolver = _principal_resolver_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:
|
||||
principal = resolver.resolve_request(request, session=session)
|
||||
api_principal = _api_principal_from_ref(session, principal, permission_evaluator=permission_evaluator)
|
||||
_enforce_maintenance_mode(session, api_principal)
|
||||
return api_principal
|
||||
|
||||
principal = _resolve_legacy_principal_ref(
|
||||
context = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
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)
|
||||
return api_principal
|
||||
|
||||
|
||||
@@ -600,7 +600,7 @@ def _route_factory(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="0.1.7",
|
||||
version="0.1.8",
|
||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||
permissions=ACCESS_PERMISSIONS,
|
||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""v0.1.7 access baseline
|
||||
|
||||
Revision ID: 4a5b6c7d8e9f
|
||||
Revises: None
|
||||
Create Date: 2026-07-11 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '4a5b6c7d8e9f'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = '4f2a9c8e7b6d'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('access_identities',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('display_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('external_subject', sa.String(length=255), nullable=True),
|
||||
sa.Column('source', sa.String(length=50), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_identities'))
|
||||
)
|
||||
op.create_index(op.f('ix_access_identities_external_subject'), 'access_identities', ['external_subject'], unique=False)
|
||||
op.create_table('access_organization_units',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('parent_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['access_organization_units.id'], name=op.f('fk_access_organization_units_parent_id_access_organization_units'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_organization_units_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_organization_units')),
|
||||
sa.UniqueConstraint('tenant_id', 'slug', name='uq_organization_units_tenant_slug')
|
||||
)
|
||||
op.create_index(op.f('ix_access_organization_units_parent_id'), 'access_organization_units', ['parent_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_organization_units_tenant_id'), 'access_organization_units', ['tenant_id'], unique=False)
|
||||
op.create_table('access_external_function_role_assignments',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('source_module', sa.String(length=50), nullable=False),
|
||||
sa.Column('function_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('role_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_external_function_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_external_function_role_assignments_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_external_function_role_assignments')),
|
||||
sa.UniqueConstraint('tenant_id', 'source_module', 'function_id', 'role_id', name='uq_external_function_role_assignments')
|
||||
)
|
||||
op.create_index(op.f('ix_access_external_function_role_assignments_function_id'), 'access_external_function_role_assignments', ['function_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_external_function_role_assignments_role_id'), 'access_external_function_role_assignments', ['role_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_external_function_role_assignments_source_module'), 'access_external_function_role_assignments', ['source_module'], unique=False)
|
||||
op.create_index(op.f('ix_access_external_function_role_assignments_tenant_id'), 'access_external_function_role_assignments', ['tenant_id'], unique=False)
|
||||
op.create_table('access_functions',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('delegable', sa.Boolean(), nullable=False),
|
||||
sa.Column('act_in_place_allowed', sa.Boolean(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['organization_unit_id'], ['access_organization_units.id'], name=op.f('fk_access_functions_organization_unit_id_access_organization_units'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_functions_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_functions')),
|
||||
sa.UniqueConstraint('tenant_id', 'organization_unit_id', 'slug', name='uq_functions_tenant_ou_slug')
|
||||
)
|
||||
op.create_index(op.f('ix_access_functions_organization_unit_id'), 'access_functions', ['organization_unit_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_functions_tenant_id'), 'access_functions', ['tenant_id'], unique=False)
|
||||
op.create_table('access_identity_account_links',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('identity_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('account_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('is_primary', sa.Boolean(), nullable=False),
|
||||
sa.Column('source', sa.String(length=50), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['access_accounts.id'], name=op.f('fk_access_identity_account_links_account_id_access_accounts'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['identity_id'], ['access_identities.id'], name=op.f('fk_access_identity_account_links_identity_id_access_identities'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_identity_account_links')),
|
||||
sa.UniqueConstraint('identity_id', 'account_id', name='uq_identity_account_links_identity_account')
|
||||
)
|
||||
op.create_index(op.f('ix_access_identity_account_links_account_id'), 'access_identity_account_links', ['account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_identity_account_links_identity_id'), 'access_identity_account_links', ['identity_id'], unique=False)
|
||||
op.create_index('uq_identity_account_links_primary_account', 'access_identity_account_links', ['account_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
|
||||
op.create_index('uq_identity_account_links_primary_identity', 'access_identity_account_links', ['identity_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
|
||||
op.create_table('access_function_assignments',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('account_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('identity_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('function_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('applies_to_subunits', sa.Boolean(), nullable=False),
|
||||
sa.Column('source', sa.String(length=50), nullable=False),
|
||||
sa.Column('delegated_from_assignment_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('acting_for_account_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['access_accounts.id'], name=op.f('fk_access_function_assignments_account_id_access_accounts'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['acting_for_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_assignments_acting_for_account_id_access_accounts'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['delegated_from_assignment_id'], ['access_function_assignments.id'], name=op.f('fk_access_function_assignments_delegated_from_assignment_id_access_function_assignments'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['function_id'], ['access_functions.id'], name=op.f('fk_access_function_assignments_function_id_access_functions'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['identity_id'], ['access_identities.id'], name=op.f('fk_access_function_assignments_identity_id_access_identities'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['organization_unit_id'], ['access_organization_units.id'], name=op.f('fk_access_function_assignments_organization_unit_id_access_organization_units'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_assignments_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_assignments')),
|
||||
sa.UniqueConstraint('tenant_id', 'account_id', 'function_id', 'organization_unit_id', name='uq_function_assignments_account_scope')
|
||||
)
|
||||
op.create_index(op.f('ix_access_function_assignments_account_id'), 'access_function_assignments', ['account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_acting_for_account_id'), 'access_function_assignments', ['acting_for_account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_delegated_from_assignment_id'), 'access_function_assignments', ['delegated_from_assignment_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_function_id'), 'access_function_assignments', ['function_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_identity_id'), 'access_function_assignments', ['identity_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_organization_unit_id'), 'access_function_assignments', ['organization_unit_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_tenant_id'), 'access_function_assignments', ['tenant_id'], unique=False)
|
||||
op.create_table('access_function_role_assignments',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('function_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('role_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['function_id'], ['access_functions.id'], name=op.f('fk_access_function_role_assignments_function_id_access_functions'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_function_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_role_assignments_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_role_assignments')),
|
||||
sa.UniqueConstraint('tenant_id', 'function_id', 'role_id', name='uq_function_role_assignments')
|
||||
)
|
||||
op.create_index(op.f('ix_access_function_role_assignments_function_id'), 'access_function_role_assignments', ['function_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_role_assignments_role_id'), 'access_function_role_assignments', ['role_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_role_assignments_tenant_id'), 'access_function_role_assignments', ['tenant_id'], unique=False)
|
||||
op.create_table('access_function_delegations',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('function_assignment_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('delegator_account_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('delegate_account_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('mode', sa.String(length=30), nullable=False),
|
||||
sa.Column('reason', sa.Text(), nullable=True),
|
||||
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['delegate_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_delegations_delegate_account_id_access_accounts'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['delegator_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_delegations_delegator_account_id_access_accounts'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['function_assignment_id'], ['access_function_assignments.id'], name=op.f('fk_access_function_delegations_function_assignment_id_access_function_assignments'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_delegations_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_delegations')),
|
||||
sa.UniqueConstraint('tenant_id', 'function_assignment_id', 'delegate_account_id', 'mode', name='uq_function_delegations_assignment_delegate_mode')
|
||||
)
|
||||
op.create_index(op.f('ix_access_function_delegations_delegate_account_id'), 'access_function_delegations', ['delegate_account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_delegations_delegator_account_id'), 'access_function_delegations', ['delegator_account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_delegations_function_assignment_id'), 'access_function_delegations', ['function_assignment_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_delegations_revoked_at'), 'access_function_delegations', ['revoked_at'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_delegations_tenant_id'), 'access_function_delegations', ['tenant_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('access_function_delegations')
|
||||
op.drop_table('access_function_role_assignments')
|
||||
op.drop_table('access_function_assignments')
|
||||
op.drop_table('access_identity_account_links')
|
||||
op.drop_table('access_functions')
|
||||
op.drop_table('access_external_function_role_assignments')
|
||||
op.drop_table('access_organization_units')
|
||||
op.drop_table('access_identities')
|
||||
@@ -3,71 +3,26 @@ from __future__ import annotations
|
||||
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 scopes_grant as _scopes_grant
|
||||
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
|
||||
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]] = {
|
||||
"campaign:write": frozenset({
|
||||
"campaign:create",
|
||||
"campaign:update",
|
||||
"campaign:copy",
|
||||
"campaign:archive",
|
||||
"campaign:delete",
|
||||
"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)
|
||||
def _legacy_permission(permission: CoreLegacyPermissionDefinition) -> PermissionDefinition:
|
||||
parts = permission.scope.split(":", 2)
|
||||
if len(parts) == 2:
|
||||
module_id, action = parts
|
||||
resource = module_id
|
||||
else:
|
||||
module_id, resource, action = parts
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category=category,
|
||||
level=level,
|
||||
scope=permission.scope,
|
||||
label=permission.label,
|
||||
description=permission.description,
|
||||
category=permission.category,
|
||||
level=permission.level, # type: ignore[arg-type]
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
@@ -75,43 +30,9 @@ def _permission(scope: str, label: str, description: str, category: str, level:
|
||||
)
|
||||
|
||||
|
||||
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = (
|
||||
_permission("admin:users:read", "View users", "List tenant users and their effective access.", "Tenant administration", "tenant"),
|
||||
_permission("admin:users:create", "Create users", "Create tenant memberships for accounts.", "Tenant administration", "tenant"),
|
||||
_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"),
|
||||
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = tuple(
|
||||
_legacy_permission(permission)
|
||||
for permission in CORE_LEGACY_PERMISSION_DEFINITIONS
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
def scope_grants(granted: str, required: str) -> bool:
|
||||
catalog = permission_map(include_legacy=True)
|
||||
def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
|
||||
catalog = catalog if catalog is not None else permission_map(include_legacy=True)
|
||||
if _scope_grants(granted, required, catalog=catalog):
|
||||
return True
|
||||
for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()):
|
||||
if scope_grants(alias, required):
|
||||
if scope_grants(alias, required, catalog=catalog):
|
||||
return True
|
||||
return any(
|
||||
_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:
|
||||
return any(scope_grants(scope, required) for scope in scopes)
|
||||
def scopes_grant(scopes: Iterable[str], required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
|
||||
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]:
|
||||
@@ -173,7 +95,7 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis
|
||||
expanded.add(scope)
|
||||
for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()):
|
||||
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)
|
||||
for alias in compatible_required_scopes(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]:
|
||||
candidates = _effective_permission_candidates(level=level)
|
||||
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:
|
||||
@@ -207,7 +130,11 @@ def _effective_permission_candidates(*, level: PermissionLevel | None = None) ->
|
||||
continue
|
||||
if level is not None and definition.level != level:
|
||||
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
|
||||
candidates.add(scope)
|
||||
return candidates
|
||||
@@ -231,7 +158,7 @@ def validate_permissions(scopes: Iterable[str], *, level: PermissionLevel) -> li
|
||||
expanded.add(alias)
|
||||
continue
|
||||
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:
|
||||
expanded.add(scope)
|
||||
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]:
|
||||
user = list(user_scopes)
|
||||
key = list(key_scopes)
|
||||
tenant_scopes = {scope for scope, definition in permission_map(include_legacy=True).items() if definition.level == "tenant"}
|
||||
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope) and scopes_grant(key, scope)}
|
||||
catalog = permission_map(include_legacy=True)
|
||||
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))
|
||||
key_raw = set(expand_scopes(key))
|
||||
allowed.update(
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -18,7 +19,7 @@ from govoplan_access.backend.db.models import (
|
||||
UserGroupMembership,
|
||||
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_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
@@ -33,6 +34,16 @@ class CreatedSession:
|
||||
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:
|
||||
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]:
|
||||
scopes: set[str] = set()
|
||||
for role in collect_user_roles(session, user):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import or_
|
||||
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
|
||||
|
||||
|
||||
@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:
|
||||
return (
|
||||
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(
|
||||
session: Session,
|
||||
user: User,
|
||||
|
||||
58
tests/test_admin_batch_helpers.py
Normal file
58
tests/test_admin_batch_helpers.py
Normal 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()
|
||||
89
tests/test_admin_pagination.py
Normal file
89
tests/test_admin_pagination.py
Normal 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()
|
||||
49
tests/test_auth_dependencies.py
Normal file
49
tests/test_auth_dependencies.py
Normal 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()
|
||||
@@ -15,8 +15,8 @@ class OptionalTenancyContractTests(unittest.TestCase):
|
||||
|
||||
dependencies = tuple(project["dependencies"])
|
||||
|
||||
self.assertIn("govoplan-core>=0.1.6", dependencies)
|
||||
self.assertNotIn("govoplan-tenancy>=0.1.6", dependencies)
|
||||
self.assertIn("govoplan-core>=0.1.8", dependencies)
|
||||
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
|
||||
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
||||
|
||||
def test_tenancy_is_declared_as_optional_module_integration(self) -> None:
|
||||
|
||||
29
tests/test_permission_catalog_contract.py
Normal file
29
tests/test_permission_catalog_contract.py
Normal 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()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.7",
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -1,47 +1,16 @@
|
||||
import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui";
|
||||
import { apiFetch } from "@govoplan/core-webui";
|
||||
|
||||
export type PermissionItem = {
|
||||
scope: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: string;
|
||||
level: "tenant" | "system";
|
||||
system_template_id?: string | null;
|
||||
system_required?: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy, TenantAdminItem } from "@govoplan/core-webui";
|
||||
import { apiFetch, apiGetList, apiPath, apiQuery } from "@govoplan/core-webui";
|
||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||
export type {
|
||||
AdminOverview,
|
||||
PrivacyRetentionLimitPermissionPatch,
|
||||
PrivacyRetentionLimitPermissions,
|
||||
PrivacyRetentionPolicy,
|
||||
PrivacyRetentionPolicyFieldKey,
|
||||
PrivacyRetentionPolicyPatch,
|
||||
PermissionItem,
|
||||
TenantAdminItem
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type TenantOwnerCandidate = {
|
||||
account_id: string;
|
||||
@@ -195,28 +164,6 @@ export type SystemMembershipDraft = {
|
||||
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 = {
|
||||
default_locale: string;
|
||||
allow_tenant_custom_groups: boolean;
|
||||
@@ -317,36 +264,18 @@ export type TenantSettingsDeltaResponse = {
|
||||
} & DeltaResponseFields;
|
||||
|
||||
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options.since) params.set("since", options.since);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
return params.toString() ? `?${params.toString()}` : "";
|
||||
return apiQuery(options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
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> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`);
|
||||
}
|
||||
|
||||
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
|
||||
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
|
||||
return response.accounts;
|
||||
return apiGetList<TenantOwnerCandidate, "accounts">(settings, "/api/v1/admin/tenants/owner-candidates", "accounts");
|
||||
}
|
||||
|
||||
export function createTenant(settings: ApiSettings, payload: {
|
||||
@@ -429,19 +358,17 @@ export function fetchResourceAccessExplanation(
|
||||
settings: ApiSettings,
|
||||
options: { userId: string; resourceType: string; resourceId: string; action: string; tenantId?: string | null }
|
||||
): Promise<ResourceAccessExplanationResponse> {
|
||||
const params = new URLSearchParams({
|
||||
return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation", {
|
||||
user_id: options.userId,
|
||||
resource_type: options.resourceType,
|
||||
resource_id: options.resourceId,
|
||||
action: options.action
|
||||
});
|
||||
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
||||
return apiFetch(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
|
||||
action: options.action,
|
||||
tenant_id: options.tenantId
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
|
||||
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
|
||||
return response.groups;
|
||||
return apiGetList<GroupSummary, "groups">(settings, "/api/v1/admin/groups", "groups");
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles");
|
||||
return response.roles;
|
||||
return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/roles", "roles");
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
|
||||
return response.roles;
|
||||
return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/system/roles", "roles");
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const params = new URLSearchParams();
|
||||
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;
|
||||
return apiGetList<ApiKeyAdminItem, "api_keys">(settings, "/api/v1/admin/api-keys", "api_keys", { include_revoked: includeRevoked ? true : undefined });
|
||||
}
|
||||
|
||||
export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (includeRevoked) params.set("include_revoked", "true");
|
||||
if (options.since) params.set("since", options.since);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/api-keys/delta${suffix}`);
|
||||
return apiFetch(settings, apiPath("/api/v1/admin/api-keys/delta", {
|
||||
include_revoked: includeRevoked ? true : undefined,
|
||||
since: options.since,
|
||||
limit: options.limit
|
||||
}));
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
||||
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
|
||||
return response.templates;
|
||||
return apiGetList<GovernanceTemplateItem, "templates">(settings, "/api/v1/admin/system/governance-templates", "templates", { kind });
|
||||
}
|
||||
|
||||
export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GovernanceTemplateListDeltaResponse> {
|
||||
|
||||
@@ -5,11 +5,12 @@ import type {
|
||||
AdminSectionsUiCapability,
|
||||
ApiSettings,
|
||||
AuthInfo,
|
||||
AuthUpdate,
|
||||
FilesConnectorsUiCapability,
|
||||
MailProfilesUiCapability,
|
||||
OrganizationFunctionPickerUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import { fetchMe } from "@govoplan/core-webui";
|
||||
import { fetchShellAuth } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
||||
@@ -63,7 +64,7 @@ export default function AdminPage({
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
}) {
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
@@ -131,13 +132,9 @@ export default function AdminPage({
|
||||
}, [requestedSection, available, fallbackSection]);
|
||||
|
||||
async function refreshAuth() {
|
||||
onAuthChange(await fetchMe(settings));
|
||||
onAuthChange(await fetchShellAuth(settings));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAuth().catch(() => undefined);
|
||||
}, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
if (!hasAnyScope(auth, adminReadScopes)) {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
|
||||
@@ -83,8 +83,17 @@ export default function SystemUsersPanel({
|
||||
let hasMore = false;
|
||||
do {
|
||||
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 });
|
||||
nextRoles = response.full ? response.roles : mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles });
|
||||
const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
|
||||
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;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
|
||||
@@ -24,7 +24,12 @@ export async function loadDeltaRows<TItem, TResponse extends AdminDeltaResponse>
|
||||
do {
|
||||
const response = await fetchDelta(nextWatermark);
|
||||
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;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.add_api_key.725d9988": "Add API key",
|
||||
"i18n:govoplan-access.add_global_account.18e4df22": "Add global account",
|
||||
"i18n:govoplan-access.add_group.2fca464f": "Add group",
|
||||
"i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Add function role mapping",
|
||||
"i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Add function mapping",
|
||||
"i18n:govoplan-access.add_role.d8d5d55c": "Add role",
|
||||
"i18n:govoplan-access.add_system_role.f9ef262b": "Add system role",
|
||||
"i18n:govoplan-access.add_tenant_user.36f37ce7": "Add tenant user",
|
||||
@@ -55,7 +55,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.create_api_key.d7b30388": "Create API key",
|
||||
"i18n:govoplan-access.create_global_account.e821f016": "Create global account",
|
||||
"i18n:govoplan-access.create_group.5a0b1c17": "Create group",
|
||||
"i18n:govoplan-access.create_function_role_mapping.3718168d": "Create function role mapping",
|
||||
"i18n:govoplan-access.create_function_role_mapping.3718168d": "Create function mapping",
|
||||
"i18n:govoplan-access.create_key.e028cb09": "Create key",
|
||||
"i18n:govoplan-access.create_role.db859bad": "Create role",
|
||||
"i18n:govoplan-access.create_system_role.a1e40b25": "Create system role",
|
||||
@@ -80,7 +80,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}",
|
||||
"i18n:govoplan-access.default_locale.b99d021f": "Default locale",
|
||||
"i18n:govoplan-access.delete_role.fbf0667e": "Delete role",
|
||||
"i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Delete function role mapping",
|
||||
"i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Delete function mapping",
|
||||
"i18n:govoplan-access.delete_function_role_mapping_value.419da2aa": "Delete the mapping for {value0}? Accepted assignments will no longer grant the mapped role.",
|
||||
"i18n:govoplan-access.delete_mapping.0d27d92a": "Delete mapping",
|
||||
"i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
|
||||
@@ -95,7 +95,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.dry_run.485a3d15": "Dry run",
|
||||
"i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account",
|
||||
"i18n:govoplan-access.edit_group.edb57d8e": "Edit group",
|
||||
"i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Edit function role mapping",
|
||||
"i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Edit function mapping",
|
||||
"i18n:govoplan-access.edit_role.61dd63e9": "Edit role",
|
||||
"i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role",
|
||||
"i18n:govoplan-access.edit_tenant_user.99121a61": "Edit tenant user",
|
||||
@@ -114,11 +114,11 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.function_fact.4f7435e4": "Function fact",
|
||||
"i18n:govoplan-access.function_facts.848b32cc": "Function facts",
|
||||
"i18n:govoplan-access.function_id.e5e08937": "Function ID",
|
||||
"i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Function role mapping created.",
|
||||
"i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Function role mapping deleted.",
|
||||
"i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Function mapping created.",
|
||||
"i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Function mapping deleted.",
|
||||
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "The source module and function ID identify an accepted external function fact. Access grants the selected tenant role only while IDM reports an active accepted assignment for that fact.",
|
||||
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Function role mapping updated.",
|
||||
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function role mappings",
|
||||
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Function mapping updated.",
|
||||
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function mappings",
|
||||
"i18n:govoplan-access.general.9239ee2c": "General",
|
||||
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
||||
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
||||
@@ -186,7 +186,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.",
|
||||
"i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "No assignable roles exist.",
|
||||
"i18n:govoplan-access.no_expiry.39d436aa": "No expiry",
|
||||
"i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "No function role mappings found.",
|
||||
"i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "No function mappings found.",
|
||||
"i18n:govoplan-access.no_function_facts_found.b2ecee17": "No function facts found.",
|
||||
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
|
||||
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "No groups exist yet.",
|
||||
@@ -301,7 +301,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles",
|
||||
"i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy",
|
||||
"i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention",
|
||||
"i18n:govoplan-access.tenant_role_templates": "Tenant role templates",
|
||||
"i18n:govoplan-access.tenant_role_templates": "Role templates",
|
||||
"i18n:govoplan-access.tenant_roles.51aca82d": "Tenant roles",
|
||||
"i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.",
|
||||
"i18n:govoplan-access.tenant_user_details.fbab9079": "Tenant user details",
|
||||
@@ -365,7 +365,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.add_api_key.725d9988": "Add API key",
|
||||
"i18n:govoplan-access.add_global_account.18e4df22": "Add global account",
|
||||
"i18n:govoplan-access.add_group.2fca464f": "Gruppe hinzufügen",
|
||||
"i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Funktions-Rollenzuordnung hinzufügen",
|
||||
"i18n:govoplan-access.add_function_role_mapping.1bc376ac": "Funktionszuordnung hinzufügen",
|
||||
"i18n:govoplan-access.add_role.d8d5d55c": "Rolle hinzufügen",
|
||||
"i18n:govoplan-access.add_system_role.f9ef262b": "Add system role",
|
||||
"i18n:govoplan-access.add_tenant_user.36f37ce7": "Mandantenbenutzer hinzufügen",
|
||||
@@ -403,7 +403,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.create_api_key.d7b30388": "API-Schlüssel erstellen",
|
||||
"i18n:govoplan-access.create_global_account.e821f016": "Create global account",
|
||||
"i18n:govoplan-access.create_group.5a0b1c17": "Gruppe erstellen",
|
||||
"i18n:govoplan-access.create_function_role_mapping.3718168d": "Funktions-Rollenzuordnung erstellen",
|
||||
"i18n:govoplan-access.create_function_role_mapping.3718168d": "Funktionszuordnung erstellen",
|
||||
"i18n:govoplan-access.create_key.e028cb09": "Create key",
|
||||
"i18n:govoplan-access.create_role.db859bad": "Rolle erstellen",
|
||||
"i18n:govoplan-access.create_system_role.a1e40b25": "Create system role",
|
||||
@@ -428,7 +428,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.deactivate_value.a276a667": "Deactivate {value0}",
|
||||
"i18n:govoplan-access.default_locale.b99d021f": "Standardsprache",
|
||||
"i18n:govoplan-access.delete_role.fbf0667e": "Delete role",
|
||||
"i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Funktions-Rollenzuordnung löschen",
|
||||
"i18n:govoplan-access.delete_function_role_mapping.0c0eec6e": "Funktionszuordnung löschen",
|
||||
"i18n:govoplan-access.delete_function_role_mapping_value.419da2aa": "Zuordnung für {value0} löschen? Akzeptierte Zuweisungen gewähren die zugeordnete Rolle dann nicht mehr.",
|
||||
"i18n:govoplan-access.delete_mapping.0d27d92a": "Zuordnung löschen",
|
||||
"i18n:govoplan-access.delete_system_role.e2d84a56": "Delete system role",
|
||||
@@ -443,7 +443,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.dry_run.485a3d15": "Trockenlauf",
|
||||
"i18n:govoplan-access.edit_global_account.d13b8485": "Edit global account",
|
||||
"i18n:govoplan-access.edit_group.edb57d8e": "Gruppe bearbeiten",
|
||||
"i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Funktions-Rollenzuordnung bearbeiten",
|
||||
"i18n:govoplan-access.edit_function_role_mapping.91ee75af": "Funktionszuordnung bearbeiten",
|
||||
"i18n:govoplan-access.edit_role.61dd63e9": "Rolle bearbeiten",
|
||||
"i18n:govoplan-access.edit_system_role.6ebb7cb0": "Edit system role",
|
||||
"i18n:govoplan-access.edit_tenant_user.99121a61": "Mandantenbenutzer bearbeiten",
|
||||
@@ -462,11 +462,11 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.function_fact.4f7435e4": "Funktionsfakt",
|
||||
"i18n:govoplan-access.function_facts.848b32cc": "Funktionsfakten",
|
||||
"i18n:govoplan-access.function_id.e5e08937": "Funktions-ID",
|
||||
"i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Funktions-Rollenzuordnung erstellt.",
|
||||
"i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Funktions-Rollenzuordnung gelöscht.",
|
||||
"i18n:govoplan-access.function_role_mapping_created.7a25eb5a": "Funktionszuordnung erstellt.",
|
||||
"i18n:govoplan-access.function_role_mapping_deleted.fb180786": "Funktionszuordnung gelöscht.",
|
||||
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "Quellmodul und Funktions-ID identifizieren einen akzeptierten externen Funktionsfakt. Access gewährt die ausgewählte Mandantenrolle nur, solange IDM eine aktive akzeptierte Zuweisung für diesen Fakt meldet.",
|
||||
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktions-Rollenzuordnung aktualisiert.",
|
||||
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktions-Rollenzuordnungen",
|
||||
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktionszuordnung aktualisiert.",
|
||||
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktionszuordnungen",
|
||||
"i18n:govoplan-access.general.9239ee2c": "Allgemein",
|
||||
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
||||
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
||||
@@ -534,7 +534,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.no_api_keys_found.1f377128": "No API keys found.",
|
||||
"i18n:govoplan-access.no_assignable_roles_exist.a4c268c2": "Es gibt keine zuweisbaren Rollen.",
|
||||
"i18n:govoplan-access.no_expiry.39d436aa": "No expiry",
|
||||
"i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "Keine Funktions-Rollenzuordnungen gefunden.",
|
||||
"i18n:govoplan-access.no_function_role_mappings_found.f735ff54": "Keine Funktionszuordnungen gefunden.",
|
||||
"i18n:govoplan-access.no_function_facts_found.b2ecee17": "Keine Funktionsfakten gefunden.",
|
||||
"i18n:govoplan-access.no_global_accounts_found.29d96a9e": "No global accounts found.",
|
||||
"i18n:govoplan-access.no_groups_exist_yet.9cd029f6": "Es gibt noch keine Gruppen.",
|
||||
@@ -649,7 +649,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.tenant_profiles.4d7281ce": "Tenant profiles",
|
||||
"i18n:govoplan-access.tenant_retention_policy.f10893d7": "Tenant retention policy",
|
||||
"i18n:govoplan-access.tenant_retention.95b35db0": "Tenant retention",
|
||||
"i18n:govoplan-access.tenant_role_templates": "Mandantenrollenvorlagen",
|
||||
"i18n:govoplan-access.tenant_role_templates": "Rollenvorlagen",
|
||||
"i18n:govoplan-access.tenant_roles.51aca82d": "Mandantenrollen",
|
||||
"i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae": "Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited.",
|
||||
"i18n:govoplan-access.tenant_user_details.fbab9079": "Mandantenbenutzerdetails",
|
||||
|
||||
Reference in New Issue
Block a user