From a0cc947bf65241ac93280d1d7c00d8fc8b7a7740 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 13 Sep 2026 12:20:02 +0200 Subject: [PATCH] Support tenant-scoped directory lifecycle with isolation tests Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c --- .forgejo/workflows/identity-journeys.yaml | 18 +++++++ identity-provisioner/provisioner.py | 38 +++++++++++++++ .../tests/test_tenant_access.py | 47 +++++++++++++++++++ ...P-0038-tenant-scoped-identity-lifecycle.md | 43 +++++++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 .forgejo/workflows/identity-journeys.yaml create mode 100644 identity-provisioner/tests/test_tenant_access.py create mode 100644 workplans/NK-WP-0038-tenant-scoped-identity-lifecycle.md diff --git a/.forgejo/workflows/identity-journeys.yaml b/.forgejo/workflows/identity-journeys.yaml new file mode 100644 index 0000000..bf23e4f --- /dev/null +++ b/.forgejo/workflows/identity-journeys.yaml @@ -0,0 +1,18 @@ +name: Identity provider journey acceptance +on: + push: + branches: [main] + paths: ["identity-provisioner/**", ".forgejo/workflows/identity-journeys.yaml"] + workflow_dispatch: +jobs: + provider: + runs-on: self-hosted + steps: + - name: Test the exact commit + run: | + set -eu + mkdir -p identity-source + curl -fsSL "https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${GITHUB_SHA}.tar.gz" -o identity-source.tar.gz + tar xzf identity-source.tar.gz -C identity-source --strip-components=1 + cd identity-source + PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=identity-provisioner python3 -m unittest discover -s identity-provisioner/tests -v diff --git a/identity-provisioner/provisioner.py b/identity-provisioner/provisioner.py index c350aa1..02f31e2 100644 --- a/identity-provisioner/provisioner.py +++ b/identity-provisioner/provisioner.py @@ -70,6 +70,42 @@ mutation CreateUser($id: String!, $email: String!, $display: String!) { raise return Result("netkingdom-lldap", _oidc_subject(username), "password_setup_required", resumed) + def tenant_access(self, payload: dict[str, Any]) -> Result: + """Change only the two role groups owned by the specified tenant.""" + _required(payload, "external_subject", "tenant", "idempotency_key", "correlation_id") + tenant = str(payload["tenant"]) + if not re.fullmatch(r"tenant:[a-z0-9][a-z0-9._-]*(?::[a-z0-9][a-z0-9._-]*)*", tenant): + raise ValueError("invalid tenant identifier") + if tenant == "tenant:platform:root": + raise ValueError("platform root is not a tenant access target") + if not isinstance(payload.get("enabled"), bool): + raise ValueError("enabled must be boolean") + roles = payload.get("roles", []) + if not isinstance(roles, (list, tuple)) or any(r not in {"user", "tenant-admin"} for r in roles): + raise ValueError("unsupported tenant role") + subject = _directory_username(str(payload["external_subject"])) + if not re.fullmatch(r"[A-Za-z0-9._-]+", subject): + raise ValueError("invalid directory subject") + token = self._login() + user = self._user(token, subject) + if user is None: + raise ValueError("login identity not found; create it before changing access") + managed = {f"{tenant}:users", f"{tenant}:admins"} + desired = {f"{tenant}:users"} if payload["enabled"] else set() + if payload["enabled"] and "tenant-admin" in roles: + desired.add(f"{tenant}:admins") + current = {str(g["displayName"]): int(g["id"]) for g in user.get("groups", ())} + groups = list(self._directory(token)[1]) + for name in sorted(desired - current.keys()): + self._add_group(token, subject, self._ensure_group(token, groups, name)) + for name in sorted((managed & current.keys()) - desired): + self._remove_group(token, subject, current[name]) + checked = self._user(token, subject) + if checked is None or ({g["displayName"] for g in checked.get("groups", ())} & managed) != desired: + raise RuntimeError("tenant access readback did not confirm the requested state") + return Result("netkingdom-lldap", _oidc_subject(subject), + "tenant_active" if payload["enabled"] else "tenant_disabled", False) + def suspend(self, subject: str) -> Result: subject = _directory_username(subject) token = self._login() @@ -245,6 +281,8 @@ mutation Remove($userId: String!, $groupId: Int!) { def dispatch( provisioner: LLDAPProvisioner, path: str, payload: dict[str, Any] ) -> Result | DriftResult: + if path == "/v1/identities/tenant-access": + return provisioner.tenant_access(payload) if path == "/v1/identities/provision": return provisioner.provision(payload) if path == "/v1/identities/drift": diff --git a/identity-provisioner/tests/test_tenant_access.py b/identity-provisioner/tests/test_tenant_access.py new file mode 100644 index 0000000..3f3a620 --- /dev/null +++ b/identity-provisioner/tests/test_tenant_access.py @@ -0,0 +1,47 @@ +import unittest +from provisioner import LLDAPProvisioner, dispatch + +class Directory(LLDAPProvisioner): + def __init__(self): + self.groups = {'tenant:trial:one:users':1, 'tenant:trial:one:admins':2, + 'tenant:trial:two:users':3, 'netkingdom-suspended':4, 'unrelated':5} + self.calls=[] + self.fail=False + def _login(self): return 'test-token' + def _user(self, token, subject): + return {'groups':[{'displayName':n,'id':i} for n,i in self.groups.items()]} + def _directory(self, token): return [],[] + def _ensure_group(self, token, groups, name): return max(self.groups.values(), default=0)+1 + def _add_group(self, token, subject, group): + self.calls.append(('add',group)) + if not self.fail:self.groups['tenant:trial:one:users']=group + def _remove_group(self, token, subject, group): + self.calls.append(('remove',group)) + if not self.fail:self.groups={n:i for n,i in self.groups.items() if i!=group} + +class TenantAccessTests(unittest.TestCase): + def payload(self, **kwargs): + return dict(external_subject='uid=member,ou=people,dc=netkingdom,dc=local', + tenant='tenant:trial:one', enabled=False, roles=['user'], + idempotency_key='test-operation-123456', correlation_id='test', **kwargs) + def test_disable_preserves_other_tenants_and_global_suspension(self): + d=Directory(); result=dispatch(d,'/v1/identities/tenant-access',self.payload()) + self.assertEqual('tenant_disabled',result.status) + self.assertEqual({'tenant:trial:two:users','netkingdom-suspended','unrelated'},set(d.groups)) + d.calls.clear(); dispatch(d,'/v1/identities/tenant-access',self.payload()) + self.assertEqual([],d.calls) + def test_reactivation_does_not_remove_platform_suspension(self): + d=Directory();d.tenant_access(self.payload());p=self.payload();p['enabled']=True + self.assertEqual('tenant_active',d.tenant_access(p).status) + self.assertIn('tenant:trial:one:users',d.groups) + self.assertIn('netkingdom-suspended',d.groups) + self.assertNotIn('tenant:trial:one:admins',d.groups) + def test_failed_readback_never_reports_success_and_retry_recovers(self): + d=Directory();d.fail=True + with self.assertRaises(RuntimeError):d.tenant_access(self.payload()) + d.fail=False;self.assertEqual('tenant_disabled',d.tenant_access(self.payload()).status) + def test_invalid_scope_or_role_causes_no_mutation(self): + for key,value in [('tenant','tenant:platform:root'),('tenant','tenant:one:users*'),('enabled','false'),('roles',['platform-operator'])]: + d=Directory();p=self.payload();p[key]=value + with self.assertRaises(ValueError):d.tenant_access(p) + self.assertEqual([],d.calls) diff --git a/workplans/NK-WP-0038-tenant-scoped-identity-lifecycle.md b/workplans/NK-WP-0038-tenant-scoped-identity-lifecycle.md new file mode 100644 index 0000000..953f4cb --- /dev/null +++ b/workplans/NK-WP-0038-tenant-scoped-identity-lifecycle.md @@ -0,0 +1,43 @@ +--- +id: NK-WP-0038 +type: workplan +title: "Tenant-scoped identity lifecycle for account journeys" +domain: infotech +repo: net-kingdom +status: active +owner: codex +topic_slug: infotech +created: "2026-09-13" +updated: "2026-09-13" +--- + +## Implement scoped directory access changes + +```task +id: NK-WP-0038-T01 +status: done +priority: high +``` + +Support USER-WP-0029 T06: /v1/identities/tenant-access changes only the target +tenant's users/admins groups, never deletes the identity, clears global suspension +or touches other tenants. Validate tenant/role/status; verify readback before +reporting success. Repeated desired-state requests converge. Preserve original +identity-wide owner operations for explicitly global use. + +## Verify and publish the scoped provider contract + +```task +id: NK-WP-0038-T02 +status: progress +priority: high +``` + +Run provider regression and new isolation/readback/retry tests, build a pinned +image, update owner manifests and deploy before the portal uses this endpoint. +No live user lifecycle mutation is part of deployment verification. OTP provider +credentials remain a separate NK-WP-0033/KEY-WP-0035 dependency. + +Provider regression: 20 tests passed, including tenant-group isolation, repeated +desired-state updates, failed readback/retry and preservation of global suspension. +CI now runs the provider suite for changes to identity-provisioner.