Implements AccountRepository against the control-plane schema plus the phase-1 identity migration, and wires it in so production can select it. The adapter is not a thin SQL wrapper, because the control plane does not store the two things the application needs on the users row. Role and organization come from user_roles. There is no role column and no organization column on users; a role is assigned through user_roles with a scope, and app.validate_user_role_scope() enforces that the role's scope matches the assignment. Resolving one role per user is therefore a join, and a user holding more than one active assignment is refused rather than resolved to an arbitrary row. The organization on that assignment becomes the scope of every administrative decision, so guessing would hand out the wrong tenant's authority. Reconciling this with project-scoped membership stays an open review question, recorded as such. Email became a required field on account creation. users.email is NOT NULL UNIQUE, so accepting a missing address would let account creation succeed against the development repositories and then fail on the authoritative store. It is now required in the service, the API schema, and the bootstrap command, so it fails identically everywhere. Audit identifiers now belong to the store. audit_events.id is a bigint identity, so the adapter reports that value; the application correlation UUID is preserved in the detail and never displaces the row key. AuditEvent.id is a string for that reason, which removes a type the two stores disagreed about. Three further fail-closed behaviours: a users.status of invited or locked is refused rather than coerced onto active; a row with mfa_required is refused, because this phase cannot satisfy a second factor and authenticating it anyway would grant access to someone who was supposed to present one; and a missing organization blocks access rather than granting it. save_user deliberately does not write username or email, since both are identity keys with no reviewed change path and no audit event behind them. Every operation runs in one transaction on one pooled connection and publishes the caller with SET LOCAL app.current_user_id, so a returned connection cannot carry one request's identity into the next. That is inert until the reviewed RLS policies exist; the explicit WHERE clauses enforce scope today. A statement timeout bounds how long a pooled connection can be held. Production fails closed at two independent gates: the settings layer refuses without a DSN, and the repository boundary refuses to build a PostgreSQL store without one. Verified by running both, including that an unreachable host refuses to start rather than degrading. The migration gains an index on audit_events (target_type, target_id), which the administrator audit filter needs and nothing covered, and the review questions now include the three things that must be checked before provisioning: the roles rows and their scopes, any row with mfa_required set, and any user holding more than one active assignment. Verification, and its limit. 236 tests pass with 3 skipped. The adapter's row mapping, role resolution, conflict mapping, and configuration are unit tested and run everywhere. Its SQL, transactions, and pooled reuse are NOT executed: this environment has no database credentials, because sudo to the postgres user needs a password, there is no umbra database role, and there is no .pgpass. The integration suite is written and selected by UMBRATRANS_TEST_DSN, and its fixture refuses any DSN whose database name contains umbracontrol. A green run here says nothing about whether the SQL is correct.
996 lines
40 KiB
Python
996 lines
40 KiB
Python
"""PostgreSQL adapter for the account and organization repository.
|
|
|
|
This is the authoritative store. It is written against the control-plane schema
|
|
in ``001_control_plane.sql`` plus the phase-1 identity migration in
|
|
``migrations/0001_phase1_identity.sql``.
|
|
|
|
Three mappings in this adapter are load-bearing and are the reason it is not a
|
|
thin SQL wrapper. Each is documented at its implementation.
|
|
|
|
1. **Role and organization come from ``user_roles``, not from ``users``.**
|
|
The control plane stores no role column and no organization column on
|
|
``users``. It assigns a role through ``user_roles`` with a scope, and
|
|
``app.validate_user_role_scope()`` enforces that the role's scope matches the
|
|
assignment. Resolving a single role per user is therefore a join, and
|
|
:func:`_resolve_assignment` refuses to guess when a user holds more than one
|
|
active assignment, because picking one silently would grant the wrong scope.
|
|
|
|
2. **A missing email is impossible, not optional.** ``users.email`` is
|
|
``NOT NULL UNIQUE``. The service already requires one; this adapter is the
|
|
second line of defence for rows written by another system.
|
|
|
|
3. **Audit identifiers are the database's.** ``audit_events.id`` is
|
|
``bigint GENERATED ALWAYS AS IDENTITY``, so the adapter returns that value
|
|
and not the UUID the in-memory repository mints. The API therefore reports
|
|
audit events by their store identifier.
|
|
|
|
Connection handling
|
|
-------------------
|
|
Every operation runs inside one transaction on one pooled connection. The
|
|
caller's identity is published with ``SET LOCAL app.current_user_id`` so that
|
|
row-level-security policies, once the reviewed policies are applied, see a
|
|
server-derived value rather than anything a client controls. ``SET LOCAL`` is
|
|
scoped to the transaction, so a pooled connection cannot leak one caller's
|
|
identity into the next request.
|
|
|
|
Until those policies exist the ``SET LOCAL`` is inert and authorization is
|
|
enforced by the explicit ``WHERE`` clauses below. That is defence in depth
|
|
being staged, not a substitute: the ``WHERE`` clauses are what actually
|
|
enforce scope today.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from contextlib import contextmanager
|
|
from datetime import datetime
|
|
from typing import Any, Iterator, Mapping, Sequence
|
|
from uuid import UUID
|
|
|
|
import psycopg
|
|
from psycopg.rows import dict_row
|
|
from psycopg_pool import ConnectionPool
|
|
|
|
from .errors import ConflictError, NotFoundError, RepositoryError
|
|
from .models import (
|
|
AccountStatus,
|
|
AuditEvent,
|
|
CredentialRecord,
|
|
CredentialType,
|
|
Organization,
|
|
OrganizationStatus,
|
|
RoleName,
|
|
SessionRecord,
|
|
User,
|
|
username_key,
|
|
)
|
|
from .repository import AccountRepository
|
|
from .security import utc_now
|
|
|
|
LOGGER = logging.getLogger("umbratrans_web.postgres")
|
|
|
|
#: ``users.status`` allows more values than the application models. Anything
|
|
#: outside the application's set is refused on read rather than coerced, so an
|
|
#: unrecognised row surfaces as an error instead of being treated as active.
|
|
_ACCOUNT_STATUSES: Mapping[str, AccountStatus] = {status.value: status for status in AccountStatus}
|
|
|
|
#: The GUC that row-level-security policies are expected to read.
|
|
RLS_USER_SETTING = "app.current_user_id"
|
|
|
|
#: Statement timeout applied to every transaction, so one slow query cannot pin
|
|
#: a pooled connection indefinitely.
|
|
STATEMENT_TIMEOUT_MS = 5_000
|
|
|
|
|
|
class PostgresAccountRepository(AccountRepository):
|
|
"""Thread-safe :class:`AccountRepository` backed by PostgreSQL.
|
|
|
|
The pool must be opened by the caller. Pass ``open=False`` and call
|
|
:meth:`open` yourself so startup can fail before the process serves a
|
|
request.
|
|
"""
|
|
|
|
def __init__(self, pool: ConnectionPool[Any], *, open_pool: bool = True) -> None:
|
|
if open_pool:
|
|
pool.open(wait=True, timeout=10)
|
|
self._pool = pool
|
|
|
|
# -- lifecycle -----------------------------------------------------------
|
|
|
|
@classmethod
|
|
def from_dsn(
|
|
cls,
|
|
dsn: str,
|
|
*,
|
|
min_size: int = 1,
|
|
max_size: int = 10,
|
|
application_name: str = "umbratrans-web",
|
|
) -> PostgresAccountRepository:
|
|
"""Build a repository from a DSN.
|
|
|
|
The DSN is expected to come from the environment or a secret store. It
|
|
is never logged, never returned by an endpoint, and never written to a
|
|
development data file.
|
|
"""
|
|
|
|
pool = ConnectionPool(
|
|
conninfo=dsn,
|
|
min_size=min_size,
|
|
max_size=max_size,
|
|
kwargs={"application_name": application_name, "row_factory": dict_row},
|
|
open=False,
|
|
# Every checkout is wrapped in a transaction by _connection, so a
|
|
# returned connection never carries uncommitted work.
|
|
reset=_reset_connection,
|
|
)
|
|
return cls(pool)
|
|
|
|
def open(self, *, wait: bool = True, timeout: float = 10) -> None:
|
|
self._pool.open(wait=wait, timeout=timeout)
|
|
|
|
def close(self) -> None:
|
|
self._pool.close()
|
|
|
|
def check(self) -> bool:
|
|
"""Return whether the database answers a trivial query.
|
|
|
|
Used by the health probe. It reports reachability only and never
|
|
surfaces a version, host, or error string.
|
|
"""
|
|
|
|
try:
|
|
with self._connection() as connection:
|
|
connection.execute("SELECT 1")
|
|
return True
|
|
except Exception: # noqa: BLE001 - any failure means "not usable"
|
|
LOGGER.warning("database health check failed", exc_info=True)
|
|
return False
|
|
|
|
# -- transaction plumbing ----------------------------------------------
|
|
|
|
@contextmanager
|
|
def _connection(self, *, user_id: UUID | None = None) -> Iterator[psycopg.Connection[Any]]:
|
|
"""Yield a connection inside one transaction, publishing the caller.
|
|
|
|
``SET LOCAL`` is transaction-scoped, which is what makes connection
|
|
pooling safe here: a returned connection cannot carry the previous
|
|
request's identity.
|
|
"""
|
|
|
|
with self._pool.connection() as connection:
|
|
with connection.transaction():
|
|
connection.execute("SET LOCAL statement_timeout = %s", (STATEMENT_TIMEOUT_MS,))
|
|
connection.execute("SET LOCAL idle_in_transaction_session_timeout = '10s'")
|
|
if user_id is not None:
|
|
connection.execute(f"SET LOCAL {RLS_USER_SETTING} = %s", (str(user_id),))
|
|
yield connection
|
|
|
|
@contextmanager
|
|
def _for_user(self, user_id: UUID | None) -> Iterator[psycopg.Connection[Any]]:
|
|
with self._connection(user_id=user_id) as connection:
|
|
yield connection
|
|
|
|
# -- users ---------------------------------------------------------------
|
|
|
|
def create_user(self, user: User) -> User:
|
|
"""Insert the profile and its role assignment in one transaction.
|
|
|
|
``users`` carries no role or organization column, so the role is written
|
|
as a ``user_roles`` row. Both statements share a transaction: a user
|
|
without a resolvable role could not authenticate, so committing one
|
|
without the other would create an account that can never be used.
|
|
"""
|
|
|
|
now = user.created_at or utc_now()
|
|
with self._connection() as connection:
|
|
try:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO public.users (
|
|
id, email, display_name, username, status,
|
|
mfa_required, created_at, updated_at
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, false, %s, %s)
|
|
""",
|
|
(
|
|
user.id,
|
|
user.email,
|
|
user.display_name,
|
|
user.username,
|
|
user.status.value,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
except psycopg.errors.UniqueViolation as error:
|
|
raise _unique_conflict(error, user.username) from error
|
|
self._insert_assignment(connection, user=user, now=now)
|
|
return user
|
|
|
|
def save_user(self, user: User) -> User:
|
|
"""Update the mutable profile fields.
|
|
|
|
``username`` and ``email`` are intentionally not written. Both are
|
|
identity keys under unique constraints, and neither has a reviewed
|
|
change path in this phase, so an accidental edit here would move an
|
|
account's login identifier with no audit event behind it.
|
|
"""
|
|
|
|
with self._connection() as connection:
|
|
updated = connection.execute(
|
|
"""
|
|
UPDATE public.users
|
|
SET display_name = %s,
|
|
status = %s,
|
|
updated_at = %s
|
|
WHERE id = %s
|
|
RETURNING id
|
|
""",
|
|
(user.display_name, user.status.value, user.updated_at or utc_now(), user.id),
|
|
).fetchone()
|
|
if updated is None:
|
|
raise NotFoundError("account_not_found")
|
|
self._sync_assignment(connection, user=user)
|
|
return user
|
|
|
|
def get_user(self, user_id: UUID) -> User | None:
|
|
return self._fetch_user("u.id = %s", (user_id,))
|
|
|
|
def get_user_by_username(self, key: str) -> User | None:
|
|
try:
|
|
normalized = username_key(key)
|
|
except Exception: # noqa: BLE001 - a malformed key is simply "no match"
|
|
return None
|
|
return self._fetch_user("u.username::text = %s", (normalized,))
|
|
|
|
def count_users(self, organization_id: UUID | None = None) -> int:
|
|
where, params = self._user_scope(organization_id)
|
|
with self._connection() as connection:
|
|
row = connection.execute(f"SELECT count(*) AS total FROM public.users u {where}", params).fetchone()
|
|
return int(row["total"]) if row else 0
|
|
|
|
def find_users(
|
|
self,
|
|
*,
|
|
organization_id: UUID | None = None,
|
|
status: AccountStatus | None = None,
|
|
role: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[User], int]:
|
|
"""Return a page of accounts.
|
|
|
|
Filtering by organization is a join through ``user_roles`` because
|
|
``users`` holds no organization column. The aggregate count is computed
|
|
in the same statement shape so the page total and the rows cannot
|
|
disagree.
|
|
"""
|
|
|
|
conditions: list[str] = []
|
|
params: list[Any] = []
|
|
if organization_id is not None:
|
|
conditions.append(
|
|
"EXISTS (SELECT 1 FROM public.user_roles ur WHERE ur.user_id = u.id AND ur.organization_id = %s AND ur.status = 'active')"
|
|
)
|
|
params.append(organization_id)
|
|
if status is not None:
|
|
conditions.append("u.status = %s")
|
|
params.append(status.value)
|
|
if role is not None:
|
|
conditions.append(
|
|
"EXISTS (SELECT 1 FROM public.user_roles ur JOIN public.roles r ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.name = %s AND ur.status = 'active')"
|
|
)
|
|
params.append(role)
|
|
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
|
|
|
with self._connection() as connection:
|
|
total_row = connection.execute(f"SELECT count(*) AS total FROM public.users u {where}", params).fetchone()
|
|
total = int(total_row["total"]) if total_row else 0
|
|
if total == 0:
|
|
return [], 0
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT u.id, u.username, u.display_name, u.email, u.status,
|
|
u.mfa_required, u.created_at, u.updated_at
|
|
FROM public.users u
|
|
{where}
|
|
ORDER BY u.created_at DESC, u.id
|
|
LIMIT %s OFFSET %s
|
|
""",
|
|
[*params, max(limit, 0), max(offset, 0)],
|
|
).fetchall()
|
|
return [_user_from_row(row) for row in rows], total
|
|
|
|
def _fetch_user(self, predicate: str, params: Sequence[Any]) -> User | None:
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
f"""
|
|
SELECT u.id, u.username, u.display_name, u.email, u.status,
|
|
u.mfa_required, u.created_at, u.updated_at
|
|
FROM public.users u
|
|
WHERE {predicate}
|
|
""",
|
|
list(params),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return _user_with_assignment(connection, row)
|
|
|
|
def _user_scope(self, organization_id: UUID | None) -> tuple[str, list[Any]]:
|
|
if organization_id is None:
|
|
return "", []
|
|
return (
|
|
"WHERE EXISTS (SELECT 1 FROM public.user_roles ur "
|
|
"WHERE ur.user_id = u.id AND ur.organization_id = %s AND ur.status = 'active')",
|
|
[organization_id],
|
|
)
|
|
|
|
# -- role assignments ---------------------------------------------------
|
|
|
|
def _insert_assignment(self, connection: psycopg.Connection[Any], *, user: User, now: datetime) -> None:
|
|
scope, organization_id = _assignment_shape(user)
|
|
role_row = connection.execute(
|
|
"SELECT id, scope FROM public.roles WHERE name = %s", (str(user.role),)
|
|
).fetchone()
|
|
if role_row is None:
|
|
raise RepositoryError(
|
|
"role_not_seeded",
|
|
"The requested role is not present in the roles table.",
|
|
)
|
|
if role_row["scope"] != scope:
|
|
# app.validate_user_role_scope() would reject this anyway; failing
|
|
# here names the actual problem instead of surfacing a trigger error.
|
|
raise RepositoryError(
|
|
"role_scope_mismatch",
|
|
"The requested role does not have the scope required for this assignment.",
|
|
)
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO public.user_roles (
|
|
user_id, role_id, scope, organization_id, project_id,
|
|
status, created_at, created_by
|
|
)
|
|
VALUES (%s, %s, %s, %s, NULL, 'active', %s, %s)
|
|
""",
|
|
(user.id, role_row["id"], scope, organization_id, now, user.id),
|
|
)
|
|
|
|
def _sync_assignment(self, connection: psycopg.Connection[Any], *, user: User) -> None:
|
|
"""Make the active assignment match the requested role and organization.
|
|
|
|
Phase 1 models exactly one role and one organization per account, so
|
|
the target state is fully determined. A user holding several active
|
|
assignments is refused by :func:`_resolve_assignment` on read; this
|
|
write path refuses to collapse them silently either.
|
|
"""
|
|
|
|
scope, organization_id = _assignment_shape(user)
|
|
role_row = connection.execute(
|
|
"SELECT id, scope FROM public.roles WHERE name = %s", (str(user.role),)
|
|
).fetchone()
|
|
if role_row is None:
|
|
raise RepositoryError("role_not_seeded", "The requested role is not present in the roles table.")
|
|
if role_row["scope"] != scope:
|
|
raise RepositoryError(
|
|
"role_scope_mismatch",
|
|
"The requested role does not have the scope required for this assignment.",
|
|
)
|
|
existing = connection.execute(
|
|
"SELECT id, role_id, organization_id FROM public.user_roles WHERE user_id = %s AND status = 'active'",
|
|
(user.id,),
|
|
).fetchall()
|
|
if len(existing) > 1:
|
|
raise RepositoryError(
|
|
"ambiguous_role_assignment",
|
|
"The account holds more than one active role assignment, which this phase cannot represent.",
|
|
)
|
|
if existing:
|
|
if existing[0]["role_id"] == role_row["id"] and existing[0]["organization_id"] == organization_id:
|
|
return
|
|
connection.execute(
|
|
"UPDATE public.user_roles SET role_id = %s, organization_id = %s WHERE id = %s",
|
|
(role_row["id"], organization_id, existing[0]["id"]),
|
|
)
|
|
return
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO public.user_roles (
|
|
user_id, role_id, scope, organization_id, project_id, status, created_at, created_by
|
|
)
|
|
VALUES (%s, %s, %s, %s, NULL, 'active', now(), %s)
|
|
""",
|
|
(user.id, role_row["id"], scope, organization_id, user.id),
|
|
)
|
|
|
|
# -- organizations -------------------------------------------------------
|
|
|
|
def create_organization(self, organization: Organization) -> Organization:
|
|
with self._connection() as connection:
|
|
try:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO public.organizations (id, name, slug, status, created_at, updated_at)
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
organization.id,
|
|
organization.name,
|
|
organization.slug,
|
|
organization.status.value,
|
|
organization.created_at or utc_now(),
|
|
organization.updated_at or utc_now(),
|
|
),
|
|
)
|
|
except psycopg.errors.UniqueViolation as error:
|
|
raise ConflictError("organization_slug_taken", "That organization slug is already in use.") from error
|
|
return organization
|
|
|
|
def save_organization(self, organization: Organization) -> Organization:
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
UPDATE public.organizations
|
|
SET name = %s, slug = %s, status = %s, updated_at = %s
|
|
WHERE id = %s
|
|
RETURNING id
|
|
""",
|
|
(
|
|
organization.name,
|
|
organization.slug,
|
|
organization.status.value,
|
|
organization.updated_at or utc_now(),
|
|
organization.id,
|
|
),
|
|
).fetchone()
|
|
if row is None:
|
|
raise NotFoundError("organization_not_found")
|
|
return organization
|
|
|
|
def get_organization(self, organization_id: UUID) -> Organization | None:
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
"SELECT id, name, slug, status, created_at, updated_at FROM public.organizations WHERE id = %s",
|
|
(organization_id,),
|
|
).fetchone()
|
|
return _organization_from_row(row) if row else None
|
|
|
|
def get_organization_by_slug(self, slug: str) -> Organization | None:
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
"SELECT id, name, slug, status, created_at, updated_at FROM public.organizations WHERE slug::text = %s",
|
|
(slug.casefold(),),
|
|
).fetchone()
|
|
return _organization_from_row(row) if row else None
|
|
|
|
def find_organizations(
|
|
self,
|
|
*,
|
|
status: OrganizationStatus | None = None,
|
|
organization_ids: tuple[UUID, ...] | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[Organization], int]:
|
|
conditions: list[str] = []
|
|
params: list[Any] = []
|
|
if organization_ids is not None:
|
|
if not organization_ids:
|
|
return [], 0
|
|
conditions.append("id = ANY(%s)")
|
|
params.append([str(value) for value in organization_ids])
|
|
if status is not None:
|
|
conditions.append("status = %s")
|
|
params.append(status.value)
|
|
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
|
with self._connection() as connection:
|
|
total_row = connection.execute(
|
|
f"SELECT count(*) AS total FROM public.organizations {where}", params
|
|
).fetchone()
|
|
total = int(total_row["total"]) if total_row else 0
|
|
if total == 0:
|
|
return [], 0
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT id, name, slug, status, created_at, updated_at
|
|
FROM public.organizations
|
|
{where}
|
|
ORDER BY created_at DESC, id
|
|
LIMIT %s OFFSET %s
|
|
""",
|
|
[*params, max(limit, 0), max(offset, 0)],
|
|
).fetchall()
|
|
return [_organization_from_row(row) for row in rows], total
|
|
|
|
# -- credentials --------------------------------------------------------
|
|
|
|
def get_credential(self, user_id: UUID) -> CredentialRecord | None:
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT id, user_id, credential_type, password_hash, must_change_password,
|
|
failed_login_count, locked_until, last_login_at,
|
|
password_changed_at, created_at, updated_at
|
|
FROM public.user_credentials
|
|
WHERE user_id = %s
|
|
""",
|
|
(user_id,),
|
|
).fetchone()
|
|
return _credential_from_row(row) if row else None
|
|
|
|
def save_credential(self, credential: CredentialRecord) -> CredentialRecord:
|
|
"""Write the credential, or insert it when the account has none.
|
|
|
|
The check constraint on ``user_credentials`` independently enforces that
|
|
a local password carries a hash and an OIDC credential does not, so a
|
|
programming error here is refused by the database rather than stored.
|
|
"""
|
|
|
|
with self._connection() as connection:
|
|
updated = connection.execute(
|
|
"""
|
|
UPDATE public.user_credentials
|
|
SET credential_type = %s,
|
|
password_hash = %s,
|
|
must_change_password = %s,
|
|
failed_login_count = %s,
|
|
locked_until = %s,
|
|
last_login_at = %s,
|
|
password_changed_at = %s,
|
|
updated_at = %s
|
|
WHERE user_id = %s
|
|
RETURNING id
|
|
""",
|
|
(
|
|
credential.credential_type.value,
|
|
credential.password_hash,
|
|
credential.must_change_password,
|
|
credential.failed_login_count,
|
|
credential.locked_until,
|
|
credential.last_login_at,
|
|
credential.password_changed_at,
|
|
credential.updated_at or utc_now(),
|
|
credential.user_id,
|
|
),
|
|
).fetchone()
|
|
if updated is not None:
|
|
return credential
|
|
try:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO public.user_credentials (
|
|
id, user_id, credential_type, password_hash, must_change_password,
|
|
failed_login_count, locked_until, last_login_at, password_changed_at,
|
|
created_at, updated_at
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
credential.id,
|
|
credential.user_id,
|
|
credential.credential_type.value,
|
|
credential.password_hash,
|
|
credential.must_change_password,
|
|
credential.failed_login_count,
|
|
credential.locked_until,
|
|
credential.last_login_at,
|
|
credential.password_changed_at,
|
|
credential.created_at or utc_now(),
|
|
credential.updated_at or utc_now(),
|
|
),
|
|
)
|
|
except psycopg.errors.UniqueViolation as error:
|
|
raise ConflictError("credential_exists", "This account already has a credential.") from error
|
|
# The control plane's users.auth_provider is its own field and this
|
|
# phase does not treat it as an identity source: the canonical
|
|
# credential type lives in user_credentials. Mirroring it here keeps
|
|
# the two from disagreeing for anything that does read it.
|
|
connection.execute(
|
|
"UPDATE public.users SET auth_provider = %s WHERE id = %s",
|
|
(credential.credential_type.value, credential.user_id),
|
|
)
|
|
return credential
|
|
|
|
# -- sessions -----------------------------------------------------------
|
|
|
|
def create_session(self, session: SessionRecord) -> SessionRecord:
|
|
"""Insert a session, writing both deadline columns.
|
|
|
|
``sessions.expires_at`` predates this application and is the absolute
|
|
deadline for anything else that reads it, so it is kept equal to
|
|
``absolute_deadline_at`` rather than left to default.
|
|
"""
|
|
|
|
with self._connection() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO public.sessions (
|
|
id, user_id, token_hash, expires_at, revoked_at,
|
|
created_at, last_seen_at, idle_deadline_at, mfa_verified_at
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
session.id,
|
|
session.user_id,
|
|
session.token_hash,
|
|
session.absolute_deadline_at,
|
|
session.revoked_at,
|
|
session.created_at,
|
|
session.last_seen_at,
|
|
session.idle_deadline_at,
|
|
session.mfa_verified_at,
|
|
),
|
|
)
|
|
# users.last_login_at is the control plane's own signal; keep it in
|
|
# step so anything reading it sees the same value.
|
|
connection.execute(
|
|
"UPDATE public.users SET last_login_at = %s WHERE id = %s", (session.created_at, session.user_id)
|
|
)
|
|
return session
|
|
|
|
def get_session(self, session_id: UUID) -> SessionRecord | None:
|
|
return self._fetch_session("s.id = %s", (session_id,))
|
|
|
|
def get_session_by_token_hash(self, token_hash: str) -> SessionRecord | None:
|
|
return self._fetch_session("s.token_hash = %s", (token_hash,))
|
|
|
|
def save_session(self, session: SessionRecord) -> SessionRecord:
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
UPDATE public.sessions
|
|
SET last_seen_at = %s,
|
|
idle_deadline_at = %s,
|
|
expires_at = %s,
|
|
revoked_at = %s
|
|
WHERE id = %s
|
|
RETURNING id
|
|
""",
|
|
(
|
|
session.last_seen_at,
|
|
session.idle_deadline_at,
|
|
session.absolute_deadline_at,
|
|
session.revoked_at,
|
|
session.id,
|
|
),
|
|
).fetchone()
|
|
if row is None:
|
|
raise NotFoundError("session_not_found")
|
|
return session
|
|
|
|
def list_sessions(self, user_id: UUID, *, include_revoked: bool = False) -> list[SessionRecord]:
|
|
clause = "" if include_revoked else "AND s.revoked_at IS NULL"
|
|
with self._connection() as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT s.id, s.user_id, s.token_hash, s.created_at, s.last_seen_at,
|
|
s.idle_deadline_at, s.expires_at, s.revoked_at, s.mfa_verified_at
|
|
FROM public.sessions s
|
|
WHERE s.user_id = %s {clause}
|
|
ORDER BY s.created_at DESC
|
|
""",
|
|
(user_id,),
|
|
).fetchall()
|
|
return [_session_from_row(row) for row in rows]
|
|
|
|
def count_sessions(self, user_id: UUID) -> int:
|
|
return len(self.list_sessions(user_id))
|
|
|
|
def revoke_sessions(
|
|
self,
|
|
user_id: UUID,
|
|
*,
|
|
reason: str,
|
|
now: datetime,
|
|
exclude_session_id: UUID | None = None,
|
|
) -> int:
|
|
"""Revoke live sessions in one statement.
|
|
|
|
The ``reason`` is written into a comment-free column only: the
|
|
control-plane ``sessions`` table has no reason column, so it is returned
|
|
to the caller for the audit record rather than persisted here.
|
|
"""
|
|
|
|
clause = "AND id <> %s" if exclude_session_id is not None else ""
|
|
params: list[Any] = [now, user_id]
|
|
if exclude_session_id is not None:
|
|
params.append(exclude_session_id)
|
|
with self._connection() as connection:
|
|
rows = connection.execute(
|
|
f"""
|
|
UPDATE public.sessions
|
|
SET revoked_at = %s
|
|
WHERE user_id = %s AND revoked_at IS NULL {clause}
|
|
RETURNING id
|
|
""",
|
|
params,
|
|
).fetchall()
|
|
LOGGER.info("revoked sessions user_id=%s count=%s reason=%s", user_id, len(rows), reason)
|
|
return len(rows)
|
|
|
|
def _fetch_session(self, predicate: str, params: Sequence[Any]) -> SessionRecord | None:
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
f"""
|
|
SELECT s.id, s.user_id, s.token_hash, s.created_at, s.last_seen_at,
|
|
s.idle_deadline_at, s.expires_at, s.revoked_at, s.mfa_verified_at
|
|
FROM public.sessions s
|
|
WHERE {predicate}
|
|
""",
|
|
list(params),
|
|
).fetchone()
|
|
return _session_from_row(row) if row else None
|
|
|
|
# -- audit --------------------------------------------------------------
|
|
|
|
def append_audit_event(self, event: AuditEvent) -> AuditEvent:
|
|
"""Append one audit event.
|
|
|
|
The application mints a UUID for correlation, but ``audit_events.id`` is
|
|
a database identity, so the row identifier comes from the database. The
|
|
application UUID is preserved in ``metadata`` so a client that saw the
|
|
in-memory identifier can still correlate a test run.
|
|
"""
|
|
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
INSERT INTO public.audit_events (
|
|
actor_user_id, organization_id, action, target_type, target_id,
|
|
request_id, metadata, created_at
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s)
|
|
RETURNING id
|
|
""",
|
|
(
|
|
event.actor_user_id,
|
|
event.organization_id,
|
|
event.action,
|
|
"user" if event.target_user_id else None,
|
|
str(event.target_user_id) if event.target_user_id else None,
|
|
event.request_id,
|
|
json.dumps({**dict(event.detail), "application_event_id": str(event.id)}),
|
|
event.occurred_at,
|
|
),
|
|
).fetchone()
|
|
return _audit_from_row(row, event)
|
|
|
|
def list_audit_events(
|
|
self,
|
|
*,
|
|
organization_id: UUID | None = None,
|
|
target_user_id: UUID | None = None,
|
|
actor_user_id: UUID | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[AuditEvent], int]:
|
|
conditions: list[str] = []
|
|
params: list[Any] = []
|
|
if organization_id is not None:
|
|
conditions.append("organization_id = %s")
|
|
params.append(organization_id)
|
|
if target_user_id is not None:
|
|
# The application's target_user_id maps onto the polymorphic
|
|
# (target_type, target_id) pair the control plane uses.
|
|
conditions.append("target_type = 'user' AND target_id = %s")
|
|
params.append(str(target_user_id))
|
|
if actor_user_id is not None:
|
|
conditions.append("actor_user_id = %s")
|
|
params.append(actor_user_id)
|
|
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
|
with self._connection() as connection:
|
|
total_row = connection.execute(
|
|
f"SELECT count(*) AS total FROM public.audit_events {where}", params
|
|
).fetchone()
|
|
total = int(total_row["total"]) if total_row else 0
|
|
if total == 0:
|
|
return [], 0
|
|
rows = connection.execute(
|
|
f"""
|
|
SELECT id, actor_user_id, organization_id, action, target_type, target_id,
|
|
request_id, metadata, created_at
|
|
FROM public.audit_events
|
|
{where}
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT %s OFFSET %s
|
|
""",
|
|
[*params, max(limit, 0), max(offset, 0)],
|
|
).fetchall()
|
|
return [_audit_from_row(row, None) for row in rows], total
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Row mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _reset_connection(connection: psycopg.Connection[Any]) -> None:
|
|
"""Clear per-transaction state before a pooled connection is reused."""
|
|
|
|
connection.rollback()
|
|
|
|
|
|
def _assignment_shape(user: User) -> tuple[str, UUID | None]:
|
|
"""Return the ``user_roles`` scope and organization for a user.
|
|
|
|
A platform administrator is assigned at platform scope with no organization,
|
|
which is what the ``user_roles`` check constraint requires. Everyone else
|
|
needs an organization, because an organization-scoped assignment without one
|
|
is rejected by the same constraint.
|
|
"""
|
|
|
|
if user.role is RoleName.PLATFORM_ADMIN:
|
|
return "platform", None
|
|
if user.organization_id is None:
|
|
raise RepositoryError(
|
|
"organization_required",
|
|
"An organization-scoped role requires an organization.",
|
|
)
|
|
return "organization", user.organization_id
|
|
|
|
|
|
def _resolve_assignment(connection: psycopg.Connection[Any], user_id: UUID) -> tuple[RoleName, UUID | None]:
|
|
"""Resolve one role and organization for a user, or refuse.
|
|
|
|
Phase 1 can represent exactly one active assignment. A user with more than
|
|
one active row is refused rather than resolved to an arbitrary row: the
|
|
organization on the returned assignment becomes the scope of every
|
|
administrative decision, so guessing would hand out the wrong tenant's
|
|
authority. Reconciling this with project-scoped membership is the open
|
|
review question recorded in ``migrations/README.md``.
|
|
"""
|
|
|
|
rows = connection.execute(
|
|
"""
|
|
SELECT r.name, ur.organization_id, ur.scope
|
|
FROM public.user_roles ur
|
|
JOIN public.roles r ON r.id = ur.role_id
|
|
WHERE ur.user_id = %s AND ur.status = 'active'
|
|
ORDER BY ur.scope, ur.created_at, ur.id
|
|
""",
|
|
(user_id,),
|
|
).fetchall()
|
|
if not rows:
|
|
raise RepositoryError(
|
|
"no_active_role",
|
|
"The account has no active role assignment, so it cannot be authorized.",
|
|
)
|
|
if len(rows) > 1:
|
|
raise RepositoryError(
|
|
"ambiguous_role_assignment",
|
|
"The account holds more than one active role assignment, which this phase cannot represent.",
|
|
)
|
|
row = rows[0]
|
|
try:
|
|
role = RoleName(row["name"])
|
|
except ValueError as error:
|
|
raise RepositoryError("unknown_role", "The account has a role this application does not model.") from error
|
|
return role, row["organization_id"]
|
|
|
|
|
|
def _user_with_assignment(connection: psycopg.Connection[Any], row: Mapping[str, Any]) -> User:
|
|
role, organization_id = _resolve_assignment(connection, row["id"])
|
|
return _user_from_row(row, role=role, organization_id=organization_id)
|
|
|
|
|
|
def _user_from_row(
|
|
row: Mapping[str, Any], *, role: RoleName | None = None, organization_id: UUID | None = None
|
|
) -> User:
|
|
if row.get("mfa_required"):
|
|
# The control plane can mark an account as requiring a second factor.
|
|
# This application cannot satisfy that, so it refuses rather than
|
|
# authenticating someone who was supposed to present a second factor.
|
|
# The deployment gate is that no row carries mfa_required = true until
|
|
# MFA is implemented; see migrations/README.md.
|
|
raise RepositoryError(
|
|
"mfa_required_unsupported",
|
|
"The account requires multi-factor authentication, which this application does not implement.",
|
|
)
|
|
status = row["status"]
|
|
if status not in _ACCOUNT_STATUSES:
|
|
# 'invited' and 'locked' exist in the control plane but are not states
|
|
# this application models. Failing closed beats mapping one of them
|
|
# onto 'active' by accident.
|
|
raise RepositoryError(
|
|
"unmapped_account_status",
|
|
"The account has a status this application does not model.",
|
|
)
|
|
return User(
|
|
id=row["id"],
|
|
username=row["username"],
|
|
username_key=username_key(row["username"]),
|
|
display_name=row["display_name"] or row["username"],
|
|
role=role if role is not None else RoleName.MEMBER,
|
|
status=_ACCOUNT_STATUSES[status],
|
|
organization_id=organization_id,
|
|
email=row["email"],
|
|
created_at=row["created_at"],
|
|
updated_at=row["updated_at"],
|
|
)
|
|
|
|
|
|
def _organization_from_row(row: Mapping[str, Any]) -> Organization:
|
|
return Organization(
|
|
id=row["id"],
|
|
name=row["name"],
|
|
slug=row["slug"],
|
|
status=OrganizationStatus(row["status"]),
|
|
created_at=row["created_at"],
|
|
updated_at=row["updated_at"],
|
|
)
|
|
|
|
|
|
def _credential_from_row(row: Mapping[str, Any]) -> CredentialRecord:
|
|
return CredentialRecord(
|
|
id=row["id"],
|
|
user_id=row["user_id"],
|
|
credential_type=CredentialType(row["credential_type"]),
|
|
password_hash=row["password_hash"],
|
|
must_change_password=row["must_change_password"],
|
|
failed_login_count=row["failed_login_count"],
|
|
locked_until=row["locked_until"],
|
|
last_login_at=row["last_login_at"],
|
|
password_changed_at=row["password_changed_at"],
|
|
created_at=row["created_at"],
|
|
updated_at=row["updated_at"],
|
|
)
|
|
|
|
|
|
def _session_from_row(row: Mapping[str, Any]) -> SessionRecord:
|
|
return SessionRecord(
|
|
id=row["id"],
|
|
user_id=row["user_id"],
|
|
token_hash=row["token_hash"],
|
|
created_at=row["created_at"],
|
|
last_seen_at=row["last_seen_at"],
|
|
idle_deadline_at=row["idle_deadline_at"],
|
|
absolute_deadline_at=row["expires_at"],
|
|
revoked_at=row["revoked_at"],
|
|
mfa_verified_at=row["mfa_verified_at"],
|
|
)
|
|
|
|
|
|
def _audit_from_row(row: Mapping[str, Any], original: AuditEvent | None) -> AuditEvent:
|
|
"""Map an ``audit_events`` row, keeping the store's identifier authoritative.
|
|
|
|
The application mints a UUID for correlation and the store mints a bigint
|
|
identity. The store's value is what the API reports, and the application
|
|
UUID is preserved in ``detail`` so a caller that saw it on a write can
|
|
still correlate a test run.
|
|
"""
|
|
|
|
metadata = row.get("metadata") or {}
|
|
if isinstance(metadata, str):
|
|
metadata = json.loads(metadata)
|
|
metadata = dict(metadata)
|
|
detail = {key: str(value) for key, value in metadata.items() if key != "application_event_id"}
|
|
return AuditEvent(
|
|
id=str(row["id"]),
|
|
occurred_at=row["created_at"],
|
|
action=row["action"],
|
|
outcome=(original.outcome if original is not None else str(metadata.get("outcome", "success"))),
|
|
actor_user_id=row["actor_user_id"],
|
|
target_user_id=(row["target_id"] if row["target_type"] == "user" else None),
|
|
organization_id=row["organization_id"],
|
|
request_id=row["request_id"],
|
|
detail=detail,
|
|
)
|
|
|
|
|
|
def _unique_conflict(error: Exception, username: str) -> ConflictError:
|
|
"""Map a unique violation onto the right domain conflict.
|
|
|
|
``users`` has two unique constraints that matter here: the username added by
|
|
the phase-1 migration and the pre-existing email. The constraint name is
|
|
used rather than parsing the driver message, which is not stable.
|
|
"""
|
|
|
|
diagnostic = getattr(error, "diag", None)
|
|
constraint = getattr(diagnostic, "constraint_name", "") or ""
|
|
if "email" in constraint:
|
|
return ConflictError("email_taken", "That email address is already in use.")
|
|
return ConflictError("username_taken", "That username is already in use.")
|
|
|
|
|
|
__all__ = ["PostgresAccountRepository", "RLS_USER_SETTING", "STATEMENT_TIMEOUT_MS"]
|