Source code for loom.core.repository.sqlalchemy.rls.config

"""Declaration of the database users, names and schema one application bootstraps."""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Final, Literal

from loom.core.schema_names import SchemaNames, schema_identifier, sql_identifier

Access = Literal["read", "write", "bypass"]

BYPASS_VERSION_PRIVILEGES: Final = frozenset({"SELECT"})
MAX_GUARD_LENGTH: Final = 58
MAX_VERSION_TABLE_LENGTH: Final = 59
POSTGRES_SCRAM_ITERATIONS: Final = 4096


[docs] @dataclass(frozen=True, slots=True) class DatabaseRoles: """The two database roles every scoped schema needs; unrelated to RBAC roles.""" owner: str migrator: str
[docs] @dataclass(frozen=True, slots=True) class DatabaseUser: """A database user the product declares, with how far it may reach.""" login: bool access: Access
[docs] @dataclass(frozen=True, slots=True) class BootstrapConfig: """Everything the bootstrap applies for one application schema; no name has a default. ``revoke_public`` revokes every ``PUBLIC`` privilege on the ``public`` schema; it is opt-in because that schema belongs to the whole database. ``scram_iterations`` is the PBKDF2 iteration count of every password verifier; it defaults to Postgres' own ``scram_iterations`` (4096) and may only be raised. """ schema: str roles: DatabaseRoles database_users: Mapping[str, DatabaseUser] names: SchemaNames revoke_public: bool = False scram_iterations: int = POSTGRES_SCRAM_ITERATIONS @property def bypass_users(self) -> tuple[str, ...]: """The declared users with ``access="bypass"``, in declaration order.""" return tuple(name for name, spec in self.database_users.items() if spec.access == "bypass")
[docs] def validated(self) -> BootstrapConfig: """Return ``self`` after checking every name Postgres will see. Raises: ValueError: Naming the first identifier Postgres would fold, truncate or resolve to something else, or two roles sharing a name. """ schema_identifier(self.schema) sql_identifier(self.names.guard, max_length=MAX_GUARD_LENGTH) for table in (self.names.version_table, self.names.data_version_table): sql_identifier(table, max_length=MAX_VERSION_TABLE_LENGTH) roles = ( self.roles.owner, self.roles.migrator, self.names.readers, self.names.writers, *self.database_users, ) for name in roles: sql_identifier(name) if len(set(roles)) != len(roles): raise ValueError(f"database roles must have distinct names, got {sorted(roles)}") if self.names.version_table == self.names.data_version_table: raise ValueError("the structural and the data version tables must differ") if self.scram_iterations < POSTGRES_SCRAM_ITERATIONS: raise ValueError( f"scram_iterations must be at least {POSTGRES_SCRAM_ITERATIONS}, " f"got {self.scram_iterations}" ) return self
[docs] def document(self) -> dict[str, object]: """The bound JSON document the guard's ``configure`` consumes.""" return { "app_schema": self.schema, "owner_role": self.roles.owner, "migrator_role": self.roles.migrator, "readers_role": self.names.readers, "writers_role": self.names.writers, "users": [ {"name": name, "login": spec.login, "access": spec.access} for name, spec in self.database_users.items() ], "revoke_public": self.revoke_public, "version_table": self.names.version_table, "data_version_table": self.names.data_version_table, }