Every project statement now says `projects` unqualified and takes its
organization through search_path. A shared table filtered by
`organization_id = %s` is a filter somebody has to remember, and there is always
a write path that would not have it; an unqualified name resolves into the
caller's own schema or it does not resolve at all. The strongest possible
mistake - forgetting to scope a query - now raises instead of returning a wider
result set.
Project.organization_id survives in the model and is filled in from the schema
the answer came from. Callers still see which tenant a row belongs to; it is
derived from where the row is rather than from what the row says about itself.
Two consequences that are worth more than the mechanical change:
- assign_user_to_project now takes the organization as an argument. It used to
read it off the project row and copy it into user_roles, and there is no
column to read now. user_roles is platform-level, so it cannot be written to
by looking inside a tenant schema - so the caller, which has already resolved
the project, has to say it. The protocol declares it, and the in-memory store
accepts and ignores it rather than being the one implementation with a
different signature.
- A platform administrator listing projects without naming an organization now
gets the answer assembled across tenant schemas, because there is no shared
table to ask. That is one round trip per organization and it is paid only by
that one caller; every per-tenant path, which is what an ordinary request
does, is still a single statement. The merge reproduces the ordering and the
total a shared table would have given, so paging across the boundary is the
same as paging inside one.
1767 lines
76 KiB
Python
1767 lines
76 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, and two
|
|
things are published onto it before the statement runs.
|
|
|
|
``SET LOCAL app.user_id`` carries the caller's identity, so the row-level
|
|
security policies that still guard the platform tables 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.
|
|
|
|
``SET LOCAL search_path`` carries the caller's **organization**, resolved into
|
|
the schema that holds its tables. That is the isolation model: one schema per
|
|
organization, no table of tenant data shared, so an organization's ``projects``
|
|
table is found by being in the search path rather than by being filtered by a
|
|
predicate. A statement with no organization in hand gets ``public`` alone, and
|
|
therefore finds no tenant table at all.
|
|
|
|
Both are set with ``set_config``, which takes bind parameters. ``SET`` itself
|
|
takes none - psycopg renders them as ``$1`` and the grammar rejects that.
|
|
|
|
Until the policies are applied to a non-owner role the ``app.user_id`` setting 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
|
|
enforce scope today, and the ``search_path`` enforces the schema boundary.
|
|
"""
|
|
|
|
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, uuid4
|
|
|
|
import psycopg
|
|
from psycopg.rows import dict_row
|
|
from psycopg_pool import ConnectionPool
|
|
|
|
from .errors import ConflictError, InputError, NotFoundError, RepositoryError
|
|
from .models import (
|
|
VERIFICATION_PURPOSE_EMAIL,
|
|
AccountStatus,
|
|
AuditEvent,
|
|
CredentialRecord,
|
|
CredentialType,
|
|
Organization,
|
|
OrganizationStatus,
|
|
Project,
|
|
ProjectStatus,
|
|
RoleName,
|
|
SessionRecord,
|
|
User,
|
|
VerificationToken,
|
|
normalize_email,
|
|
username_key,
|
|
)
|
|
from .repository import AccountRepository
|
|
from .security import utc_now
|
|
from .tenancy import tenant_schema_name, tenant_search_path
|
|
|
|
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 the row-level-security policy helpers read.
|
|
#:
|
|
#: This name is not a guess and it is not cosmetic. Every policy helper in the
|
|
#: control plane resolves the caller through ``app.current_user_id()``, and that
|
|
#: function is defined as ``current_setting('app.user_id', true)``. Setting a
|
|
#: similarly named GUC instead leaves every helper resolving to NULL, which makes
|
|
#: every policy return false and every query return zero rows: a silent total
|
|
#: lockout that looks like an empty database rather than an error.
|
|
#: :meth:`PostgresAccountRepository.verify_rls_contract` proves the two agree at
|
|
#: startup, for exactly that reason.
|
|
RLS_USER_SETTING = "app.user_id"
|
|
|
|
#: The function the policies call to resolve the caller.
|
|
RLS_USER_FUNCTION = "app.current_user_id"
|
|
|
|
#: The GUC that selects which tenant schema a statement sees.
|
|
#:
|
|
#: Named here rather than interpolated because this is the one setting a
|
|
#: statement's *target* depends on: `search_path` is the only way a single
|
|
#: statement template can reach a table whose address is not known until the
|
|
#: caller's organization has been resolved. Everything else in this adapter
|
|
#: names `public.` explicitly, because the platform tables have one address.
|
|
SEARCH_PATH_SETTING = "search_path"
|
|
|
|
#: The function the migration uses to name a tenant schema, and the one this
|
|
#: adapter is checked against at startup.
|
|
TENANT_SCHEMA_FUNCTION = "app.tenant_schema_name"
|
|
|
|
#: 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
|
|
|
|
def verify_tenant_schema_contract(self) -> None:
|
|
"""Prove the adapter and the database agree on a tenant schema's name.
|
|
|
|
The name is derived from the organization id in two languages:
|
|
``umbratrans_web.tenancy.tenant_schema_name`` for the connection, and
|
|
``app.tenant_schema_name`` for the migration that created the schemas.
|
|
Nothing in SQL joins the two together, and the failure mode of a
|
|
disagreement is silent and total - every statement would resolve into a
|
|
schema that does not exist, so every project request for every
|
|
organization would raise "relation does not exist", and the error would
|
|
name the statement rather than the cause. That reads as an empty
|
|
database, which is the one diagnosis nobody reaches for.
|
|
|
|
So this asks the database what it calls one organization's schema and
|
|
refuses to serve unless it is what this adapter would have chosen. Run at
|
|
startup, for the same reason :meth:`verify_rls_contract` is.
|
|
"""
|
|
|
|
probe = uuid4()
|
|
expected = tenant_schema_name(probe)
|
|
with self._connection() as connection:
|
|
row = connection.execute(f"SELECT {TENANT_SCHEMA_FUNCTION}(%s) AS resolved", (probe,)).fetchone()
|
|
resolved = row["resolved"] if row is not None else None
|
|
if resolved != expected:
|
|
raise RepositoryError(
|
|
"tenant_schema_mismatch",
|
|
"The database derives a different tenant schema name from this adapter, so every "
|
|
"tenant table would be looked for in a schema that does not exist. Refusing to "
|
|
"start.",
|
|
)
|
|
LOGGER.info("tenant schema naming contract verified")
|
|
|
|
def verify_rls_contract(self) -> None:
|
|
"""Prove the adapter and the policy helpers agree on the caller's identity.
|
|
|
|
The policies resolve the caller by calling ``app.current_user_id()``.
|
|
The adapter publishes that identity as a session GUC. Nothing in the SQL
|
|
connects the two, so a mismatch is possible and the failure mode is
|
|
silent: every policy evaluates false, every query returns zero rows, and
|
|
the service reports an empty database rather than an error.
|
|
|
|
So this runs a real probe: set the GUC, ask the policy function what it
|
|
sees, and refuse to continue unless the two agree. Called at startup,
|
|
because discovering this in production as a total lockout is far worse
|
|
than refusing to start.
|
|
"""
|
|
|
|
probe = uuid4()
|
|
with self._connection() as connection:
|
|
connection.execute("SELECT set_config(%s, %s, true)", (RLS_USER_SETTING, str(probe)))
|
|
row = connection.execute(f"SELECT {RLS_USER_FUNCTION}() AS resolved").fetchone()
|
|
resolved = row["resolved"] if row is not None else None
|
|
if resolved != probe:
|
|
raise RepositoryError(
|
|
"rls_identity_mismatch",
|
|
"The row-level-security helper does not read the session setting this "
|
|
"adapter publishes, so every policy would deny access. Refusing to start.",
|
|
)
|
|
LOGGER.info("row-level-security identity contract verified setting=%s", RLS_USER_SETTING)
|
|
|
|
# -- transaction plumbing ----------------------------------------------
|
|
|
|
@contextmanager
|
|
def _connection(
|
|
self, *, user_id: UUID | None = None, organization_id: UUID | None = None
|
|
) -> Iterator[psycopg.Connection[Any]]:
|
|
"""Yield a connection inside one transaction, publishing caller and tenant.
|
|
|
|
``organization_id`` is the isolation model. It is resolved into a schema
|
|
name by :func:`umbratrans_web.tenancy.tenant_search_path` and set as
|
|
``SET LOCAL search_path``, so an unqualified ``projects`` in this
|
|
transaction means *this organization's* projects, and there is no
|
|
unqualified ``projects`` at all when no organization was given.
|
|
|
|
Both settings are ``SET LOCAL`` and therefore transaction-scoped, which is
|
|
what makes connection pooling safe: a returned connection cannot carry the
|
|
previous request's identity or the previous request's tenant.
|
|
|
|
The search path is set on **every** transaction, including the ones with
|
|
no organization, where it becomes ``public`` alone. Leaving it alone
|
|
instead would let the connection's own default apply, and a default that
|
|
happened to name a tenant schema would be a silent leak with no code
|
|
anywhere mentioning it.
|
|
"""
|
|
|
|
# ``SET`` is a utility statement and takes no bind parameters at all -
|
|
# psycopg renders them as ``$1`` and the grammar rejects that. The first
|
|
# statement therefore carries a literal. It is coerced to ``int`` here so
|
|
# the interpolation cannot carry SQL, and the value is a module constant
|
|
# anyway. Verified against a real server, not assumed.
|
|
timeout_ms = int(STATEMENT_TIMEOUT_MS)
|
|
search_path = tenant_search_path(organization_id)
|
|
with self._pool.connection() as connection:
|
|
with connection.transaction():
|
|
connection.execute(f"SET LOCAL statement_timeout = {timeout_ms}")
|
|
connection.execute("SET LOCAL idle_in_transaction_session_timeout = '10s'")
|
|
if user_id is not None:
|
|
# ``set_config`` is the parameterisable form and the right way
|
|
# to set a namespaced GUC, so the identity stays a bound value.
|
|
# The third argument is ``true`` for local scope, which is what
|
|
# makes pooling safe: the identity is discarded when the
|
|
# transaction ends, so a returned connection cannot carry the
|
|
# previous request's caller.
|
|
connection.execute(
|
|
"SELECT set_config(%s, %s, true)",
|
|
(RLS_USER_SETTING, str(user_id)),
|
|
)
|
|
connection.execute("SELECT set_config(%s, %s, true)", (SEARCH_PATH_SETTING, search_path))
|
|
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_email(self, key: str) -> User | None:
|
|
"""Look up an account by its email address. This is the login path.
|
|
|
|
The column comparison is left to the database. ``users.email`` is
|
|
``citext``, so equality is already case-insensitive there, which means
|
|
case-insensitivity does not depend on this process normalising
|
|
correctly. The value is still normalised, because a malformed address
|
|
cannot match any row and rejecting it here avoids a pointless query.
|
|
|
|
Validation failure is "no such account", not an error: the login form
|
|
must not tell an attacker which addresses exist.
|
|
"""
|
|
|
|
try:
|
|
candidate = normalize_email(key)
|
|
except InputError:
|
|
return None
|
|
return self._fetch_user("u.email = %s", (candidate,))
|
|
|
|
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 count_users_with_role(self, role: RoleName) -> int:
|
|
"""Count active assignments of one role.
|
|
|
|
Counts ``user_roles`` rather than ``users``, because a role lives on the
|
|
assignment and not on the profile. The join is left inner on purpose: a
|
|
user with no resolvable assignment cannot administer anything, so it must
|
|
not satisfy a check that asks whether an administrator exists.
|
|
|
|
This is what decides whether first-run setup is still required, so it runs
|
|
on every health probe. That makes it the one query here that cannot be
|
|
left broken without taking the whole deployment's health check with it.
|
|
"""
|
|
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
f"""
|
|
SELECT count(*) AS total
|
|
FROM public.user_roles ur
|
|
JOIN public.roles r ON r.id = ur.role_id
|
|
WHERE r.name = %s
|
|
AND ur.status = 'active'
|
|
AND {TENANT_ROW_PREDICATE}
|
|
""",
|
|
(str(role),),
|
|
).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(
|
|
f"EXISTS (SELECT 1 FROM public.user_roles ur WHERE ur.user_id = u.id "
|
|
f"AND ur.organization_id = %s AND ur.status = 'active' AND {TENANT_ROW_PREDICATE})"
|
|
)
|
|
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(
|
|
f"EXISTS (SELECT 1 FROM public.user_roles ur JOIN public.roles r ON r.id = ur.role_id "
|
|
f"WHERE ur.user_id = u.id AND r.name = %s AND ur.status = 'active' AND {TENANT_ROW_PREDICATE})"
|
|
)
|
|
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 "
|
|
f"WHERE ur.user_id = u.id AND ur.organization_id = %s "
|
|
f"AND ur.status = 'active' AND {TENANT_ROW_PREDICATE})",
|
|
[organization_id],
|
|
)
|
|
|
|
# -- projects ------------------------------------------------------------
|
|
#
|
|
# Every statement below names `projects` **unqualified**, and the organization
|
|
# reaches the right one through `search_path` rather than through a predicate.
|
|
# That is deliberate and it is the whole point: a shared table filtered by
|
|
# `organization_id = %s` is a filter somebody has to remember, and there is
|
|
# always a write path that would not have it. An unqualified name resolves into
|
|
# the caller's own schema or it does not resolve at all.
|
|
#
|
|
# `Project.organization_id` therefore comes from the schema the row was found
|
|
# in rather than from a column. It is still filled in - the model and the API
|
|
# both carry it, and a caller needs to be able to see which tenant a row
|
|
# belongs to - but it is derived from where the row is rather than from what
|
|
# the row says about itself.
|
|
|
|
def create_project(self, project: Project) -> Project:
|
|
"""Insert into the organization's own schema.
|
|
|
|
The organization comes from the row: a project cannot be created without
|
|
one, so there is nothing to ask for separately.
|
|
"""
|
|
|
|
with self._connection(organization_id=project.organization_id) as connection:
|
|
try:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO projects (
|
|
id, name, slug, status, created_by, created_at, updated_at
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
""",
|
|
(
|
|
project.id,
|
|
project.name,
|
|
project.slug,
|
|
str(project.status),
|
|
project.created_by,
|
|
project.created_at,
|
|
project.updated_at or project.created_at,
|
|
),
|
|
)
|
|
except psycopg.errors.UniqueViolation as error:
|
|
raise _unique_conflict(error, project.slug) from None
|
|
return project
|
|
|
|
def get_project(self, project_id: UUID, *, organization_id: UUID | None = None) -> Project | None:
|
|
"""Fetch one project.
|
|
|
|
With an organization, the lookup runs in that organization's schema, so a
|
|
project belonging to another tenant is not merely filtered out of a result
|
|
set: there is no row to read. A project identifier is unique on its own, so
|
|
an unscoped lookup against a shared table would fetch another tenant's row
|
|
and then decide it was not the caller's - a read that should never have
|
|
happened, and one that a later change to the check could turn into a leak.
|
|
|
|
Without one - a platform administrator who has not named an organization -
|
|
there is no single schema to look in, so the tenant schemas are searched in
|
|
turn. That is a platform operation by definition: the caller is asking for
|
|
"any project's", and there is no statement that can answer it without
|
|
naming every organization.
|
|
"""
|
|
|
|
if organization_id is not None:
|
|
with self._connection(organization_id=organization_id) as connection:
|
|
row = connection.execute(
|
|
f"SELECT {_PROJECT_COLUMNS} FROM projects p WHERE p.id = %s", (project_id,)
|
|
).fetchone()
|
|
if row is not None:
|
|
return _project_from_row(row, organization_id=organization_id)
|
|
return None
|
|
for candidate in self._organizations_with_tenant_schema():
|
|
with self._connection(organization_id=candidate) as connection:
|
|
row = connection.execute(
|
|
f"SELECT {_PROJECT_COLUMNS} FROM projects p WHERE p.id = %s", (project_id,)
|
|
).fetchone()
|
|
if row is not None:
|
|
return _project_from_row(row, organization_id=candidate)
|
|
return None
|
|
|
|
def get_project_by_slug(self, slug: str, *, organization_id: UUID) -> Project | None:
|
|
"""Fetch by handle, case-insensitively.
|
|
|
|
The comparison is left to the ``citext`` column rather than casting it to
|
|
``text``. ``slug::text = %s`` is an ordinary text comparison, so it is
|
|
case-*sensitive*, and ``/Book-One`` would fail to find ``book-one`` while
|
|
appearing to work for every other store in the system. The cast looks like
|
|
a type accommodation for the parameter and is in fact a silent change of
|
|
behaviour.
|
|
|
|
The organization is no longer part of the predicate, because it is part of
|
|
the schema: the same slug in another tenant is a different table.
|
|
"""
|
|
|
|
with self._connection(organization_id=organization_id) as connection:
|
|
row = connection.execute(
|
|
f"SELECT {_PROJECT_COLUMNS} FROM projects p WHERE p.slug = %s",
|
|
(str(slug),),
|
|
).fetchone()
|
|
return _project_from_row(row, organization_id=organization_id) if row else None
|
|
|
|
def save_project(self, project: Project) -> Project:
|
|
with self._connection(organization_id=project.organization_id) as connection:
|
|
row = connection.execute(
|
|
"""
|
|
UPDATE projects
|
|
SET name = %s, slug = %s, status = %s, updated_at = %s
|
|
WHERE id = %s
|
|
RETURNING id
|
|
""",
|
|
(
|
|
project.name,
|
|
project.slug,
|
|
str(project.status),
|
|
project.updated_at or utc_now(),
|
|
project.id,
|
|
),
|
|
).fetchone()
|
|
if row is None:
|
|
raise NotFoundError("project_not_found")
|
|
return project
|
|
|
|
def find_projects(
|
|
self,
|
|
*,
|
|
organization_id: UUID | None = None,
|
|
statuses: Sequence[ProjectStatus] | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[Project], int]:
|
|
"""List projects, from one tenant's schema or from all of them.
|
|
|
|
The scoped form is one statement in one schema. The unscoped form - a
|
|
platform administrator who has not named an organization - is assembled
|
|
across tenant schemas, because there is no shared table to ask.
|
|
"""
|
|
|
|
if organization_id is None:
|
|
return self._find_projects_across_tenants(statuses=statuses, limit=limit, offset=offset)
|
|
|
|
conditions: list[str] = []
|
|
params: list[Any] = []
|
|
if statuses:
|
|
conditions.extend(_status_conditions(statuses))
|
|
params.extend(_status_params(statuses))
|
|
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
|
window = max(offset, 0) + max(limit, 0)
|
|
with self._connection(organization_id=organization_id) as connection:
|
|
total_row = connection.execute(f"SELECT count(*) AS total FROM projects p {where}", params).fetchone()
|
|
total = int(total_row["total"]) if total_row else 0
|
|
if total == 0:
|
|
return [], 0
|
|
rows = connection.execute(
|
|
f"SELECT {_PROJECT_COLUMNS} FROM projects p {where} "
|
|
"ORDER BY p.created_at DESC, p.id LIMIT %s OFFSET %s",
|
|
[*params, window, max(offset, 0)],
|
|
).fetchall()
|
|
return [_project_from_row(row, organization_id=organization_id) for row in rows], total
|
|
|
|
def _find_projects_across_tenants(
|
|
self,
|
|
*,
|
|
statuses: Sequence[ProjectStatus] | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[Project], int]:
|
|
"""The platform-wide listing, assembled one tenant schema at a time.
|
|
|
|
Each tenant contributes its own total and its own first ``offset + limit``
|
|
rows; merging those and slicing gives the same answer a single shared table
|
|
would have. Asking each tenant for more than that is unnecessary, because a
|
|
row outside its tenant's first window cannot be inside the global one.
|
|
|
|
That is one round trip per organization, which at a hundred organizations
|
|
is a hundred round trips. It is the price of not sharing the table, and it
|
|
is paid only by a platform administrator listing across every tenant - the
|
|
per-tenant path above, which is everything an ordinary request does, is one
|
|
statement.
|
|
"""
|
|
|
|
window = max(offset, 0) + max(limit, 0)
|
|
collected: list[tuple[datetime, str, Project]] = []
|
|
total = 0
|
|
for candidate in self._organizations_with_tenant_schema():
|
|
conditions: list[str] = []
|
|
params: list[Any] = []
|
|
if statuses:
|
|
conditions.extend(_status_conditions(statuses))
|
|
params.extend(_status_params(statuses))
|
|
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
|
with self._connection(organization_id=candidate) as connection:
|
|
total_row = connection.execute(f"SELECT count(*) AS total FROM projects p {where}", params).fetchone()
|
|
total += int(total_row["total"]) if total_row else 0
|
|
rows = connection.execute(
|
|
f"SELECT {_PROJECT_COLUMNS} FROM projects p {where} ORDER BY p.created_at DESC, p.id LIMIT %s",
|
|
[*params, window],
|
|
).fetchall()
|
|
collected.extend(
|
|
(row["created_at"], str(row["id"]), _project_from_row(row, organization_id=candidate))
|
|
for row in rows
|
|
)
|
|
# The ordering a shared table would have given: newest first, identifier
|
|
# breaking ties, so a page boundary does not depend on which tenant
|
|
# answered first.
|
|
collected.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
|
page = [project for _, _, project in collected][max(offset, 0) : max(offset, 0) + max(limit, 0)]
|
|
return page, total
|
|
|
|
def count_projects(self, *, organization_id: UUID | None = None) -> int:
|
|
if organization_id is None:
|
|
return sum(
|
|
self.count_projects(organization_id=candidate)
|
|
for candidate in self._organizations_with_tenant_schema()
|
|
)
|
|
with self._connection(organization_id=organization_id) as connection:
|
|
row = connection.execute("SELECT count(*) AS total FROM projects").fetchone()
|
|
return int(row["total"]) if row else 0
|
|
|
|
def _organizations_with_tenant_schema(self) -> list[UUID]:
|
|
"""Every organization that has a tenant schema, for the cross-tenant paths.
|
|
|
|
The join against ``pg_namespace`` is not decoration. An organization can
|
|
exist without a schema if it was inserted by something that bypassed the
|
|
trigger, and a cross-tenant listing that pointed at a missing schema would
|
|
fail on that one organization and return nothing for any of them - a single
|
|
half-provisioned tenant taking the whole platform view down with it.
|
|
"""
|
|
|
|
with self._connection() as connection:
|
|
rows = connection.execute(
|
|
"SELECT o.id FROM public.organizations o "
|
|
"JOIN pg_namespace n ON n.nspname = app.tenant_schema_name(o.id) "
|
|
"ORDER BY o.id"
|
|
).fetchall()
|
|
return [row["id"] for row in rows]
|
|
|
|
def list_project_member_ids(self, project_id: UUID) -> list[UUID]:
|
|
"""Project-scoped assignments, from the same table the roles live in.
|
|
|
|
Scoped to ``scope = 'project'`` because a user's organization-scoped
|
|
assignment must not be read as membership of every project in the tenant.
|
|
That is the mistake that would make one assignment grant project work.
|
|
"""
|
|
|
|
with self._connection() as connection:
|
|
rows = connection.execute(
|
|
"SELECT user_id FROM public.user_roles "
|
|
"WHERE project_id = %s AND scope = 'project' AND status = 'active' "
|
|
"ORDER BY created_at, user_id",
|
|
(project_id,),
|
|
).fetchall()
|
|
return [row["user_id"] for row in rows]
|
|
|
|
#: The project-scoped role an assignment grants.
|
|
#:
|
|
#: There is no ``member`` role at project scope. The control plane seeds
|
|
#: ``translator``, ``proofreader``, ``project_manager``, ``project_owner``,
|
|
#: ``release_manager``, ``audio_director``, and ``viewer`` - all scoped to
|
|
#: ``project`` - and those name jobs rather than participation. ``viewer`` is
|
|
#: used because it is the least privileged of them and is the only one that
|
|
#: claims nothing about what the assignee is doing there.
|
|
#:
|
|
#: This is provisional and deliberately so. An assignment currently means "is
|
|
#: working on this", and the workflow that would give it a job title is not
|
|
#: defined yet. When it is, this constant is where the role changes, and the
|
|
#: alternative to changing it is a migration - so it is named here rather than
|
|
#: buried in a query.
|
|
PROJECT_ASSIGNMENT_ROLE = "viewer"
|
|
|
|
def assign_user_to_project(self, project_id: UUID, user_id: UUID, *, organization_id: UUID) -> bool:
|
|
"""Add a project-scoped assignment, or report that there already is one.
|
|
|
|
Idempotent rather than erroring, because assigning the same account twice
|
|
is a no-op an administrator can reasonably make. The insert is conditional
|
|
so two concurrent requests cannot both add one.
|
|
|
|
``organization_id`` is required here rather than derived, and that is a
|
|
consequence of the isolation model rather than a preference. This statement
|
|
used to read ``p.organization_id`` off the project row and copy it into
|
|
``user_roles``. There is no column to read now - the schema the project was
|
|
found in *is* the organization - and ``user_roles`` is a platform table, so
|
|
it cannot be written to by looking inside a tenant schema. The caller has
|
|
already resolved the project and therefore already knows.
|
|
"""
|
|
|
|
with self._connection(organization_id=organization_id) as connection:
|
|
row = connection.execute(
|
|
"""
|
|
INSERT INTO public.user_roles (user_id, role_id, scope, organization_id, project_id, status)
|
|
SELECT %s, r.id, 'project', %s, p.id, 'active'
|
|
FROM projects p
|
|
JOIN public.roles r ON r.name = %s AND r.scope = 'project'
|
|
WHERE p.id = %s
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id
|
|
""",
|
|
(user_id, organization_id, self.PROJECT_ASSIGNMENT_ROLE, project_id),
|
|
).fetchone()
|
|
if row is not None:
|
|
return True
|
|
# Nothing inserted: either it was already there, or the project has no
|
|
# member-scoped project role seeded, or the project does not exist.
|
|
existing = connection.execute(
|
|
"SELECT 1 AS present FROM public.user_roles "
|
|
"WHERE user_id = %s AND project_id = %s AND scope = 'project' AND status = 'active'",
|
|
(user_id, project_id),
|
|
).fetchone()
|
|
if existing is not None:
|
|
return False
|
|
project_row = connection.execute("SELECT 1 AS present FROM projects WHERE id = %s", (project_id,)).fetchone()
|
|
if project_row is None:
|
|
raise NotFoundError("project_not_found")
|
|
raise RepositoryError(
|
|
"project_role_not_seeded",
|
|
f"No project-scoped {self.PROJECT_ASSIGNMENT_ROLE!r} role is present in the roles table.",
|
|
)
|
|
|
|
def unassign_user_from_project(self, project_id: UUID, user_id: UUID) -> bool:
|
|
with self._connection() as connection:
|
|
rows = connection.execute(
|
|
"DELETE FROM public.user_roles "
|
|
"WHERE user_id = %s AND project_id = %s AND scope = 'project' AND status = 'active' "
|
|
"RETURNING id",
|
|
(user_id, project_id),
|
|
).fetchall()
|
|
return bool(rows)
|
|
|
|
# -- 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 account's *tenant* assignment match the requested role and organization.
|
|
|
|
One role and one organization per account, so the target state is fully
|
|
determined.
|
|
|
|
Only tenant rows are read and written. An account assigned to a project
|
|
holds a second active ``user_roles`` row that says who is working on what;
|
|
it is not a second tenancy, and it must survive this call untouched.
|
|
Counting it would make every role change on an assigned member fail, and
|
|
a narrow ``UPDATE`` that reached it would strip the assignment while
|
|
appearing to change a role.
|
|
|
|
Several *tenant* rows are still refused, on the same grounds as
|
|
:func:`_resolve_assignment`: this path will not collapse them silently.
|
|
"""
|
|
|
|
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 ur "
|
|
f"WHERE ur.user_id = %s AND ur.status = 'active' AND {TENANT_ROW_PREDICATE}",
|
|
(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
|
|
|
|
# -- verification tokens ------------------------------------------------
|
|
|
|
def create_verification_token(self, token: VerificationToken) -> VerificationToken:
|
|
"""Store a single-use token in ``bootstrap_tokens``.
|
|
|
|
That table predates this application and was unused, which is why email
|
|
validation needs no migration. It has no ``purpose`` column, so every row
|
|
in it is treated as an email-verification token. Phase 1 issues no other
|
|
kind, and if a second purpose is ever added the column has to be added
|
|
with it: without a purpose in the row, a token issued for one purpose
|
|
would be honoured for another. :func:`_verification_from_row` therefore
|
|
always reports the email-verification purpose rather than reading one.
|
|
"""
|
|
|
|
with self._connection() as connection:
|
|
connection.execute(
|
|
"""
|
|
INSERT INTO public.bootstrap_tokens (
|
|
id, user_id, token_hash, expires_at, consumed_at, created_at, purpose
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
ON CONFLICT (id) DO NOTHING
|
|
""",
|
|
(
|
|
token.id,
|
|
token.user_id,
|
|
token.token_hash,
|
|
token.expires_at,
|
|
token.consumed_at,
|
|
token.created_at,
|
|
token.purpose,
|
|
),
|
|
)
|
|
return token
|
|
|
|
def get_live_verification_token(
|
|
self,
|
|
token_hash: str,
|
|
*,
|
|
purpose: str = VERIFICATION_PURPOSE_EMAIL,
|
|
now: datetime | None = None,
|
|
) -> VerificationToken | None:
|
|
"""Return an unconsumed, unexpired token for this hash *and purpose*.
|
|
|
|
Both are matched in the query rather than checked afterwards. A
|
|
password-reset token and an email-verification token are both a hash
|
|
against a user, so a lookup that ignored purpose would accept one where
|
|
the other is required: a verification link would set a password, and a
|
|
reset link would confirm an address. The ``purpose`` column added by
|
|
migration 0002 exists for this.
|
|
|
|
Expiry is filtered in SQL rather than in Python so a token that expired
|
|
while the process was down is still refused, and so the decision is made
|
|
against the same clock the row was written with.
|
|
|
|
``now`` is accepted and deliberately unused. The in-memory repository has
|
|
to be told what time it is, and the two have to agree about the contract,
|
|
but here the comparison is ``expires_at > now()`` inside the query: the
|
|
database's clock, against the database's own timestamp. Substituting the
|
|
application's clock would make a token appear live or dead depending on
|
|
which repository answered, which is the divergence this whole argument
|
|
exists to prevent.
|
|
"""
|
|
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
SELECT id, user_id, token_hash, expires_at, consumed_at, created_at, purpose
|
|
FROM public.bootstrap_tokens
|
|
WHERE token_hash = %s
|
|
AND purpose = %s
|
|
AND consumed_at IS NULL
|
|
AND expires_at > now()
|
|
""",
|
|
(token_hash, purpose),
|
|
).fetchone()
|
|
return _verification_from_row(row) if row else None
|
|
|
|
def mark_verification_token_consumed(self, token_id: UUID, *, now: datetime) -> bool:
|
|
"""Consume a token, returning whether this call was the one that did it.
|
|
|
|
The update carries ``consumed_at IS NULL`` in its predicate, so the
|
|
database decides the winner. Two simultaneous requests cannot both
|
|
observe success, which is what makes the link single-use; deciding this in
|
|
Python after a read would not.
|
|
"""
|
|
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
UPDATE public.bootstrap_tokens
|
|
SET consumed_at = %s
|
|
WHERE id = %s
|
|
AND consumed_at IS NULL
|
|
RETURNING id
|
|
""",
|
|
(now, token_id),
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
def delete_expired_verification_tokens(self, *, now: datetime) -> int:
|
|
"""Delete tokens that are spent or long expired, returning how many."""
|
|
|
|
with self._connection() as connection:
|
|
row = connection.execute(
|
|
"""
|
|
DELETE FROM public.bootstrap_tokens
|
|
WHERE consumed_at IS NOT NULL
|
|
OR expires_at <= %s
|
|
RETURNING id
|
|
""",
|
|
(now,),
|
|
).fetchall()
|
|
return len(row)
|
|
|
|
# -- 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(
|
|
# The whole row comes back, not just the identifier: the store
|
|
# defaults and normalises these columns, so the returned row is
|
|
# what was actually written and is what a later read would see.
|
|
# Returning only ``id`` left the mapper reading keys the row did
|
|
# not have.
|
|
"""
|
|
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, actor_user_id, organization_id, action, target_type,
|
|
target_id, request_id, metadata, created_at
|
|
""",
|
|
(
|
|
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.
|
|
|
|
An account may hold several active ``user_roles`` rows, because two different
|
|
kinds of row live in that table:
|
|
|
|
* a **tenant** row, at ``platform`` or ``organization`` scope, saying which
|
|
organization the account belongs to and what it may do there;
|
|
* a **project** row, at ``project`` scope, saying who is working on which
|
|
project.
|
|
|
|
Only the first kind answers "which tenant is this?", so only the first kind is
|
|
read here. Project rows are excluded by scope rather than by organisation,
|
|
because a project row carries an organization too and filtering on the
|
|
column rather than the scope would be indistinguishable from the tenant row
|
|
it sits beside.
|
|
|
|
Counting all rows here would refuse to resolve any assigned member, which
|
|
locks them out of authentication entirely - the failure mode is a member who
|
|
worked on a book and then could not sign in.
|
|
|
|
One tenant row remains the rule, and one tenant row is still ambiguous: the
|
|
organization returned scopes every administrative decision, so a second tenant
|
|
row is refused rather than resolved to whichever sorted first, which would
|
|
hand out the wrong organization's authority. Project membership does not
|
|
create that ambiguity and is not a reason to refuse.
|
|
"""
|
|
|
|
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'
|
|
AND ur.scope <> 'project'
|
|
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"],
|
|
)
|
|
|
|
|
|
#: The predicate that selects an account's *tenancy* rows and excludes project
|
|
#: rows.
|
|
#:
|
|
#: ``user_roles`` holds two different statements. A tenant row (scope ``platform``
|
|
#: or ``organization``) says which organization the account belongs to and what it
|
|
#: may do there. A project row (scope ``project``) says who is working on which
|
|
#: project. Project rows also carry an organization, so they cannot be told apart
|
|
#: by that column - only by scope.
|
|
#:
|
|
#: Every query that asks "which organization is this account in", or "what role
|
|
#: does it hold there", must apply this. Leaving one out is not a style
|
|
#: difference: it made role resolution refuse any assigned member, locking them
|
|
#: out of sign-in, because such an account now holds two active rows.
|
|
#:
|
|
#: Written for a table aliased ``ur``.
|
|
TENANT_ROW_PREDICATE = "ur.scope <> 'project'"
|
|
|
|
def _status_conditions(statuses: Sequence[ProjectStatus]) -> list[str]:
|
|
"""The predicate for a status filter, refusing a value the model does not have.
|
|
|
|
Every status the model knows is rendered, so a value outside the database's own
|
|
check constraint is refused by the store rather than silently matching nothing.
|
|
"No projects have status 'paused'" and "that status does not exist" are
|
|
different answers and only one of them is true.
|
|
"""
|
|
|
|
wanted = [str(status) for status in ProjectStatus]
|
|
for status in statuses:
|
|
if str(status) not in wanted:
|
|
raise RepositoryError("unmodelled_project_status", f"Unrecognised project status {status!r}.")
|
|
return ["p.status = ANY(%s)"]
|
|
|
|
|
|
def _status_params(statuses: Sequence[ProjectStatus]) -> list[Any]:
|
|
return [[str(status) for status in statuses]]
|
|
|
|
|
|
#: The columns a project is read with.
|
|
#:
|
|
#: ``organization_id`` is **not** among them, and that is the migration talking:
|
|
#: the column is gone from the table, because the schema the row was found in
|
|
#: already says which organization owns it. The model still carries an organization
|
|
#: and callers still see one, so :func:`_project_from_row` fills it in from the
|
|
#: schema the answer came from.
|
|
_PROJECT_COLUMNS = "p.id, p.name, p.slug, p.status, p.created_by, p.created_at, p.updated_at"
|
|
|
|
|
|
def _project_from_row(row: Mapping[str, Any], *, organization_id: UUID) -> Project:
|
|
"""Map a ``projects`` row, taking the organization from the schema it is in.
|
|
|
|
The status is mapped through the same table of modelled values the accounts
|
|
use, and an unrecognised value is refused rather than coerced. The database's
|
|
check constraint allows exactly the four the model declares, so a row outside
|
|
them would mean the schema and the application disagree, and guessing which is
|
|
right is how a project ends up quietly archived.
|
|
"""
|
|
|
|
return Project(
|
|
id=row["id"],
|
|
organization_id=organization_id,
|
|
name=row["name"],
|
|
slug=str(row["slug"]),
|
|
status=_project_status(row["status"]),
|
|
created_by=row["created_by"],
|
|
created_at=row["created_at"],
|
|
updated_at=row["updated_at"],
|
|
)
|
|
|
|
|
|
def _project_status(value: str) -> ProjectStatus:
|
|
try:
|
|
return ProjectStatus(value)
|
|
except ValueError:
|
|
raise RepositoryError(
|
|
"unmodelled_project_status", f"The store holds a project status this version does not know: {value!r}."
|
|
) from None
|
|
|
|
|
|
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"}
|
|
# ``target_id`` is a text column that also holds non-user targets, so it is
|
|
# narrowed on ``target_type`` and then parsed. A ``user`` row whose text is not
|
|
# a UUID is a store inconsistency; it becomes no target rather than an
|
|
# exception raised while mapping an audit record, which would fail the write
|
|
# that was already committed.
|
|
target_user_id: UUID | None = None
|
|
if row["target_type"] == "user" and row["target_id"]:
|
|
try:
|
|
target_user_id = UUID(str(row["target_id"]))
|
|
except ValueError:
|
|
target_user_id = None
|
|
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=target_user_id,
|
|
organization_id=row["organization_id"],
|
|
request_id=row["request_id"],
|
|
detail=detail,
|
|
)
|
|
|
|
|
|
def _verification_from_row(row: Mapping[str, Any]) -> VerificationToken:
|
|
"""Map a ``bootstrap_tokens`` row, reading the purpose from the row.
|
|
|
|
The purpose used to be hardcoded here because the table had no such column.
|
|
Migration 0002 added it, and it is read now, because a token that reports the
|
|
wrong purpose is exactly the substitution the column exists to prevent.
|
|
"""
|
|
|
|
return VerificationToken(
|
|
id=row["id"],
|
|
user_id=row["user_id"],
|
|
token_hash=row["token_hash"],
|
|
expires_at=row["expires_at"],
|
|
created_at=row["created_at"],
|
|
consumed_at=row["consumed_at"],
|
|
purpose=row["purpose"],
|
|
)
|
|
|
|
|
|
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_FUNCTION",
|
|
"RLS_USER_SETTING",
|
|
"STATEMENT_TIMEOUT_MS",
|
|
]
|