Sync GovOPlaN module state

This commit is contained in:
2026-07-10 21:57:28 +02:00
parent 7e8df56723
commit 881f8d8755
5 changed files with 607 additions and 5 deletions

348
.gitignore vendored Normal file
View File

@@ -0,0 +1,348 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/

View File

@@ -0,0 +1,29 @@
# GovOPlaN Tenancy Module Boundary
`govoplan-tenancy` owns tenant registry, tenant settings, tenant lifecycle, and
tenant-context route contributions.
## Owned Routes
- `/api/v1/admin/tenants*`: tenant registry, delta feed, owner candidates,
settings, deletion plans, retirement, and guarded empty-tenant destruction.
- `/api/v1/tenancy/switch-tenant`: tenant-owned route surface for interactive
tenant context switching.
`/api/v1/auth/switch-tenant` remains in `govoplan-access` for API
compatibility, but both routes delegate to the same
`auth.tenantContextSwitcher` capability.
## Lifecycle Rules
Tenant retirement is non-destructive. It marks the tenant inactive and stores
lifecycle metadata in tenant settings.
Destructive deletion is intentionally narrow: it is allowed only when the
tenant is not the caller's active tenant and all registered tenant-owned counts
are zero. Populated tenants must be retired first or cleaned explicitly by
their owning modules before physical deletion.
Tenant lifecycle planning uses registered tenant summary providers and delete
veto providers. Modules that own tenant-scoped data must contribute summaries
so destructive deletion cannot silently miss their rows.

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
@@ -8,7 +8,12 @@ from govoplan_core.admin.common import AdminValidationError, slugify
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.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.access import (
CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
TenantAccessProvisioner,
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.runtime import get_registry
from govoplan_core.db.session import get_session
@@ -20,6 +25,7 @@ from govoplan_core.i18n import (
tenant_enabled_language_codes,
update_i18n_settings,
)
from govoplan_core.security.time import utc_now
from govoplan_core.tenancy.service import (
assert_tenant_governance_override_allowed,
effective_tenant_governance,
@@ -29,7 +35,13 @@ from govoplan_tenancy.backend.db.models import Tenant
from .schemas import (
TenantAdminItem,
TenantContextSwitchRequest,
TenantContextSwitchResponse,
TenantCreateRequest,
TenantDeleteRequest,
TenantDeletionPlanResponse,
TenantLifecycleIssue,
TenantLifecycleResponse,
TenantListDeltaResponse,
TenantListResponse,
TenantOwnerCandidate,
@@ -41,6 +53,7 @@ from .schemas import (
)
router = APIRouter(prefix="/admin", tags=["admin"])
tenant_router = APIRouter(prefix="/tenancy", tags=["tenancy"])
TENANCY_MODULE_ID = "tenancy"
TENANT_LIST_COLLECTION = "tenancy.tenants"
@@ -62,6 +75,16 @@ def _tenant_access_provisioner() -> TenantAccessProvisioner:
return capability
def _tenant_context_switcher() -> TenantContextSwitcher:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Tenant context switcher capability is not configured")
capability = registry.require_capability(CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER)
if not isinstance(capability, TenantContextSwitcher):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Tenant context switcher 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}")
@@ -91,6 +114,61 @@ def _tenant_item(session: Session, tenant: Tenant) -> TenantAdminItem:
)
def _tenant_deletion_plan(session: Session, tenant: Tenant, principal: ApiPrincipal, *, mode: str = "retire") -> TenantDeletionPlanResponse:
issues: list[TenantLifecycleIssue] = []
counts = tenant_counts(session, tenant.id)
destructive = mode == "destroy"
if tenant.id == principal.tenant_id:
issues.append(TenantLifecycleIssue(
severity="blocker",
code="active_tenant",
message=f"Switch to another tenant before {'deleting' if destructive else 'retiring'} the active tenant.",
module_id=TENANCY_MODULE_ID,
))
if not tenant.is_active:
issues.append(TenantLifecycleIssue(
severity="info",
code="already_inactive",
message="Tenant is already inactive; retiring it again will only refresh lifecycle metadata.",
module_id=TENANCY_MODULE_ID,
))
has_tenant_data = any(value for value in counts.values())
if destructive and has_tenant_data:
issues.append(TenantLifecycleIssue(
severity="blocker",
code="tenant_data_present",
message="Tenant-owned data exists; retire the tenant or remove its data before destructive deletion.",
module_id=TENANCY_MODULE_ID,
))
elif has_tenant_data:
issues.append(TenantLifecycleIssue(
severity="warning",
code="tenant_data_retained",
message="Tenant-owned data will be retained. This operation is non-destructive.",
module_id=TENANCY_MODULE_ID,
))
registry = get_registry()
if registry is not None and hasattr(registry, "delete_veto_providers"):
for provider in registry.delete_veto_providers("tenant"):
try:
provider(session, tenant.id, tenant.id)
except Exception as exc:
issues.append(TenantLifecycleIssue(
severity="blocker",
code="module_veto",
message=str(exc),
))
return TenantDeletionPlanResponse(
tenant_id=tenant.id,
action="destroy" if destructive else "retire",
allowed=not any(issue.severity == "blocker" for issue in issues),
destructive_supported=destructive and not has_tenant_data,
issues=issues,
counts=counts,
)
def _tenant_settings_item(session: Session, tenant: Tenant) -> TenantSettingsItem:
system_settings = get_system_settings(session)
system_payload = system_i18n_payload(system_settings)
@@ -254,6 +332,27 @@ def _tenant_settings_response_watermark(session: Session, *, tenant_id: str, ent
return encode_sequence_watermark(entries[-1].id) if has_more and entries else _tenant_settings_watermark(session, tenant_id=tenant_id)
@tenant_router.post("/switch-tenant", response_model=TenantContextSwitchResponse)
def switch_tenant_context(
payload: TenantContextSwitchRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
try:
switched = _tenant_context_switcher().switch_tenant_context(session, principal=principal, tenant_id=payload.tenant_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
session.commit()
return TenantContextSwitchResponse(
account_id=switched.account_id,
membership_id=switched.membership_id,
tenant_id=switched.tenant_id,
session_id=switched.session_id,
)
@router.get("/tenants", response_model=TenantListResponse)
def list_tenants(
session: Session = Depends(get_session),
@@ -430,6 +529,82 @@ def update_tenant(
return _tenant_item(session, tenant)
@router.get("/tenants/{tenant_id}/deletion-plan", response_model=TenantDeletionPlanResponse)
def tenant_deletion_plan(
tenant_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("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")
return _tenant_deletion_plan(session, tenant, principal)
@router.delete("/tenants/{tenant_id}", response_model=TenantLifecycleResponse)
def retire_tenant(
tenant_id: str,
payload: TenantDeleteRequest | None = Body(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:tenants:suspend")),
):
request = payload or TenantDeleteRequest()
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
plan = _tenant_deletion_plan(session, tenant, principal, mode=request.mode)
if not plan.allowed:
action_label = "destroyed" if request.mode == "destroy" else "retired"
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"message": f"Tenant cannot be {action_label}.", "plan": plan.model_dump(mode="json")},
)
if request.mode == "destroy":
deleted_item = _tenant_item(session, tenant)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="tenant.destroyed",
scope="system",
object_type="tenant",
object_id=tenant.id,
details={"reason": request.reason, "counts": plan.counts},
)
_record_tenant_list_change(session, tenant=tenant, operation="deleted", principal=principal)
session.delete(tenant)
session.commit()
return TenantLifecycleResponse(item=deleted_item, plan=plan)
settings_payload = dict(tenant.settings or {})
lifecycle_payload = dict(settings_payload.get("lifecycle") or {})
lifecycle_payload.update({
"state": "retired",
"retired_at": utc_now().isoformat(),
"retired_by_user_id": principal.user.id,
})
if request.reason:
lifecycle_payload["reason"] = request.reason.strip()
settings_payload["lifecycle"] = lifecycle_payload
tenant.settings = settings_payload
tenant.is_active = False
session.add(tenant)
audit_event(
session,
tenant_id=tenant.id,
user_id=principal.user.id,
action="tenant.retired",
scope="system",
object_type="tenant",
object_id=tenant.id,
details={"reason": request.reason, "counts": plan.counts},
)
_record_tenant_list_change(session, tenant=tenant, operation="updated", principal=principal)
session.commit()
return TenantLifecycleResponse(item=_tenant_item(session, tenant), plan=plan)
@router.get("/tenant/settings", response_model=TenantSettingsItem)
def get_tenant_settings(
session: Session = Depends(get_session),

View File

@@ -74,6 +74,47 @@ class TenantUpdateRequest(BaseModel):
is_active: bool | None = None
class TenantLifecycleIssue(BaseModel):
severity: Literal["blocker", "warning", "info"]
code: str
message: str
module_id: str | None = None
class TenantDeletionPlanResponse(BaseModel):
tenant_id: str
action: Literal["retire", "destroy"] = "retire"
allowed: bool
destructive_supported: bool = False
issues: list[TenantLifecycleIssue] = Field(default_factory=list)
counts: dict[str, int] = Field(default_factory=dict)
class TenantDeleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
mode: Literal["retire", "destroy"] = "retire"
reason: str | None = None
class TenantLifecycleResponse(BaseModel):
item: TenantAdminItem
plan: TenantDeletionPlanResponse
class TenantContextSwitchRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str
class TenantContextSwitchResponse(BaseModel):
account_id: str
membership_id: str
tenant_id: str
session_id: str | None = None
class TenantSettingsItem(BaseModel):
id: str
slug: str

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
CAPABILITY_TENANCY_TENANT_RESOLVER,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
@@ -17,16 +18,24 @@ def _tenant_resolver(context: ModuleContext):
def _route_factory(context: ModuleContext):
del context
from govoplan_tenancy.backend.api.v1.routes import router
from fastapi import APIRouter
from govoplan_tenancy.backend.api.v1.routes import router, tenant_router
return router
aggregate = APIRouter()
aggregate.include_router(router)
aggregate.include_router(tenant_router)
return aggregate
manifest = ModuleManifest(
id="tenancy",
name="Tenancy",
version="0.1.6",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
),
route_factory=_route_factory,
capability_factories={
CAPABILITY_TENANCY_TENANT_RESOLVER: _tenant_resolver,