feat: contribute tenant erasure for access data
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-24 15:57:20 +02:00
parent e55434f406
commit 0f8a05f8b9
9 changed files with 434 additions and 6 deletions
+3
View File
@@ -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.
+19
View File
@@ -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
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN access platform module."""
__version__ = "0.1.23"
__version__ = "0.1.24"
+83 -1
View File
@@ -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(
@@ -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",
]
+135
View File
@@ -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}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/access-webui",
"version": "0.1.23",
"version": "0.1.24",
"private": true,
"type": "module",
"scripts": {