|
|
|
|
@@ -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,
|
|
|
|
|
|