From 881f8d8755484487fd3b65023ad007d8197cfb46 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 21:57:28 +0200 Subject: [PATCH] Sync GovOPlaN module state --- .gitignore | 348 ++++++++++++++++++ docs/TENANCY_MODULE_BOUNDARY.md | 29 ++ src/govoplan_tenancy/backend/api/v1/routes.py | 179 ++++++++- .../backend/api/v1/schemas.py | 41 +++ src/govoplan_tenancy/backend/manifest.py | 15 +- 5 files changed, 607 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100644 docs/TENANCY_MODULE_BOUNDARY.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6750d45 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/docs/TENANCY_MODULE_BOUNDARY.md b/docs/TENANCY_MODULE_BOUNDARY.md new file mode 100644 index 0000000..527b95e --- /dev/null +++ b/docs/TENANCY_MODULE_BOUNDARY.md @@ -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. diff --git a/src/govoplan_tenancy/backend/api/v1/routes.py b/src/govoplan_tenancy/backend/api/v1/routes.py index fd9baf4..99bc378 100644 --- a/src/govoplan_tenancy/backend/api/v1/routes.py +++ b/src/govoplan_tenancy/backend/api/v1/routes.py @@ -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), diff --git a/src/govoplan_tenancy/backend/api/v1/schemas.py b/src/govoplan_tenancy/backend/api/v1/schemas.py index 9a48299..809e0a1 100644 --- a/src/govoplan_tenancy/backend/api/v1/schemas.py +++ b/src/govoplan_tenancy/backend/api/v1/schemas.py @@ -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 diff --git a/src/govoplan_tenancy/backend/manifest.py b/src/govoplan_tenancy/backend/manifest.py index 66d8bcf..88054ca 100644 --- a/src/govoplan_tenancy/backend/manifest.py +++ b/src/govoplan_tenancy/backend/manifest.py @@ -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,