Implement role-based account journeys with database and browser acceptance suites
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
75750c0036
commit
1127f852dd
24 changed files with 1554 additions and 148 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue