feat: contribute tenant erasure for access data
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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,3 +1,3 @@
|
||||
"""GovOPlaN access platform module."""
|
||||
|
||||
__version__ = "0.1.23"
|
||||
__version__ = "0.1.24"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.23",
|
||||
"version": "0.1.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user