- infrasynth.configs: typed multi-tenant config store (registry, service, secrets, cache) + public config_changed/config_reset signals and API - emit the declared-but-dead signals (features flags/overrides, scheduler task_completed/task_failed, tenancy tenant_updated, audit model_changed) and per-model audit field exclusions - security: permission catalog (security_permission), Django-style model-derived AutoPermission, PermissionRegistry, RoleAssignment, global-or-tenant Grant/Revoke, catalog API - consolidate the permission surface: PermissionRegistry only (drop the settings dict), IsAuthenticatedAndPermitted aliases HybridPermission, require_permission replaced by required_permissions + require_all - packaging: add [build-system]; add Forgejo publish workflow (.forgejo)
76 lines
3.1 KiB
Python
76 lines
3.1 KiB
Python
"""Tenant isolation for configuration values (TENANCY.md §10)."""
|
|
|
|
import pytest
|
|
|
|
from infrasynth.configs.models import ConfigValue
|
|
from infrasynth.configs.registry import ConfigRegistry, ConfigType
|
|
from infrasynth.configs.services import ConfigService
|
|
from infrasynth.tenancy.context import tenant_context
|
|
from infrasynth.tenancy.models import Tenant
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_registry(clean_config_registry):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def envelope_errors(settings):
|
|
settings.REST_FRAMEWORK = {
|
|
**settings.REST_FRAMEWORK,
|
|
"EXCEPTION_HANDLER": "infrasynth.api.exceptions.envelope_exception_handler",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def service():
|
|
return ConfigService()
|
|
|
|
|
|
@pytest.fixture
|
|
def other_tenant():
|
|
return Tenant.objects.create(slug="other-config-ws", name="Other Workspace")
|
|
|
|
|
|
class TestServiceIsolation:
|
|
def test_tenant_a_cannot_read_tenant_b(self, service, tenant, other_tenant):
|
|
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
|
|
service.set("k", "b-secret", tenant=other_tenant)
|
|
assert service.get("k", tenant=tenant) == "default"
|
|
|
|
def test_absent_override_falls_back_to_global_not_other_tenant(self, service, tenant, other_tenant):
|
|
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
|
|
service.set_global("k", "global")
|
|
service.set("k", "b-secret", tenant=other_tenant)
|
|
assert service.get("k", tenant=tenant) == "global"
|
|
|
|
def test_reset_only_affects_own_tenant(self, service, tenant, other_tenant):
|
|
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
|
|
service.set("k", "b-value", tenant=other_tenant)
|
|
assert service.reset("k", tenant=tenant) is False
|
|
assert service.get("k", tenant=other_tenant) == "b-value"
|
|
|
|
|
|
class TestManagerIsolation:
|
|
def test_scoped_manager_hides_other_tenant(self, tenant, other_tenant):
|
|
ConfigValue.all_objects.create(tenant=other_tenant, key="other.only", value="x")
|
|
with tenant_context(tenant):
|
|
assert ConfigValue.objects.count() == 0
|
|
assert ConfigValue.objects.filter(key="other.only").delete()[0] == 0
|
|
with tenant_context(other_tenant):
|
|
assert ConfigValue.objects.filter(key="other.only").exists()
|
|
|
|
|
|
class TestApiIsolation:
|
|
def test_other_tenant_key_is_404_not_403(self, authenticated_client, tenant, other_tenant):
|
|
ConfigValue.all_objects.create(tenant=other_tenant, key="other.only", value="x")
|
|
response = authenticated_client.get("/api/v1/configs/other.only/")
|
|
assert response.status_code == 404
|
|
|
|
def test_list_never_exposes_other_tenant_values(self, authenticated_client, tenant, other_tenant):
|
|
ConfigRegistry.register("shared.key", type=ConfigType.STRING, default="default")
|
|
ConfigValue.all_objects.create(tenant=other_tenant, key="shared.key", value="b-secret")
|
|
response = authenticated_client.get("/api/v1/configs/")
|
|
assert response.status_code == 200
|
|
values = {entry["key"]: entry["value"] for entry in response.json()["values"]}
|
|
assert values["shared.key"] == "default"
|