Files
govoplan-tenancy/src/govoplan_tenancy/backend/api/v1/routes.py

528 lines
23 KiB
Python

from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from govoplan_core.admin.common import AdminValidationError, slugify
from govoplan_core.admin.settings import get_system_settings
from govoplan_access.auth import ApiPrincipal, get_api_principal, has_scope, require_scope
from govoplan_core.audit.logging import audit_event
from govoplan_core.core.access import CAPABILITY_ACCESS_TENANT_PROVISIONER, TenantAccessProvisioner
from govoplan_core.core.change_sequence import ChangeSequenceEntry, decode_sequence_watermark, encode_sequence_watermark, record_change, sequence_watermark_is_expired
from govoplan_core.core.runtime import get_registry
from govoplan_core.db.session import get_session
from govoplan_core.i18n import (
i18n_settings,
normalize_enabled_language_codes,
system_enabled_language_codes,
system_i18n_payload,
tenant_enabled_language_codes,
update_i18n_settings,
)
from govoplan_core.tenancy.service import (
assert_tenant_governance_override_allowed,
effective_tenant_governance,
tenant_counts,
)
from govoplan_tenancy.backend.db.models import Tenant
from .schemas import (
TenantAdminItem,
TenantCreateRequest,
TenantListDeltaResponse,
TenantListResponse,
TenantOwnerCandidate,
TenantOwnerCandidateListResponse,
TenantSettingsItem,
TenantSettingsDeltaResponse,
TenantSettingsUpdateRequest,
TenantUpdateRequest,
)
router = APIRouter(prefix="/admin", tags=["admin"])
TENANCY_MODULE_ID = "tenancy"
TENANT_LIST_COLLECTION = "tenancy.tenants"
TENANT_LIST_RESOURCE = "tenant"
TENANT_SETTINGS_COLLECTION = "tenancy.tenant_settings"
TENANT_SETTINGS_RESOURCE = "tenant_settings_section"
ADMIN_MODULE_ID = "admin"
ADMIN_SYSTEM_SETTINGS_COLLECTION = "admin.system_settings"
TENANT_SETTINGS_SECTIONS = ("identity", "locale", "languages", "settings")
def _tenant_access_provisioner() -> TenantAccessProvisioner:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_TENANT_PROVISIONER):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access tenant provisioner capability is not configured")
capability = registry.require_capability(CAPABILITY_ACCESS_TENANT_PROVISIONER)
if not isinstance(capability, TenantAccessProvisioner):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access tenant provisioner capability is invalid")
return capability
def _require_permission(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem:
governance = effective_tenant_governance(session, tenant)
return TenantAdminItem(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
description=tenant.description,
default_locale=tenant.default_locale,
settings=tenant.settings or {},
allow_custom_groups=tenant.allow_custom_groups,
allow_custom_roles=tenant.allow_custom_roles,
allow_api_keys=tenant.allow_api_keys,
effective_governance={
"allow_custom_groups": governance.allow_custom_groups,
"allow_custom_roles": governance.allow_custom_roles,
"allow_api_keys": governance.allow_api_keys,
},
is_active=tenant.is_active,
counts=tenant_counts(session, tenant.id),
created_at=tenant.created_at,
updated_at=tenant.updated_at,
)
def _tenant_settings_item(session: Session, tenant: Tenant) -> TenantSettingsItem:
system_settings = get_system_settings(session)
system_payload = system_i18n_payload(system_settings)
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale)
return TenantSettingsItem(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
default_locale=tenant.default_locale,
available_languages=system_payload["available_languages"],
system_enabled_language_codes=system_enabled,
enabled_language_codes=enabled,
settings=tenant.settings or {},
)
def _tenant_settings_sections(item: TenantSettingsItem) -> dict[str, Any]:
payload = item.model_dump(mode="json")
return {
"identity": {"id": payload["id"], "slug": payload["slug"], "name": payload["name"]},
"locale": {"default_locale": payload["default_locale"]},
"languages": {
"available_languages": payload["available_languages"],
"system_enabled_language_codes": payload["system_enabled_language_codes"],
"enabled_language_codes": payload["enabled_language_codes"],
},
"settings": payload["settings"],
}
def _record_tenant_settings_section_changes(
session: Session,
*,
tenant_id: str,
before: dict[str, Any],
after: dict[str, Any],
principal: ApiPrincipal,
) -> None:
for section in TENANT_SETTINGS_SECTIONS:
if before.get(section) == after.get(section):
continue
record_change(
session,
module_id=TENANCY_MODULE_ID,
collection=TENANT_SETTINGS_COLLECTION,
resource_type=TENANT_SETTINGS_RESOURCE,
resource_id=section,
operation="updated",
tenant_id=tenant_id,
actor_type="user",
actor_id=principal.user.id,
payload={"section": section},
)
def _record_tenant_list_change(session: Session, *, tenant: Tenant, operation: str, principal: ApiPrincipal) -> None:
record_change(
session,
module_id=TENANCY_MODULE_ID,
collection=TENANT_LIST_COLLECTION,
resource_type=TENANT_LIST_RESOURCE,
resource_id=tenant.id,
operation=operation,
tenant_id=None,
actor_type="user",
actor_id=principal.user.id,
payload={"slug": tenant.slug, "name": tenant.name, "is_active": tenant.is_active},
)
def _tenant_list_delta_query(session: Session, *, since_sequence: int):
return session.query(ChangeSequenceEntry).filter(
ChangeSequenceEntry.id > since_sequence,
ChangeSequenceEntry.module_id == TENANCY_MODULE_ID,
ChangeSequenceEntry.collection == TENANT_LIST_COLLECTION,
)
def _tenant_list_watermark(session: Session) -> str:
sequence_id = _tenant_list_delta_query(session, since_sequence=0).with_entities(func.max(ChangeSequenceEntry.id)).scalar()
return encode_sequence_watermark(int(sequence_id or 0))
def _tenant_list_delta_entries(session: Session, *, since: str, limit: int):
try:
since_sequence = decode_sequence_watermark(since)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if sequence_watermark_is_expired(
session,
since=since_sequence,
tenant_id=None,
module_id=TENANCY_MODULE_ID,
collections=(TENANT_LIST_COLLECTION,),
):
return None, False
entries_plus_one = _tenant_list_delta_query(session, since_sequence=since_sequence).order_by(ChangeSequenceEntry.id.asc()).limit(limit + 1).all()
has_more = len(entries_plus_one) > limit
return entries_plus_one[:limit], has_more
def _tenant_list_response_watermark(session: Session, *, entries, has_more: bool) -> str:
return encode_sequence_watermark(entries[-1].id) if has_more and entries else _tenant_list_watermark(session)
def _tenant_list_deleted_entries(entries: list[ChangeSequenceEntry], visible_tenant_ids: set[str]):
return [
{"id": entry.resource_id, "resource_type": entry.resource_type or TENANT_LIST_RESOURCE}
for entry in entries
if entry.resource_id and (entry.operation == "deleted" or entry.resource_id not in visible_tenant_ids)
]
def _tenant_settings_delta_query(session: Session, *, tenant_id: str, since_sequence: int):
return session.query(ChangeSequenceEntry).filter(
ChangeSequenceEntry.id > since_sequence,
or_(
and_(
ChangeSequenceEntry.module_id == TENANCY_MODULE_ID,
ChangeSequenceEntry.collection == TENANT_SETTINGS_COLLECTION,
ChangeSequenceEntry.tenant_id == tenant_id,
),
and_(
ChangeSequenceEntry.module_id == ADMIN_MODULE_ID,
ChangeSequenceEntry.collection == ADMIN_SYSTEM_SETTINGS_COLLECTION,
ChangeSequenceEntry.resource_id == "languages",
),
),
)
def _tenant_settings_watermark(session: Session, *, tenant_id: str) -> str:
sequence_id = _tenant_settings_delta_query(session, tenant_id=tenant_id, since_sequence=0).with_entities(func.max(ChangeSequenceEntry.id)).scalar()
return encode_sequence_watermark(int(sequence_id or 0))
def _tenant_settings_delta_entries(session: Session, *, tenant_id: str, since: str, limit: int):
try:
since_sequence = decode_sequence_watermark(since)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if sequence_watermark_is_expired(
session,
since=since_sequence,
tenant_id=tenant_id,
module_id=TENANCY_MODULE_ID,
collections=(TENANT_SETTINGS_COLLECTION,),
):
return None, False
entries_plus_one = _tenant_settings_delta_query(
session,
tenant_id=tenant_id,
since_sequence=since_sequence,
).order_by(ChangeSequenceEntry.id.asc()).limit(limit + 1).all()
has_more = len(entries_plus_one) > limit
return entries_plus_one[:limit], has_more
def _tenant_settings_response_watermark(session: Session, *, tenant_id: str, entries, has_more: bool) -> str:
return encode_sequence_watermark(entries[-1].id) if has_more and entries else _tenant_settings_watermark(session, tenant_id=tenant_id)
@router.get("/tenants", response_model=TenantListResponse)
def list_tenants(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:read")),
):
tenants = session.query(Tenant).order_by(Tenant.name.asc()).all()
return TenantListResponse(tenants=[_tenant_item(session, tenant) for tenant in tenants])
def _full_tenant_list_delta_response(session: Session) -> TenantListDeltaResponse:
tenants = session.query(Tenant).order_by(Tenant.name.asc()).all()
return TenantListDeltaResponse(
tenants=[_tenant_item(session, tenant) for tenant in tenants],
deleted=[],
watermark=_tenant_list_watermark(session),
has_more=False,
full=True,
)
@router.get("/tenants/delta", response_model=TenantListDeltaResponse)
def list_tenants_delta(
since: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:read")),
):
del principal
if since is None:
return _full_tenant_list_delta_response(session)
entries, has_more = _tenant_list_delta_entries(session, since=since, limit=limit)
if entries is None:
return _full_tenant_list_delta_response(session)
changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"]
tenants = []
if changed_ids:
tenants = session.query(Tenant).filter(Tenant.id.in_(changed_ids)).order_by(Tenant.name.asc()).all()
visible_tenant_ids = {tenant.id for tenant in tenants}
return TenantListDeltaResponse(
tenants=[_tenant_item(session, tenant) for tenant in tenants],
deleted=_tenant_list_deleted_entries(entries, visible_tenant_ids),
watermark=_tenant_list_response_watermark(session, entries=entries, has_more=has_more),
has_more=has_more,
full=False,
)
@router.get("/tenants/owner-candidates", response_model=TenantOwnerCandidateListResponse)
def list_tenant_owner_candidates(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:create")),
):
del principal
accounts = _tenant_access_provisioner().tenant_owner_candidates(session)
return TenantOwnerCandidateListResponse(
accounts=[
TenantOwnerCandidate(account_id=account.account_id, email=account.email, display_name=account.display_name)
for account in accounts
]
)
@router.post("/tenants", response_model=TenantAdminItem, status_code=status.HTTP_201_CREATED)
def create_tenant(
payload: TenantCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:create")),
):
tenant_slug = slugify(payload.slug)
if session.query(Tenant).filter(Tenant.slug == tenant_slug).count():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A tenant with this slug already exists")
system_defaults = get_system_settings(session)
try:
assert_tenant_governance_override_allowed(session, field="allow_custom_groups", value=payload.allow_custom_groups)
assert_tenant_governance_override_allowed(session, field="allow_custom_roles", value=payload.allow_custom_roles)
assert_tenant_governance_override_allowed(session, field="allow_api_keys", value=payload.allow_api_keys)
except AdminValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
tenant = Tenant(
slug=tenant_slug,
name=payload.name.strip(),
description=payload.description.strip() if payload.description else None,
default_locale=payload.default_locale.strip() or system_defaults.default_locale,
settings=payload.settings,
allow_custom_groups=payload.allow_custom_groups,
allow_custom_roles=payload.allow_custom_roles,
allow_api_keys=payload.allow_api_keys,
is_active=True,
)
session.add(tenant)
session.flush()
owner_account_id = payload.owner_account_id or principal.account_id
try:
_tenant_access_provisioner().ensure_tenant_owner_membership(
session,
tenant=tenant,
owner_account_id=owner_account_id,
)
except AdminValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="tenant.created",
object_type="tenant",
object_id=tenant.id,
scope="system",
details={"slug": tenant.slug, "name": tenant.name, "creator_account_id": principal.account_id, "owner_account_id": owner_account_id},
)
_record_tenant_list_change(session, tenant=tenant, operation="created", principal=principal)
session.commit()
return _tenant_item(session, tenant)
@router.patch("/tenants/{tenant_id}", response_model=TenantAdminItem)
def update_tenant(
tenant_id: str,
payload: TenantUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
non_status_fields = {"name", "description", "default_locale", "settings", "allow_custom_groups", "allow_custom_roles", "allow_api_keys"}
if payload.model_fields_set.intersection(non_status_fields):
_require_permission(principal, "system:tenants:update")
if payload.is_active is not None:
_require_permission(principal, "system:tenants:suspend")
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
before_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant))
if payload.name is not None:
tenant.name = payload.name.strip()
if "description" in payload.model_fields_set:
tenant.description = payload.description.strip() if payload.description and payload.description.strip() else None
if payload.default_locale is not None:
tenant.default_locale = payload.default_locale.strip() or "en"
if payload.settings is not None:
tenant.settings = payload.settings
try:
if "allow_custom_groups" in payload.model_fields_set:
assert_tenant_governance_override_allowed(session, field="allow_custom_groups", value=payload.allow_custom_groups)
tenant.allow_custom_groups = payload.allow_custom_groups
if "allow_custom_roles" in payload.model_fields_set:
assert_tenant_governance_override_allowed(session, field="allow_custom_roles", value=payload.allow_custom_roles)
tenant.allow_custom_roles = payload.allow_custom_roles
if "allow_api_keys" in payload.model_fields_set:
assert_tenant_governance_override_allowed(session, field="allow_api_keys", value=payload.allow_api_keys)
tenant.allow_api_keys = payload.allow_api_keys
except AdminValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
if payload.is_active is not None:
if not payload.is_active and tenant.id == principal.tenant_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Switch to another tenant before suspending the active tenant.",
)
tenant.is_active = payload.is_active
session.add(tenant)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="tenant.updated",
scope="system",
object_type="tenant",
object_id=tenant.id,
details=payload.model_dump(exclude_unset=True),
)
after_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant))
_record_tenant_settings_section_changes(session, tenant_id=tenant.id, before=before_sections, after=after_sections, principal=principal)
_record_tenant_list_change(session, tenant=tenant, operation="updated", principal=principal)
session.commit()
return _tenant_item(session, tenant)
@router.get("/tenant/settings", response_model=TenantSettingsItem)
def get_tenant_settings(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:settings:read")),
):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
return _tenant_settings_item(session, tenant)
def _full_tenant_settings_delta_response(session: Session, tenant: Tenant) -> TenantSettingsDeltaResponse:
item = _tenant_settings_item(session, tenant)
return TenantSettingsDeltaResponse(
item=item,
sections=_tenant_settings_sections(item),
changed_sections=list(TENANT_SETTINGS_SECTIONS),
deleted=[],
watermark=_tenant_settings_watermark(session, tenant_id=tenant.id),
has_more=False,
full=True,
)
@router.get("/tenant/settings/delta", response_model=TenantSettingsDeltaResponse)
def get_tenant_settings_delta(
since: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:settings:read")),
):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
if since is None:
return _full_tenant_settings_delta_response(session, tenant)
entries, has_more = _tenant_settings_delta_entries(session, tenant_id=tenant.id, since=since, limit=limit)
if entries is None:
return _full_tenant_settings_delta_response(session, tenant)
changed = set()
for entry in entries:
if entry.module_id == ADMIN_MODULE_ID and entry.collection == ADMIN_SYSTEM_SETTINGS_COLLECTION and entry.resource_id == "languages":
changed.add("languages")
elif entry.resource_type == TENANT_SETTINGS_RESOURCE:
changed.add(entry.resource_id)
changed_sections = [section for section in TENANT_SETTINGS_SECTIONS if section in changed]
item = _tenant_settings_item(session, tenant)
sections = _tenant_settings_sections(item)
return TenantSettingsDeltaResponse(
item=None,
sections={section: sections[section] for section in changed_sections},
changed_sections=changed_sections,
deleted=[],
watermark=_tenant_settings_response_watermark(session, tenant_id=tenant.id, entries=entries, has_more=has_more),
has_more=has_more,
full=False,
)
@router.patch("/tenant/settings", response_model=TenantSettingsItem)
def update_tenant_settings(
payload: TenantSettingsUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("admin:settings:write")),
):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
before_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant))
system_settings = get_system_settings(session)
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
current_i18n = i18n_settings(tenant.settings)
raw_enabled = payload.enabled_language_codes if "enabled_language_codes" in payload.model_fields_set else current_i18n.get("enabled_language_codes")
enabled = normalize_enabled_language_codes(
raw_enabled,
[{"code": code} for code in system_enabled],
default_locale=payload.default_locale,
fallback_codes=system_enabled,
)
tenant.default_locale = payload.default_locale.strip() or enabled[0]
tenant.settings = update_i18n_settings(tenant.settings, enabled_language_codes=enabled)
session.add(tenant)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="tenant.settings.updated",
object_type="tenant",
object_id=tenant.id,
details={"default_locale": tenant.default_locale, "enabled_language_codes": enabled},
)
after_sections = _tenant_settings_sections(_tenant_settings_item(session, tenant))
_record_tenant_settings_section_changes(session, tenant_id=tenant.id, before=before_sections, after=after_sections, principal=principal)
session.commit()
return _tenant_settings_item(session, tenant)