Implement role-based account journeys with database and browser acceptance suites
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 20s
Account journey acceptance / journeys (push) Failing after 0s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 12:20:02 +02:00
parent 75750c0036
commit 1127f852dd
24 changed files with 1554 additions and 148 deletions

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import copy
import os
from threading import RLock
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Iterable, Iterator, Mapping, cast
@ -78,6 +79,17 @@ class InMemoryUserEngineStore:
default=None, init=False, repr=False
)
_lifecycle_lock: object = field(default_factory=RLock, repr=False, compare=False)
@contextmanager
def tenant_lifecycle_guard(self, tenant: str) -> Iterator[None]:
# One reentrant lock also protects the in-memory transaction snapshot.
with self._lifecycle_lock:
yield
def outbox_history(self) -> tuple[OutboxEvent, ...]:
return tuple(self.outbox_events)
def migrate(self) -> None:
"""Apply the standalone schema migration manifest."""
self.schema_version = SCHEMA_VERSION

View file

@ -8,6 +8,7 @@ pooling, securing, and observing those connections.
from __future__ import annotations
import json
from threading import RLock
from contextlib import contextmanager
from importlib.resources import files
from typing import Any, Iterable, Iterator, Mapping, Protocol, cast
@ -80,6 +81,32 @@ class PostgresUserEngineStore:
def __init__(self, connection: PostgresConnection) -> None:
self.connection = connection
self._lifecycle_lock = RLock()
self._transaction_depth = 0
self._transaction_failed = False
@contextmanager
def tenant_lifecycle_guard(self, tenant: str) -> Iterator[None]:
# Session locks survive the service's local commits and cover provider
# side effects. Separate processes use the same tenant-scoped DB lock.
with self._lifecycle_lock:
key = "user-engine:tenant-lifecycle:" + tenant
with self._cursor() as cursor:
cursor.execute("SELECT pg_advisory_lock(hashtextextended(%s, 0))", (key,))
try:
yield
except BaseException:
# An aborted transaction cannot execute the unlock query.
self.connection.rollback()
raise
finally:
with self._cursor() as cursor:
cursor.execute("SELECT pg_advisory_unlock(hashtextextended(%s, 0))", (key,))
def outbox_history(self) -> tuple[OutboxEvent, ...]:
with self._cursor() as cursor:
cursor.execute("SELECT payload FROM user_engine_outbox_events ORDER BY occurred_at, event_id")
return tuple(cast(OutboxEvent, self._decode_payload_row("outbox_events", row)) for row in cursor.fetchall())
@property
def schema_version(self) -> str | None:
@ -97,16 +124,26 @@ class PostgresUserEngineStore:
@contextmanager
def transaction(self) -> Iterator[None]:
begin = getattr(self.connection, "begin", None)
if callable(begin):
begin()
outer = self._transaction_depth == 0
if outer:
self._transaction_failed = False
begin = getattr(self.connection, "begin", None)
if callable(begin): begin()
self._transaction_depth += 1
try:
yield
except Exception:
self.connection.rollback()
except BaseException:
self._transaction_failed = True
if outer: self.connection.rollback()
raise
else:
self.connection.commit()
if outer:
if self._transaction_failed:
self.connection.rollback()
raise RuntimeError("nested transaction failed")
self.connection.commit()
finally:
self._transaction_depth -= 1
def save_user(self, user: User) -> None:
self._upsert_record(user)

View file

@ -39,6 +39,14 @@ class HTTPIdentityProvisioningAdapter:
"preferred_username": request.preferred_username,
})
def tenant_access(self, *, external_subject: str, tenant: str, roles: tuple[str, ...],
enabled: bool, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
return self._post("/v1/identities/tenant-access", {
"external_subject": external_subject, "tenant": tenant, "roles": roles,
"enabled": enabled, "idempotency_key": idempotency_key,
"correlation_id": correlation_id,
})
def suspend(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
return self._lifecycle("suspend", external_subject, idempotency_key, correlation_id)