commit c25e81fce9797b8e88da4d38f52bd648ad354186 Author: Albrecht Degering Date: Tue Jul 7 15:49:06 2026 +0200 Release v0.1.5 diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1edbc30 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: "Bug" +about: "Report a reproducible defect, regression, or incorrect behavior" +title: "[Bug] " +labels: + - type/bug + - status/triage + - module/tenancy +--- + +## Scope + +- Repository: +- Area/module: +- Affected version or commit: + +## Behavior + +Expected: + +Actual: + +## Reproduction + +1. +2. +3. + +## Evidence + +Logs, screenshots, traces, or failing test output: + +## Verification Target + +Command or workflow that should pass when fixed: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..d63f1c0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -0,0 +1,27 @@ +--- +name: "Docs / workflow" +about: "Request documentation, process, or developer workflow changes" +title: "[Docs] " +labels: + - type/docs + - status/triage + - module/tenancy + - area/docs +--- + +## Scope + +- Repository: +- Document or workflow: + +## Current State + +What is missing, unclear, duplicated, or stale? + +## Desired State + +What should the docs or workflow make clear? + +## Verification Target + +How should this be checked? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..f1ee238 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: "Feature" +about: "Propose new user-visible behavior or platform capability" +title: "[Feature] " +labels: + - type/feature + - status/triage + - module/tenancy +--- + +## Problem + +What user, operator, or developer problem should this solve? + +## Proposed Capability + +What should exist when this is done? + +## Ownership + +- Owning repository: +- Related module repositories: +- Extension point or integration boundary: + +## Acceptance Criteria + +- [ ] +- [ ] + +## Verification Target + +Command, scenario, or UI flow that should prove completion: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..2fab7ca --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,28 @@ +--- +name: "Task" +about: "Track implementation, maintenance, or migration work" +title: "[Task] " +labels: + - type/task + - status/triage + - module/tenancy +--- + +## Objective + +What needs to be completed? + +## Scope + +- Owning repository: +- In-scope: +- Out-of-scope: + +## Checklist + +- [ ] +- [ ] + +## Verification Target + +Command or manual check: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..5e0fe93 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -0,0 +1,25 @@ +--- +name: "Tech debt" +about: "Track cleanup, refactoring, risk reduction, or deferred engineering work" +title: "[Debt] " +labels: + - type/debt + - status/triage + - module/tenancy +--- + +## Current Cost + +What does this make harder, riskier, slower, or more fragile? + +## Desired Shape + +What should the code, tests, or architecture look like afterwards? + +## Constraints + +What behavior, compatibility, or module boundary must be preserved? + +## Verification Target + +Focused checks that should pass: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a8f383 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# 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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5a192cc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-tenancy" +version = "0.1.5" +description = "GovOPlaN tenancy platform module." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.5", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_tenancy = ["py.typed"] + +[project.entry-points."govoplan.modules"] +tenancy = "govoplan_tenancy.backend.manifest:get_manifest" diff --git a/src/govoplan_tenancy.egg-info/PKG-INFO b/src/govoplan_tenancy.egg-info/PKG-INFO new file mode 100644 index 0000000..8bfe04a --- /dev/null +++ b/src/govoplan_tenancy.egg-info/PKG-INFO @@ -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. diff --git a/src/govoplan_tenancy.egg-info/SOURCES.txt b/src/govoplan_tenancy.egg-info/SOURCES.txt new file mode 100644 index 0000000..7fff275 --- /dev/null +++ b/src/govoplan_tenancy.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/src/govoplan_tenancy.egg-info/dependency_links.txt b/src/govoplan_tenancy.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_tenancy.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/govoplan_tenancy.egg-info/entry_points.txt b/src/govoplan_tenancy.egg-info/entry_points.txt new file mode 100644 index 0000000..71ba448 --- /dev/null +++ b/src/govoplan_tenancy.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[govoplan.modules] +tenancy = govoplan_tenancy.backend.manifest:get_manifest diff --git a/src/govoplan_tenancy.egg-info/requires.txt b/src/govoplan_tenancy.egg-info/requires.txt new file mode 100644 index 0000000..5182e50 --- /dev/null +++ b/src/govoplan_tenancy.egg-info/requires.txt @@ -0,0 +1 @@ +govoplan-core>=0.1.4 diff --git a/src/govoplan_tenancy.egg-info/top_level.txt b/src/govoplan_tenancy.egg-info/top_level.txt new file mode 100644 index 0000000..5a59e6b --- /dev/null +++ b/src/govoplan_tenancy.egg-info/top_level.txt @@ -0,0 +1 @@ +govoplan_tenancy diff --git a/src/govoplan_tenancy/__init__.py b/src/govoplan_tenancy/__init__.py new file mode 100644 index 0000000..53e3afd --- /dev/null +++ b/src/govoplan_tenancy/__init__.py @@ -0,0 +1,2 @@ +"""GovOPlaN tenancy module.""" + diff --git a/src/govoplan_tenancy/__pycache__/__init__.cpython-312.pyc b/src/govoplan_tenancy/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..0aae74d Binary files /dev/null and b/src/govoplan_tenancy/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_tenancy/__pycache__/__init__.cpython-313.pyc b/src/govoplan_tenancy/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..4772670 Binary files /dev/null and b/src/govoplan_tenancy/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/__init__.py b/src/govoplan_tenancy/backend/__init__.py new file mode 100644 index 0000000..d81dd8d --- /dev/null +++ b/src/govoplan_tenancy/backend/__init__.py @@ -0,0 +1,2 @@ +"""Backend integration for the GovOPlaN tenancy module.""" + diff --git a/src/govoplan_tenancy/backend/__pycache__/__init__.cpython-312.pyc b/src/govoplan_tenancy/backend/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..cc2b5a2 Binary files /dev/null and b/src/govoplan_tenancy/backend/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_tenancy/backend/__pycache__/__init__.cpython-313.pyc b/src/govoplan_tenancy/backend/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d49e1fe Binary files /dev/null and b/src/govoplan_tenancy/backend/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/__pycache__/capabilities.cpython-313.pyc b/src/govoplan_tenancy/backend/__pycache__/capabilities.cpython-313.pyc new file mode 100644 index 0000000..36a4539 Binary files /dev/null and b/src/govoplan_tenancy/backend/__pycache__/capabilities.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-312.pyc b/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-312.pyc new file mode 100644 index 0000000..c9f50d5 Binary files /dev/null and b/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-312.pyc differ diff --git a/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-313.pyc b/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-313.pyc new file mode 100644 index 0000000..97c0deb Binary files /dev/null and b/src/govoplan_tenancy/backend/__pycache__/manifest.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/api/__init__.py b/src/govoplan_tenancy/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_tenancy/backend/api/__pycache__/__init__.cpython-312.pyc b/src/govoplan_tenancy/backend/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..afe7dfb Binary files /dev/null and b/src/govoplan_tenancy/backend/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_tenancy/backend/api/__pycache__/__init__.cpython-313.pyc b/src/govoplan_tenancy/backend/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..4788fbe Binary files /dev/null and b/src/govoplan_tenancy/backend/api/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/api/v1/__init__.py b/src/govoplan_tenancy/backend/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/__init__.cpython-312.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..2653644 Binary files /dev/null and b/src/govoplan_tenancy/backend/api/v1/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/__init__.cpython-313.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..6e19a6b Binary files /dev/null and b/src/govoplan_tenancy/backend/api/v1/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-312.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-312.pyc new file mode 100644 index 0000000..8b58cf8 Binary files /dev/null and b/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-312.pyc differ diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-313.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000..a546619 Binary files /dev/null and b/src/govoplan_tenancy/backend/api/v1/__pycache__/routes.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-312.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-312.pyc new file mode 100644 index 0000000..86adf47 Binary files /dev/null and b/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-312.pyc differ diff --git a/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-313.pyc b/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000..a757006 Binary files /dev/null and b/src/govoplan_tenancy/backend/api/v1/__pycache__/schemas.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/api/v1/routes.py b/src/govoplan_tenancy/backend/api/v1/routes.py new file mode 100644 index 0000000..caafd30 --- /dev/null +++ b/src/govoplan_tenancy/backend/api/v1/routes.py @@ -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) diff --git a/src/govoplan_tenancy/backend/api/v1/schemas.py b/src/govoplan_tenancy/backend/api/v1/schemas.py new file mode 100644 index 0000000..6566a69 --- /dev/null +++ b/src/govoplan_tenancy/backend/api/v1/schemas.py @@ -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) diff --git a/src/govoplan_tenancy/backend/capabilities.py b/src/govoplan_tenancy/backend/capabilities.py new file mode 100644 index 0000000..2a308d9 --- /dev/null +++ b/src/govoplan_tenancy/backend/capabilities.py @@ -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) diff --git a/src/govoplan_tenancy/backend/db/__init__.py b/src/govoplan_tenancy/backend/db/__init__.py new file mode 100644 index 0000000..8f70923 --- /dev/null +++ b/src/govoplan_tenancy/backend/db/__init__.py @@ -0,0 +1,2 @@ +"""Tenancy-owned SQLAlchemy metadata and models.""" + diff --git a/src/govoplan_tenancy/backend/db/__pycache__/__init__.cpython-312.pyc b/src/govoplan_tenancy/backend/db/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..14d011e Binary files /dev/null and b/src/govoplan_tenancy/backend/db/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/govoplan_tenancy/backend/db/__pycache__/__init__.cpython-313.pyc b/src/govoplan_tenancy/backend/db/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..5e76fe4 Binary files /dev/null and b/src/govoplan_tenancy/backend/db/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-312.pyc b/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..e876a04 Binary files /dev/null and b/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-312.pyc differ diff --git a/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-313.pyc b/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..c02b973 Binary files /dev/null and b/src/govoplan_tenancy/backend/db/__pycache__/models.cpython-313.pyc differ diff --git a/src/govoplan_tenancy/backend/db/models.py b/src/govoplan_tenancy/backend/db/models.py new file mode 100644 index 0000000..77c2dfd --- /dev/null +++ b/src/govoplan_tenancy/backend/db/models.py @@ -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"] diff --git a/src/govoplan_tenancy/backend/manifest.py b/src/govoplan_tenancy/backend/manifest.py new file mode 100644 index 0000000..a6da9fc --- /dev/null +++ b/src/govoplan_tenancy/backend/manifest.py @@ -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 diff --git a/src/govoplan_tenancy/py.typed b/src/govoplan_tenancy/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_tenancy/py.typed @@ -0,0 +1 @@ +