Release v0.1.5
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: govoplan-tenancy
|
||||
Version: 0.1.4
|
||||
Summary: GovOPlaN tenancy platform module.
|
||||
Author: GovOPlaN
|
||||
Requires-Python: >=3.12
|
||||
Description-Content-Type: text/markdown
|
||||
Requires-Dist: govoplan-core>=0.1.4
|
||||
|
||||
# GovOPlaN Tenancy
|
||||
|
||||
`govoplan-tenancy` owns the live `tenants` table, tenant lifecycle, and tenant
|
||||
settings API route contributions during the GovOPlaN module split.
|
||||
|
||||
`govoplan-access` depends on this module, and core's registry inserts tenancy
|
||||
before access for authenticated platform composition. The historical `tenants`
|
||||
table name is intentionally preserved for migration compatibility.
|
||||
@@ -0,0 +1,19 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
src/govoplan_tenancy/__init__.py
|
||||
src/govoplan_tenancy/py.typed
|
||||
src/govoplan_tenancy.egg-info/PKG-INFO
|
||||
src/govoplan_tenancy.egg-info/SOURCES.txt
|
||||
src/govoplan_tenancy.egg-info/dependency_links.txt
|
||||
src/govoplan_tenancy.egg-info/entry_points.txt
|
||||
src/govoplan_tenancy.egg-info/requires.txt
|
||||
src/govoplan_tenancy.egg-info/top_level.txt
|
||||
src/govoplan_tenancy/backend/__init__.py
|
||||
src/govoplan_tenancy/backend/capabilities.py
|
||||
src/govoplan_tenancy/backend/manifest.py
|
||||
src/govoplan_tenancy/backend/api/__init__.py
|
||||
src/govoplan_tenancy/backend/api/v1/__init__.py
|
||||
src/govoplan_tenancy/backend/api/v1/routes.py
|
||||
src/govoplan_tenancy/backend/api/v1/schemas.py
|
||||
src/govoplan_tenancy/backend/db/__init__.py
|
||||
src/govoplan_tenancy/backend/db/models.py
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[govoplan.modules]
|
||||
tenancy = govoplan_tenancy.backend.manifest:get_manifest
|
||||
@@ -0,0 +1 @@
|
||||
govoplan-core>=0.1.4
|
||||
@@ -0,0 +1 @@
|
||||
govoplan_tenancy
|
||||
@@ -0,0 +1,2 @@
|
||||
"""GovOPlaN tenancy module."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
"""Backend integration for the GovOPlaN tenancy module."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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.backend.auth.dependencies 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.runtime import get_registry
|
||||
from govoplan_core.db.session import get_session
|
||||
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,
|
||||
TenantListResponse,
|
||||
TenantOwnerCandidate,
|
||||
TenantOwnerCandidateListResponse,
|
||||
TenantSettingsItem,
|
||||
TenantSettingsUpdateRequest,
|
||||
TenantUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
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(tenant: Tenant) -> TenantSettingsItem:
|
||||
return TenantSettingsItem(
|
||||
id=tenant.id,
|
||||
slug=tenant.slug,
|
||||
name=tenant.name,
|
||||
default_locale=tenant.default_locale,
|
||||
settings=tenant.settings or {},
|
||||
)
|
||||
|
||||
|
||||
@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])
|
||||
|
||||
|
||||
@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_ENTITY, 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_ENTITY, 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},
|
||||
)
|
||||
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")
|
||||
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_ENTITY, 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),
|
||||
)
|
||||
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(tenant)
|
||||
|
||||
|
||||
@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")
|
||||
tenant.default_locale = payload.default_locale.strip() or "en"
|
||||
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},
|
||||
)
|
||||
session.commit()
|
||||
return _tenant_settings_item(tenant)
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class TenantAdminItem(BaseModel):
|
||||
id: str
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
allow_custom_groups: bool | None = None
|
||||
allow_custom_roles: bool | None = None
|
||||
allow_api_keys: bool | None = None
|
||||
effective_governance: dict[str, bool] = Field(default_factory=dict)
|
||||
is_active: bool
|
||||
counts: dict[str, int] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TenantListResponse(BaseModel):
|
||||
tenants: list[TenantAdminItem]
|
||||
|
||||
|
||||
class TenantOwnerCandidate(BaseModel):
|
||||
account_id: str
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class TenantOwnerCandidateListResponse(BaseModel):
|
||||
accounts: list[TenantOwnerCandidate]
|
||||
|
||||
|
||||
class TenantCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str
|
||||
name: str
|
||||
owner_account_id: str | None = None
|
||||
description: str | None = None
|
||||
default_locale: str = "en"
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
allow_custom_groups: bool | None = None
|
||||
allow_custom_roles: bool | None = None
|
||||
allow_api_keys: bool | None = None
|
||||
|
||||
|
||||
class TenantUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
default_locale: str | None = Field(default=None, min_length=1, max_length=20)
|
||||
settings: dict[str, Any] | None = None
|
||||
allow_custom_groups: bool | None = None
|
||||
allow_custom_roles: bool | None = None
|
||||
allow_api_keys: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class TenantSettingsItem(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TenantSettingsUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
default_locale: str = Field(min_length=1, max_length=20)
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef, TenantRef, TenantResolver
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
|
||||
|
||||
def _tenant_ref(tenant: Tenant) -> TenantRef:
|
||||
return TenantRef(
|
||||
id=tenant.id,
|
||||
name=tenant.name,
|
||||
slug=tenant.slug,
|
||||
status="active" if tenant.is_active else "inactive",
|
||||
)
|
||||
|
||||
|
||||
class SqlTenantResolver(TenantResolver):
|
||||
def get_tenant(self, tenant_id: str) -> TenantRef | None:
|
||||
with get_database().session() as session:
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
return _tenant_ref(tenant) if tenant is not None else None
|
||||
|
||||
def current_tenant(self, principal: PrincipalRef) -> TenantRef | None:
|
||||
if principal.tenant_id is None:
|
||||
return None
|
||||
return self.get_tenant(principal.tenant_id)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Tenancy-owned SQLAlchemy metadata and models."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Tenant(Base, TimestampMixin):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
default_locale: Mapped[str] = mapped_column(String(20), default="en", nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
allow_custom_groups: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
allow_custom_roles: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
allow_api_keys: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
users: Mapped[list["User"]] = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
__all__ = ["Tenant", "new_uuid"]
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_TENANCY_TENANT_RESOLVER
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_tenancy.backend.db import models as tenancy_models # noqa: F401 - populate Tenancy ORM metadata
|
||||
|
||||
|
||||
def _tenant_resolver(context: ModuleContext):
|
||||
del context
|
||||
from govoplan_tenancy.backend.capabilities import SqlTenantResolver
|
||||
|
||||
return SqlTenantResolver()
|
||||
|
||||
|
||||
def _route_factory(context: ModuleContext):
|
||||
del context
|
||||
from govoplan_tenancy.backend.api.v1.routes import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="tenancy",
|
||||
name="Tenancy",
|
||||
version="0.1.5",
|
||||
route_factory=_route_factory,
|
||||
migration_spec=MigrationSpec(module_id="tenancy", metadata=Base.metadata),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(tenancy_models.Tenant, label="Tenancy"),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER: _tenant_resolver,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user