1768 lines
76 KiB
Python
1768 lines
76 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""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",
|
|
]
|