From 0f8a05f8b95340de7e0aa1569a51589764b0776e Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 24 Aug 2026 15:57:20 +0200 Subject: [PATCH] feat: contribute tenant erasure for access data --- README.md | 3 + docs/ACCESS_MODULE_BOUNDARY.md | 19 ++ package.json | 2 +- pyproject.toml | 4 +- src/govoplan_access/__init__.py | 2 +- src/govoplan_access/backend/manifest.py | 84 +++++++- .../backend/tenant_erasure_provider.py | 189 ++++++++++++++++++ tests/test_tenant_erasure_provider.py | 135 +++++++++++++ webui/package.json | 2 +- 9 files changed, 434 insertions(+), 6 deletions(-) create mode 100644 src/govoplan_access/backend/tenant_erasure_provider.py create mode 100644 tests/test_tenant_erasure_provider.py diff --git a/README.md b/README.md index 52e9dc7..6297a4f 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,9 @@ This module will own: capabilities, including the bounded `access.governanceProjection.v1` bulk reconciliation contract used by Admin for idempotent per-assignment outcomes - access-owned migrations +- a provider-neutral tenant-erasure contribution that removes tenant-scoped + credentials and authorization projections while preserving shared global + accounts and identities The governance-template routes under `/admin/system/governance-templates` are contributed by `govoplan-admin`; access must not register those routes. diff --git a/docs/ACCESS_MODULE_BOUNDARY.md b/docs/ACCESS_MODULE_BOUNDARY.md index 06f6462..1eb3ebc 100644 --- a/docs/ACCESS_MODULE_BOUNDARY.md +++ b/docs/ACCESS_MODULE_BOUNDARY.md @@ -25,6 +25,9 @@ contracts. - tenant owner provisioning and default access bootstrap - materializing governance templates into access-owned groups and roles - access-owned SQLAlchemy metadata and migrations for `access_*` tables +- the `tenancy.erasure_provider.access` contribution, which previews and + idempotently removes only target-tenant credentials and authorization rows + while retaining global accounts and identities shared with other tenants The active access tables use the `access_*` namespace while the model classes live in this module: `access_accounts`, `access_users`, `access_groups`, @@ -64,6 +67,22 @@ Access declares tenancy as an optional module integration. It uses the core-owned `core_scopes` table as the scope table, but it must not import `govoplan_tenancy` or require the tenancy package to start. +## Tenant-Erasure Boundary + +Access implements the Core tenant-erasure provider contract without importing +Tenancy. Its preview counts every Access table with a tenant boundary. The +first destructive step removes target-tenant sessions and API keys; the second +removes service accounts, memberships, groups, tenant roles, organization +units, functions, assignments, and delegations in dependency-safe order. +Both steps are database-transactional and idempotent, so reconciliation can +repeat them after an interrupted response. + +Global accounts, system-role assignments, identities, and identity-account +links are intentionally retained: they are installation-wide facts and may be +used by another tenant. Provider previews and receipts contain counts and +stable references only, never password hashes, session tokens, API-key hashes, +email addresses, or other credential material. + ## Core-Only Startup Contract A core-only installation must be able to start far enough to expose process diff --git a/package.json b/package.json index 00ffb10..67eccb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/access-webui", - "version": "0.1.23", + "version": "0.1.24", "private": true, "type": "module", "main": "webui/src/index.ts", diff --git a/pyproject.toml b/pyproject.toml index 805646d..59451f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-access" -version = "0.1.23" +version = "0.1.24" description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.18", + "govoplan-core>=0.1.43", "redis>=5,<6", "SQLAlchemy>=2,<3", ] diff --git a/src/govoplan_access/__init__.py b/src/govoplan_access/__init__.py index 8da6f11..8429724 100644 --- a/src/govoplan_access/__init__.py +++ b/src/govoplan_access/__init__.py @@ -1,3 +1,3 @@ """GovOPlaN access platform module.""" -__version__ = "0.1.23" +__version__ = "0.1.24" diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index 2b296bb..a378bc1 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -9,6 +9,10 @@ from govoplan_access.backend.configuration_provider import ( ACCESS_CONFIGURATION_CAPABILITY, ) from govoplan_access.backend.dsar_provider import ACCESS_DSAR_CAPABILITY +from govoplan_access.backend.tenant_erasure_provider import ( + ACCESS_TENANT_ERASURE_CAPABILITY, + AccessTenantErasureProvider, +) from govoplan_access.backend.db.base import AccessBase from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata from govoplan_core.core.access import ( @@ -75,6 +79,10 @@ def _permission( ) +def _tenant_erasure_provider(_context: ModuleContext) -> AccessTenantErasureProvider: + return AccessTenantErasureProvider() + + ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = ( _permission( "access:tenant:read", @@ -104,6 +112,13 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = ( "Access", "system", ), + _permission( + "access:tenant:erase", + "Erase tenants", + "Preview, approve, execute, and reconcile governed destructive tenant erasure.", + "Access", + "system", + ), _permission( "access:account:read", "View accounts", @@ -440,6 +455,7 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = ( "access:tenant:create", "access:tenant:update", "access:tenant:suspend", + "access:tenant:erase", "access:account:read", "access:account:create", "access:account:update", @@ -1755,6 +1771,62 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = ( ], }, ), + DocumentationTopic( + id="access.reference.tenant-erasure-boundary", + title="Understand Access behavior during tenant erasure", + summary=( + "Access contributes a bounded, idempotent erasure plan for tenant-scoped " + "credentials and authorization records without deleting shared identities." + ), + body=( + "The tenant-erasure preview counts sessions, API keys, memberships, service " + "accounts, groups, roles, organization units, functions, assignments, and " + "delegations owned by the target tenant. Execution first revokes tenant " + "credentials and then removes the remaining tenant-scoped authorization " + "projection. Global accounts, system roles, identities, and identity links are " + "retained because the same login identity may serve another tenant. Each step " + "uses the orchestration idempotency key and can be reconciled safely after a " + "database rollback or interrupted response. No credential secret is included " + "in previews, receipts, checkpoints, or audit evidence." + ), + layer="configured", + documentation_types=("admin",), + audience=("system_admin", "operator", "security_reviewer"), + order=36, + conditions=( + DocumentationCondition( + required_modules=("access", "tenancy"), + any_scopes=("system:tenants:erase",), + ), + ), + related_modules=("audit", "tenancy"), + translations={ + "de": { + "title": "Access-Grenze bei der Mandantenlöschung verstehen", + "summary": ( + "Access trägt einen begrenzten, idempotenten Löschplan für " + "mandantenbezogene Anmeldedaten und Berechtigungsdatensätze bei, " + "ohne gemeinsam verwendete Identitäten zu löschen." + ), + "body": ( + "Die Vorschau der Mandantenlöschung zählt Sitzungen, API-Schlüssel, " + "Mitgliedschaften, Servicekonten, Gruppen, Rollen, Organisationseinheiten, " + "Funktionen, Zuweisungen und Delegationen des Zielmandanten. Die Ausführung " + "widerruft zuerst mandantenbezogene Anmeldedaten und entfernt danach die " + "übrige Berechtigungsprojektion. Globale Konten, Systemrollen, Identitäten " + "und Identitätsverknüpfungen bleiben erhalten, weil dieselbe Login-Identität " + "einem weiteren Mandanten dienen kann. Jeder Schritt nutzt den " + "Idempotenzschlüssel der Orchestrierung und kann nach einem Datenbank-Rollback " + "oder einer unterbrochenen Antwort sicher abgeglichen werden. Vorschauen, " + "Belege, Prüfpunkte und Auditnachweise enthalten keine geheimen Anmeldedaten." + ), + } + }, + metadata={ + "kind": "reference", + "help_contexts": ["tenancy.admin.tenant-erasure"], + }, + ), ) @@ -1960,7 +2032,7 @@ def _people_search(context: ModuleContext) -> object: manifest = ModuleManifest( id="access", name="Access", - version="0.1.23", + version="0.1.24", optional_dependencies=("identity", "organizations", "tenancy", "idm"), provides_interfaces=( ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"), @@ -2156,6 +2228,7 @@ manifest = ModuleManifest( CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options, ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider, ACCESS_DSAR_CAPABILITY: _dsar_provider, + ACCESS_TENANT_ERASURE_CAPABILITY: _tenant_erasure_provider, }, capability_documentation={ ACCESS_DSAR_CAPABILITY: CapabilityDocumentation( @@ -2166,6 +2239,15 @@ manifest = ModuleManifest( ), contract_version="0.1.0", ), + ACCESS_TENANT_ERASURE_CAPABILITY: CapabilityDocumentation( + label="Access tenant-erasure provider", + summary=( + "Previews and idempotently removes tenant-scoped sessions, keys, " + "memberships, roles, groups, functions, and assignments while " + "retaining shared global accounts and identities." + ), + contract_version="0.1.0", + ), }, documentation=ACCESS_DOCUMENTATION, architecture=declared_module_architecture( diff --git a/src/govoplan_access/backend/tenant_erasure_provider.py b/src/govoplan_access/backend/tenant_erasure_provider.py new file mode 100644 index 0000000..02728e7 --- /dev/null +++ b/src/govoplan_access/backend/tenant_erasure_provider.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.models import ( + ApiKey, + AuthSession, + ExternalFunctionRoleAssignment, + Function, + FunctionAssignment, + FunctionDelegation, + FunctionRoleAssignment, + Group, + GroupRoleAssignment, + OrganizationUnit, + Role, + ServiceAccount, + User, + UserGroupMembership, + UserRoleAssignment, +) +from govoplan_core.core.tenant_erasure import ( + TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX, + TenantErasurePreview, + TenantErasureResource, + TenantErasureStep, + TenantErasureStepResult, +) + + +ACCESS_TENANT_ERASURE_CAPABILITY = ( + f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}access" +) + +_CREDENTIAL_MODELS = (AuthSession, ApiKey) +_TENANT_ACCESS_MODELS = ( + ServiceAccount, + FunctionDelegation, + ExternalFunctionRoleAssignment, + FunctionRoleAssignment, + UserGroupMembership, + UserRoleAssignment, + GroupRoleAssignment, + FunctionAssignment, + Function, + OrganizationUnit, + User, + Group, + Role, +) +_ALL_MODELS = _CREDENTIAL_MODELS + _TENANT_ACCESS_MODELS + + +def _counts(session: Session, tenant_id: str) -> dict[str, int]: + return { + model.__tablename__: session.query(model) + .filter(model.tenant_id == tenant_id) + .count() + for model in _ALL_MODELS + } + + +def _delete_models( + session: Session, + tenant_id: str, + models: tuple[type, ...], +) -> int: + deleted = 0 + for model in models: + deleted += ( + session.query(model) + .filter(model.tenant_id == tenant_id) + .delete(synchronize_session=False) + ) + return deleted + + +class AccessTenantErasureProvider: + module_id = "access" + + def preview_tenant_erasure( + self, + session: object, + tenant_id: str, + ) -> TenantErasurePreview: + if not isinstance(session, Session): + raise TypeError("Access tenant erasure requires a database session.") + counts = _counts(session, tenant_id) + credential_count = sum( + counts[model.__tablename__] for model in _CREDENTIAL_MODELS + ) + access_count = sum( + counts[model.__tablename__] for model in _TENANT_ACCESS_MODELS + ) + resources = tuple( + TenantErasureResource( + resource_type=table_name, + count=count, + disposition="erase", + summary=f"{count} tenant-scoped Access records will be erased.", + ) + for table_name, count in sorted(counts.items()) + ) + steps: list[TenantErasureStep] = [] + if credential_count: + steps.append( + TenantErasureStep( + step_id="revoke-tenant-credentials", + kind="erase", + summary="Revoke tenant sessions and erase tenant API keys.", + destructive=True, + irreversible=True, + ) + ) + if access_count: + steps.append( + TenantErasureStep( + step_id="erase-tenant-access", + kind="erase", + summary=( + "Erase tenant memberships, service accounts, groups, roles, " + "organization units, functions, assignments, and delegations." + ), + destructive=True, + irreversible=True, + depends_on=( + ("revoke-tenant-credentials",) if credential_count else () + ), + ) + ) + return TenantErasurePreview( + module_id=self.module_id, + complete=True, + resources=resources, + steps=tuple(steps), + warnings=( + "Global accounts and identity links are retained because they may belong to other tenants.", + ), + provider_revision="access-tenant-erasure-v1", + ) + + def execute_tenant_erasure_step( + self, + session: object, + tenant_id: str, + step_id: str, + idempotency_key: str, + ) -> TenantErasureStepResult: + if not isinstance(session, Session): + raise TypeError("Access tenant erasure requires a database session.") + if not idempotency_key.strip(): + raise ValueError("Access tenant erasure requires an idempotency key.") + if step_id == "revoke-tenant-credentials": + deleted = _delete_models(session, tenant_id, _CREDENTIAL_MODELS) + summary = "Tenant sessions and API keys were erased." + elif step_id == "erase-tenant-access": + deleted = _delete_models(session, tenant_id, _TENANT_ACCESS_MODELS) + summary = "Tenant-scoped Access records were erased." + else: + return TenantErasureStepResult( + state="blocked", + summary="Access tenant erasure step is unknown.", + ) + return TenantErasureStepResult( + state="completed", + summary=summary, + receipt_ref=f"access:tenant-erasure:{tenant_id}:{step_id}", + metrics={"deleted": deleted}, + ) + + def reconcile_tenant_erasure_step( + self, + session: object, + tenant_id: str, + step_id: str, + idempotency_key: str, + ) -> TenantErasureStepResult: + return self.execute_tenant_erasure_step( + session, + tenant_id, + step_id, + idempotency_key, + ) + + +__all__ = [ + "ACCESS_TENANT_ERASURE_CAPABILITY", + "AccessTenantErasureProvider", +] diff --git a/tests/test_tenant_erasure_provider.py b/tests/test_tenant_erasure_provider.py new file mode 100644 index 0000000..b8c3824 --- /dev/null +++ b/tests/test_tenant_erasure_provider.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session + +from govoplan_access.backend.db.base import AccessBase +from govoplan_access.backend.db.models import ( + Account, + ApiKey, + AuthSession, + Group, + Role, + User, +) +from govoplan_access.backend.tenant_erasure_provider import ( + AccessTenantErasureProvider, +) + + +def test_access_erasure_is_tenant_bounded_and_retains_global_account() -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + AccessBase.metadata.create_all(engine) + now = datetime.now(UTC) + with Session(engine) as session: + account = Account( + email="shared@example.test", + normalized_email="shared@example.test", + is_active=True, + auth_provider="local", + ) + session.add(account) + session.flush() + first = User( + tenant_id="tenant-1", + account_id=account.id, + email="shared@example.test", + is_active=True, + is_tenant_admin=True, + auth_provider="local", + ) + second = User( + tenant_id="tenant-2", + account_id=account.id, + email="shared@example.test", + is_active=True, + is_tenant_admin=False, + auth_provider="local", + ) + session.add_all( + [ + first, + second, + Group(tenant_id="tenant-1", slug="group", name="Group"), + Group(tenant_id="tenant-2", slug="group", name="Group"), + Role(tenant_id="tenant-1", slug="role", name="Role"), + Role(tenant_id="tenant-2", slug="role", name="Role"), + ] + ) + session.flush() + session.add_all( + [ + ApiKey( + tenant_id="tenant-1", + user_id=first.id, + name="key", + prefix="prefix", + key_hash="hash", + scopes=[], + ), + AuthSession( + tenant_id="tenant-1", + user_id=first.id, + account_id=account.id, + token_hash="token-hash", + expires_at=now + timedelta(hours=1), + ), + ] + ) + session.commit() + + provider = AccessTenantErasureProvider() + preview = provider.preview_tenant_erasure(session, "tenant-1") + + assert preview.allowed + assert [step.step_id for step in preview.steps] == [ + "revoke-tenant-credentials", + "erase-tenant-access", + ] + assert "revoke-tenant-credentials" in preview.steps[1].depends_on + + revoked = provider.execute_tenant_erasure_step( + session, + "tenant-1", + "revoke-tenant-credentials", + "operation:access:credentials", + ) + erased = provider.execute_tenant_erasure_step( + session, + "tenant-1", + "erase-tenant-access", + "operation:access:tenant", + ) + session.commit() + + assert revoked.state == "completed" + assert erased.state == "completed" + assert provider.preview_tenant_erasure(session, "tenant-1").steps == () + assert session.scalar(select(Account).where(Account.id == account.id)) is not None + assert session.scalar(select(User).where(User.tenant_id == "tenant-2")) is not None + assert session.scalar(select(Group).where(Group.tenant_id == "tenant-2")) is not None + assert session.scalar(select(Role).where(Role.tenant_id == "tenant-2")) is not None + + +def test_access_erasure_replay_is_idempotent() -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + AccessBase.metadata.create_all(engine) + provider = AccessTenantErasureProvider() + with Session(engine) as session: + first = provider.execute_tenant_erasure_step( + session, + "tenant-1", + "erase-tenant-access", + "operation:access:tenant", + ) + second = provider.reconcile_tenant_erasure_step( + session, + "tenant-1", + "erase-tenant-access", + "operation:access:tenant", + ) + + assert first.metrics == {"deleted": 0} + assert second.metrics == {"deleted": 0} diff --git a/webui/package.json b/webui/package.json index 29ae462..7d6a7cb 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/access-webui", - "version": "0.1.23", + "version": "0.1.24", "private": true, "type": "module", "scripts": {