4 Commits
v0.1.8 ... main

3 changed files with 279 additions and 47 deletions

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
@@ -15,6 +17,7 @@ from govoplan_core.core.access import (
TenantContextSwitcher,
)
from govoplan_core.core.change_sequence import ChangeSequenceEntry, decode_sequence_watermark, encode_sequence_watermark, record_change, sequence_watermark_is_expired
from govoplan_core.core.principal_cache import invalidate_auth_principals
from govoplan_core.core.runtime import get_registry
from govoplan_core.db.session import get_session
from govoplan_core.i18n import (
@@ -70,6 +73,17 @@ TENANT_SETTINGS_RESOURCE = "tenant_settings_section"
ADMIN_MODULE_ID = "admin"
ADMIN_SYSTEM_SETTINGS_COLLECTION = "admin.system_settings"
TENANT_SETTINGS_SECTIONS = ("identity", "locale", "languages", "settings")
TENANT_NON_STATUS_UPDATE_FIELDS = {
"name",
"description",
"default_locale",
"settings",
"allow_custom_groups",
"allow_custom_roles",
"allow_api_keys",
}
TENANT_GOVERNANCE_OVERRIDE_FIELDS = ("allow_custom_groups", "allow_custom_roles", "allow_api_keys")
TENANT_FULL_CURSOR_PREFIX = "full:tenants:"
def _tenant_access_provisioner() -> TenantAccessProvisioner:
@@ -97,6 +111,20 @@ def _require_permission(principal: ApiPrincipal, scope: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _require_tenant_update_permissions(principal: ApiPrincipal, payload: TenantUpdateRequest) -> None:
if payload.model_fields_set.intersection(TENANT_NON_STATUS_UPDATE_FIELDS):
_require_permission(principal, "system:tenants:update")
if payload.is_active is not None:
_require_permission(principal, "system:tenants:suspend")
def _tenant_or_404(session: Session, tenant_id: str) -> Tenant:
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
return tenant
def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem:
governance = effective_tenant_governance(session, tenant)
return TenantAdminItem(
@@ -115,7 +143,11 @@ def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem:
"allow_api_keys": governance.allow_api_keys,
},
is_active=tenant.is_active,
counts=tenant_counts(session, tenant.id),
counts=tenant_counts(
session,
tenant.id,
module_ids=("campaigns", "files"),
),
created_at=tenant.created_at,
updated_at=tenant.updated_at,
)
@@ -224,9 +256,11 @@ def _record_tenant_settings_section_changes(
after: dict[str, Any],
principal: ApiPrincipal,
) -> None:
changed = False
for section in TENANT_SETTINGS_SECTIONS:
if before.get(section) == after.get(section):
continue
changed = True
record_change(
session,
module_id=TENANCY_MODULE_ID,
@@ -239,6 +273,16 @@ def _record_tenant_settings_section_changes(
actor_id=principal.user.id,
payload={"section": section},
)
if changed:
invalidate_auth_principals(
session,
tenant_id=tenant_id,
source_module="tenancy",
resource_type="tenant_settings",
resource_id=tenant_id,
actor_type="user",
actor_id=principal.user.id,
)
def _record_tenant_list_change(session: Session, *, tenant: Tenant, operation: str, principal: ApiPrincipal) -> None:
@@ -254,6 +298,16 @@ def _record_tenant_list_change(session: Session, *, tenant: Tenant, operation: s
actor_id=principal.user.id,
payload={"slug": tenant.slug, "name": tenant.name, "is_active": tenant.is_active},
)
invalidate_auth_principals(
session,
tenant_id=tenant.id,
source_module="tenancy",
resource_type="tenant",
resource_id=tenant.id,
actor_type="user",
actor_id=principal.user.id,
reason=operation,
)
def _tenant_list_delta_query(session: Session, *, since_sequence: int):
@@ -291,6 +345,50 @@ def _tenant_list_response_watermark(session: Session, *, entries, has_more: bool
return encode_sequence_watermark(entries[-1].id) if has_more and entries else _tenant_list_watermark(session)
def _tenant_page(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 _tenant_full_cursor(
*,
page: int,
snapshot_sequence: int,
) -> str:
return f"{TENANT_FULL_CURSOR_PREFIX}{int(page)}:{int(snapshot_sequence)}"
def _decode_tenant_full_cursor(value: str | None) -> tuple[int, int] | None:
if not value or not value.startswith(TENANT_FULL_CURSOR_PREFIX):
return None
parts = value[len(TENANT_FULL_CURSOR_PREFIX):].split(":", 1)
if len(parts) != 2:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant full snapshot cursor",
)
try:
page, snapshot_sequence = (int(item) for item in parts)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant full snapshot cursor",
) from exc
if page < 1 or snapshot_sequence < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant full snapshot cursor",
)
return page, snapshot_sequence
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}
@@ -371,21 +469,54 @@ def switch_tenant_context(
@router.get("/tenants", response_model=TenantListResponse)
def list_tenants(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=100, ge=1, le=500),
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])
tenants, pagination = _tenant_page(
session.query(Tenant).order_by(Tenant.name.asc(), Tenant.id.asc()),
page=page,
page_size=page_size,
)
return TenantListResponse(
tenants=[_tenant_item(session, tenant) for tenant in tenants],
**pagination,
)
def _full_tenant_list_delta_response(session: Session) -> TenantListDeltaResponse:
tenants = session.query(Tenant).order_by(Tenant.name.asc()).all()
def _full_tenant_list_delta_response(
session: Session,
*,
cursor: tuple[int, int] | None = None,
limit: int = 100,
) -> TenantListDeltaResponse:
page = cursor[0] if cursor is not None else 1
snapshot_sequence = (
cursor[1]
if cursor is not None
else decode_sequence_watermark(_tenant_list_watermark(session))
)
tenants, pagination = _tenant_page(
session.query(Tenant).order_by(Tenant.name.asc(), Tenant.id.asc()),
page=page,
page_size=limit,
)
has_more = page < pagination["pages"]
return TenantListDeltaResponse(
tenants=[_tenant_item(session, tenant) for tenant in tenants],
deleted=[],
watermark=_tenant_list_watermark(session),
has_more=False,
watermark=(
_tenant_full_cursor(
page=page + 1,
snapshot_sequence=snapshot_sequence,
)
if has_more
else encode_sequence_watermark(snapshot_sequence)
),
has_more=has_more,
full=True,
**pagination,
)
@@ -397,11 +528,16 @@ def list_tenants_delta(
principal: ApiPrincipal = Depends(require_scope("system:tenants:read")),
):
del principal
if since is None:
return _full_tenant_list_delta_response(session)
full_cursor = _decode_tenant_full_cursor(since)
if since is None or full_cursor is not None:
return _full_tenant_list_delta_response(
session,
cursor=full_cursor,
limit=limit,
)
entries, has_more = _tenant_list_delta_entries(session, since=since, limit=limit)
if entries is None:
return _full_tenant_list_delta_response(session)
return _full_tenant_list_delta_response(session, limit=limit)
changed_ids = [entry.resource_id for entry in entries if entry.resource_id and entry.operation != "deleted"]
tenants = []
if changed_ids:
@@ -413,6 +549,10 @@ def list_tenants_delta(
watermark=_tenant_list_response_watermark(session, entries=entries, has_more=has_more),
has_more=has_more,
full=False,
total=len(tenants),
page=1,
page_size=limit,
pages=1,
)
@@ -492,6 +632,50 @@ def create_tenant(
return _tenant_item(session, tenant)
def _apply_tenant_content_updates(tenant: Tenant, payload: TenantUpdateRequest) -> None:
if payload.name is not None:
tenant.name = payload.name.strip()
if "description" in payload.model_fields_set:
tenant.description = _normalized_optional_text(payload.description)
if payload.default_locale is not None:
tenant.default_locale = _normalized_tenant_locale(payload.default_locale)
if payload.settings is not None:
tenant.settings = payload.settings
def _normalized_optional_text(value: str | None) -> str | None:
if value is None:
return None
clean = value.strip()
return clean or None
def _normalized_tenant_locale(value: str) -> str:
return value.strip() or "en"
def _apply_tenant_governance_updates(session: Session, tenant: Tenant, payload: TenantUpdateRequest) -> None:
try:
for field in TENANT_GOVERNANCE_OVERRIDE_FIELDS:
if field in payload.model_fields_set:
value = getattr(payload, field)
assert_tenant_governance_override_allowed(session, field=field, value=value)
setattr(tenant, field, value)
except AdminValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
def _apply_tenant_status_update(tenant: Tenant, payload: TenantUpdateRequest, principal: ApiPrincipal) -> None:
if payload.is_active is None:
return
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
@router.patch("/tenants/{tenant_id}", response_model=TenantAdminItem)
def update_tenant(
tenant_id: str,
@@ -499,43 +683,13 @@ def update_tenant(
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")
_require_tenant_update_permissions(principal, payload)
tenant = _tenant_or_404(session, tenant_id)
was_active = tenant.is_active
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
_apply_tenant_content_updates(tenant, payload)
_apply_tenant_governance_updates(session, tenant, payload)
_apply_tenant_status_update(tenant, payload, principal)
session.add(tenant)
audit_event(
session,

View File

@@ -3,7 +3,7 @@ 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
@@ -27,9 +27,13 @@ class TenantAdminItem(BaseModel):
class TenantListResponse(BaseModel):
tenants: list[TenantAdminItem]
total: int = 0
page: int = 1
page_size: int = 100
pages: int = 1
class TenantListDeltaResponse(BaseModel):
class TenantListDeltaResponse(TenantListResponse):
tenants: list[TenantAdminItem] = Field(default_factory=list)
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None

View File

@@ -2,7 +2,16 @@ from __future__ import annotations
import unittest
from datetime import UTC, datetime
from types import SimpleNamespace
from fastapi import HTTPException
from govoplan_tenancy.backend.api.v1.routes import (
_apply_tenant_content_updates,
_apply_tenant_status_update,
_require_tenant_update_permissions,
)
from govoplan_tenancy.backend.api.v1.schemas import TenantUpdateRequest
from govoplan_tenancy.backend.lifecycle import (
TENANT_EVENT_CREATED,
TENANT_EVENT_DELETION_REQUESTED,
@@ -15,6 +24,15 @@ from govoplan_tenancy.backend.lifecycle import (
)
class FakePrincipal:
def __init__(self, scopes: set[str], *, tenant_id: str = "tenant-1") -> None:
self.scopes = frozenset(scopes)
self.tenant_id = tenant_id
def has(self, required_scope: str) -> bool:
return required_scope in self.scopes
class TenantLifecycleContractTests(unittest.TestCase):
def test_lifecycle_event_names_are_stable(self) -> None:
self.assertEqual("tenant.created", tenant_lifecycle_event_type("created"))
@@ -66,5 +84,61 @@ class TenantLifecycleContractTests(unittest.TestCase):
)
class TenantUpdateHelperTests(unittest.TestCase):
def test_tenant_update_permissions_separate_content_and_status_changes(self) -> None:
_require_tenant_update_permissions(
FakePrincipal({"system:tenants:suspend"}), # type: ignore[arg-type]
TenantUpdateRequest(is_active=False),
)
_require_tenant_update_permissions(
FakePrincipal({"system:tenants:update"}), # type: ignore[arg-type]
TenantUpdateRequest(name="Updated"),
)
with self.assertRaises(HTTPException) as missing_update:
_require_tenant_update_permissions(FakePrincipal(set()), TenantUpdateRequest(name="Updated")) # type: ignore[arg-type]
self.assertEqual(403, missing_update.exception.status_code)
self.assertEqual("Missing scope: system:tenants:update", missing_update.exception.detail)
with self.assertRaises(HTTPException) as missing_suspend:
_require_tenant_update_permissions(FakePrincipal(set()), TenantUpdateRequest(is_active=False)) # type: ignore[arg-type]
self.assertEqual(403, missing_suspend.exception.status_code)
self.assertEqual("Missing scope: system:tenants:suspend", missing_suspend.exception.detail)
def test_tenant_content_updates_normalize_blank_fields_and_defaults(self) -> None:
tenant = SimpleNamespace(name="Old", description="Old description", default_locale="de", settings={})
payload = TenantUpdateRequest(
name=" New tenant ",
description=" ",
default_locale=" ",
settings={"theme": "contrast"},
)
_apply_tenant_content_updates(tenant, payload) # type: ignore[arg-type]
self.assertEqual("New tenant", tenant.name)
self.assertIsNone(tenant.description)
self.assertEqual("en", tenant.default_locale)
self.assertEqual({"theme": "contrast"}, tenant.settings)
def test_tenant_status_update_prevents_suspending_current_tenant(self) -> None:
tenant = SimpleNamespace(id="tenant-1", is_active=True)
principal = FakePrincipal({"system:tenants:suspend"}, tenant_id="tenant-1")
with self.assertRaises(HTTPException) as captured:
_apply_tenant_status_update(tenant, TenantUpdateRequest(is_active=False), principal) # type: ignore[arg-type]
self.assertEqual(409, captured.exception.status_code)
self.assertEqual("Switch to another tenant before suspending the active tenant.", captured.exception.detail)
def test_tenant_status_update_allows_other_tenant_suspension(self) -> None:
tenant = SimpleNamespace(id="tenant-2", is_active=True)
principal = FakePrincipal({"system:tenants:suspend"}, tenant_id="tenant-1")
_apply_tenant_status_update(tenant, TenantUpdateRequest(is_active=False), principal) # type: ignore[arg-type]
self.assertFalse(tenant.is_active)
if __name__ == "__main__":
unittest.main()