diff --git a/README.md b/README.md index 0d97daa..4c3d3d0 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,8 @@ pattern contract documented in [`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md). Manifest-provided documentation topics back contextual help for tenant fields, governance limits, permission blockers, and lifecycle consequences. + +Core's `module_entitlements` tenant-setting key is reserved. Generic tenant +updates preserve it even when replacing the remaining settings document; +system and tenant module administrators change it through the Admin module's +dedicated, revision-checked module policy APIs. diff --git a/src/govoplan_tenancy/backend/api/v1/routes.py b/src/govoplan_tenancy/backend/api/v1/routes.py index 14c1636..a9a40d4 100644 --- a/src/govoplan_tenancy/backend/api/v1/routes.py +++ b/src/govoplan_tenancy/backend/api/v1/routes.py @@ -18,6 +18,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.module_entitlements import MODULE_ENTITLEMENTS_KEY 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 @@ -667,7 +668,14 @@ def _apply_tenant_content_updates(tenant: Tenant, payload: TenantUpdateRequest) 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 + current_settings = dict(tenant.settings or {}) + next_settings = dict(payload.settings) + next_settings.pop(MODULE_ENTITLEMENTS_KEY, None) + if MODULE_ENTITLEMENTS_KEY in current_settings: + next_settings[MODULE_ENTITLEMENTS_KEY] = current_settings[ + MODULE_ENTITLEMENTS_KEY + ] + tenant.settings = next_settings def _normalized_optional_text(value: str | None) -> str | None: diff --git a/src/govoplan_tenancy/backend/manifest.py b/src/govoplan_tenancy/backend/manifest.py index 7ab3261..781b11e 100644 --- a/src/govoplan_tenancy/backend/manifest.py +++ b/src/govoplan_tenancy/backend/manifest.py @@ -60,7 +60,7 @@ manifest = ModuleManifest( id="tenancy.lifecycle-and-settings", title="Administer tenant lifecycle and settings", summary="Tenancy adds explicit tenant creation, activation, context resolution, and tenant-owned settings over Core's shared scope storage.", - body="A tenant is a concrete administrative and data boundary. Tenant lifecycle changes must preserve ownership and recovery guarantees for module-owned records. Tenancy contributes system tenant management and tenant settings to the shared administration workspace; without this module, the Core and Access baseline can operate in single-scope compatibility mode.", + body="A tenant is a concrete administrative and data boundary. Tenant lifecycle changes must preserve ownership and recovery guarantees for module-owned records. Tenancy contributes system tenant management and tenant settings to the shared administration workspace; without this module, the Core and Access baseline can operate in single-scope compatibility mode. Core-reserved module entitlement settings are managed only through the Admin module's tenant-module policy endpoints and are preserved when generic tenant settings are replaced.", documentation_types=("admin",), audience=("system_admin", "tenant_admin", "operator"), related_modules=("access", "admin", "audit"), diff --git a/tests/test_tenant_lifecycle.py b/tests/test_tenant_lifecycle.py index 0dbba93..4565de6 100644 --- a/tests/test_tenant_lifecycle.py +++ b/tests/test_tenant_lifecycle.py @@ -12,6 +12,7 @@ from govoplan_tenancy.backend.api.v1.routes import ( _require_tenant_update_permissions, ) from govoplan_tenancy.backend.api.v1.schemas import TenantUpdateRequest +from govoplan_core.core.module_entitlements import MODULE_ENTITLEMENTS_KEY from govoplan_tenancy.backend.lifecycle import ( TENANT_EVENT_CREATED, TENANT_EVENT_DELETION_REQUESTED, @@ -121,6 +122,26 @@ class TenantUpdateHelperTests(unittest.TestCase): self.assertEqual("en", tenant.default_locale) self.assertEqual({"theme": "contrast"}, tenant.settings) + def test_tenant_content_update_preserves_reserved_module_entitlements(self) -> None: + entitlement = {"schema_version": 1, "revision": 4} + tenant = SimpleNamespace( + name="Old", + description=None, + default_locale="en", + settings={MODULE_ENTITLEMENTS_KEY: entitlement, "theme": "old"}, + ) + payload = TenantUpdateRequest( + settings={ + "theme": "contrast", + MODULE_ENTITLEMENTS_KEY: {"revision": 999}, + } + ) + + _apply_tenant_content_updates(tenant, payload) # type: ignore[arg-type] + + self.assertEqual("contrast", tenant.settings["theme"]) + self.assertEqual(entitlement, tenant.settings[MODULE_ENTITLEMENTS_KEY]) + def test_tenant_status_update_prevents_suspending_current_tenant(self) -> None: tenant = SimpleNamespace(id="tenant-1", is_active=True) principal = FakePrincipal({"system:tenants:suspend"}, tenant_id="tenant-1")