Support tenant-scoped directory lifecycle with isolation tests
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
0071dba99e
commit
a0cc947bf6
4 changed files with 146 additions and 0 deletions
18
.forgejo/workflows/identity-journeys.yaml
Normal file
18
.forgejo/workflows/identity-journeys.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
47
identity-provisioner/tests/test_tenant_access.py
Normal file
47
identity-provisioner/tests/test_tenant_access.py
Normal file
|
|
@ -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)
|
||||
43
workplans/NK-WP-0038-tenant-scoped-identity-lifecycle.md
Normal file
43
workplans/NK-WP-0038-tenant-scoped-identity-lifecycle.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue