Skip to content

Say who a new relation is readable by, instead of assuming - #137

Open
roed-math wants to merge 5 commits into
roed314:mainfrom
roed-math:security-grant-policy
Open

Say who a new relation is readable by, instead of assuming#137
roed-math wants to merge 5 commits into
roed314:mainfrom
roed-math:security-grant-policy

Conversation

@roed-math

@roed-math roed-math commented Aug 3, 2026

Copy link
Copy Markdown

Last of five PRs from the August 3 security audit.

Stacked on #133, #134, #135 and #136, so the diff here contains their
commits too; merge those first and this one shrinks to its own commit.

The problem

Creating a search table granted SELECT on it to lmfdb and webserver, and
SELECT/INSERT on its counts and stats tables, wherever those roles happened
to exist. The metadata tables did the same, and a reload reapplied it. That is
the LMFDB's deployment, not a fact about a PostgreSQL database: a table created
for anything at all came into existence readable by two roles the administrator
may never have heard of. A missing role was passed over with a warning, so a
policy could appear to have been applied when it had not been. And because
PostgreSQL carries privileges along with a rename, the _oldN table a reload
leaves behind — a copy of the live data — stayed exactly as readable as the
table it used to be.

The fix

  • psycodict.grants.GrantPolicy states privileges by relation kind (search,
    counts, stats, meta, meta_hist, meta_format, backup), passed as
    PostgresDatabase(grant_policy=...).
  • The default policy grants nothing. LMFDBGrantPolicy() reproduces the old
    behavior for deployments that want it.
  • A policy that names a role the cluster does not have raises, so it is never
    half applied silently; missing_role="skip" warns and carries on, which is
    what LMFDBGrantPolicy uses since development databases rarely have those
    roles. The roles are checked once at connection time, so a mistaken policy is
    refused before it can interrupt a bootstrap or a reload halfway through, and
    the per-relation failure still happens inside the creation transaction, so
    the relation is rolled back rather than left ungranted.
  • One helper, _apply_grant_policy(table_name, kind), is used at every point a
    relation is created or swapped: metadata bootstrap, the format stamp,
    search/counts/stats creation, create_table_like, the reload swap, and
    create_oldstats. There are no remaining grant_select calls with hidden
    defaults.
  • A reload warns when it takes access away. It swaps in a clone, and
    PostgreSQL gives a clone no privileges of its own, so under the default
    policy the replacement is reachable only by its owner even when the table it
    replaced was readable by an application role. That is what the policy asks
    for, but it should not be discovered by a website going dark, so the swap
    logs a warning naming the roles that lost access and pointing at
    grant_policy.
  • The relation kind is looked up rather than read off the name. _swap_in_tmp
    knows which relation is the search table and which are its companions; the
    suffix reading it used to do (also what the old code did) would have granted
    a counts table's INSERT to a search table ending in _stats, which the
    LMFDB has.
  • Semantics: the policy is authoritative for the roles it names. Applying it
    revokes the four managed actions from those roles before granting, so a
    relation ends up with the policy rather than the policy plus whatever it
    inherited — which is what makes the backup case work. Roles a policy does not
    name are never touched. Documented in DataManagement.md.
  • grant_select, grant_insert, grant_update and grant_delete keep
    working, but their users argument is now required (it was a mutable list
    default naming lmfdb/webserver).

Downstream

LMFDB needs one line to keep its current permissions —
in lmfdb/lmfdb_database.py, LMFDBDatabase.__init__:

kwargs.setdefault("grant_policy", LMFDBGrantPolicy())
PostgresDatabase.__init__(self, config, **kwargs)

I have not touched the LMFDB repository; say the word and I will open that PR
too. Without it, tables created by LMFDB after this lands get no grants —
existing tables are unaffected, since a policy is applied when psycodict creates
or swaps a relation, not on connection.

Tests

8 new cases in tests/test_security.py: under the default policy a new search
table, its _counts and _stats, and the meta tables have no non-owner grants
at all; an explicit policy grants exactly what it names and nothing to a kind it
does not mention (using two disposable roles, skipped where the test user may
not create roles); the LMFDB policy's contents are what they were, including
nothing for backups; a missing role under the strict setting raises and leaves
no table, no meta_tables row and no relation behind; the permissive setting
warns and creates; after a reload the live table has the policy's grants and the
_oldN backup has none; a policy rejects unknown kinds, unknown actions, a bare
string of roles, a bad missing_role, and a role name that is not an
identifier; and the manual grant methods refuse to be called without roles.

Full suite: 1091 passed, 36 skipped against PostgreSQL 18. The wheel builds with
the new module included.

🤖 Generated with Claude Code

@roed-math
roed-math force-pushed the security-grant-policy branch 2 times, most recently from f841b14 to 8493aa7 Compare August 3, 2026 07:49
@read-the-docs-community

read-the-docs-community Bot commented Aug 3, 2026

Copy link
Copy Markdown

Documentation build overview

📚 psycodict | 🛠️ Build #33884180 | 📁 Comparing 0182800 against latest (85283b7)

  🔍 Preview build  

18 files changed · + 3 added · ± 15 modified

+ Added

± Modified

roed314 and others added 5 commits August 3, 2026 04:31
A column type cannot be bound as a value -- PostgreSQL has no placeholder
for a type -- so create_table, add_column and header-driven table creation
interpolate it into DDL as SQL text.  The check guarding that
interpolation used regexp.match() against a set of unanchored patterns, so
any string beginning with a valid type passed validation with the rest
still attached, and psycopg runs a parameterless statement with the simple
query protocol, which executes every statement in it.

Centralize the check in validate_column_type(), which matches the complete
string with fullmatch(), restricts types to an ASCII character set that
excludes NUL, control characters and lookalikes, and returns the spelling
callers must emit.  Every type-bearing DDL path now emits that returned
spelling: _order_columns, _create_table, _create_table_from_header (and so
reload/reload_all with adjust_schema=True), add_column, and the temporary
table built when resorting ids.

The character-type pattern was also malformed -- a bracket typo in the
varchar length meant varchar(N) was only ever accepted as a prefix of
varchar -- so the char family is now described deliberately, with an
optional length and an optional collation.  Collation names stay
case-sensitive, since they are quoted identifiers: "C" is a collation and
"c" is not.  add_column validates before it mutates col_type, so a
rejected type leaves the table object alone.

InvalidColumnTypeError subclasses both ValueError and RuntimeError, the
latter being what an invalid type raised before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
meta_indexes and meta_constraints hold what an index or constraint is
rebuilt from, sometimes long after it was created.  Between the two the
rows can be edited with plain SQL, exported to a file, carried to another
database and imported, or restored from the _hist tables, so
"this row must once have passed through create_index" is not something a
statement builder can rely on.  It was relied on: the access method,
column modifiers, storage-parameter names, check function and
partial-index predicate were formatted into the statement as text, and a
predicate is appended to CREATE INDEX, where a semicolon ends it.

Add validate_index_definition and validate_constraint_definition, and
apply them in both places -- when rows are imported by _reload_meta and
_revert_meta, inside the transaction so a rejected file leaves the old
metadata intact, and again in _create_index_statement and
_create_constraint_statement when the definition becomes DDL.  Column
existence is checked only at the second point, against the relation being
built, since a reload legitimately imports an index for a column the
table is about to gain.

Remove the interpolation the validators were the only defense for: access
methods, operator classes, storage-parameter names and check functions are
now quoted identifiers, and ASC/DESC/NULLS FIRST/NULLS LAST are fixed SQL
constants selected by a validated key.  Predicates stay raw SQL, but must
remain predicates: no semicolons, comments, dollar quotes, control
characters or unreasonable lengths.

The index vocabulary (_operator_classes, _valid_storage_params) moves to
base.py next to the validators, and is re-exported from table.py.
_copy_from_meta, an import path into meta_* with no callers and no
validation, is removed, and the assert guarding _meta_cols_types_jsonb_idx
becomes a ValueError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
reset_connection() called _new_connection() with no arguments, so a
replacement connection was built from the configuration alone.  The
overrides passed to PostgresDatabase(...) -- sslmode and the certificate
paths, an alternate host, port, database or user, libpq options, connect
and keepalive timeouts -- were kept in _connect_kwargs and used only for
the first connection.  A connection that dropped therefore came back
without them: possibly without TLS, possibly to a different server.  The
webserver statement timeout was set once in __init__ and went with the
session it was set on, and the read-only, superuser, knowls and userdb
capability flags were never recomputed.

Merge the options in one place, _connection_options(), used by every
connection this database opens; move per-session setup into
_configure_session(), applied to each new connection; and generalize the
webserver timeout into session_settings, a small closed set of settings
applied through set_config, which takes the name and value as bound
parameters.

A replacement is now adopted only after it is checked to have reached the
same database as the same role -- the host is deliberately not checked,
since a failover is a legitimate way for it to change -- and after the
capability flags are recomputed from it.  Both run directly on the new
connection rather than through _execute, which is what is being recovered
from.  The retry rules are untouched: a standalone statement is retried
once, and a statement inside DelayCommit still raises rather than
replaying a transaction whose earlier statements died with the connection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A search table's name is used in three namespaces at once: as a
PostgreSQL identifier, as the key its table object is reached by on the
database, and as the stem of the files copy_to writes.  Nothing checked it
against any of them.  A name containing a path separator or a ".."
component sent an export out of the folder it was asked for, and because
table objects were stored in the database object's instance dictionary, a
table called conn, config or tablenames -- from create_table, or from a
meta_tables row edited with plain SQL -- took the place of that attribute.

Add validate_search_table_name and apply it in create_table,
create_table_like (through create_table), rename_table and refresh_tables,
so a name is checked whether it came from a caller or out of meta_tables.
Move table objects into db.tables, reached through __getitem__ and a
__getattr__ that only runs after ordinary attribute lookup has failed, so
that what the database object really has always wins; tablenames becomes a
sorted property of that mapping, which cannot drift from it.  Route the
filenames copy_to generates through safe_child_path, which resolves both
paths -- so a symlink inside the folder does not lead out of it -- and
refuses anything that is not underneath.

The asserts in rename_table and in reload_all's meta-file check become
explicit errors: they validate input, and disappear under python -O.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Creating a search table granted SELECT on it to lmfdb and webserver, and
SELECT/INSERT on its counts and stats tables, wherever those roles
happened to exist; the metadata tables did the same and a reload
reapplied it.  That is the LMFDB's deployment rather than a fact about a
PostgreSQL database: a table created for anything at all came into
existence readable by two roles the administrator may never have heard
of, and a missing role was passed over with a warning, so a policy could
appear to be applied when it had not been.

Add psycodict.grants.GrantPolicy, which states the privileges by relation
kind -- search, counts, stats, meta, meta_hist, meta_format, backup -- and
pass one to PostgresDatabase.  The default grants nothing.
LMFDBGrantPolicy() reproduces what psycodict did before, for the
deployments that want it, and LMFDB should pass it explicitly.  A policy
that names a role the cluster does not have raises unless it asks for
missing_role="skip".

Apply it through a single helper at every point a relation is created or
swapped, including the _oldN table a reload leaves behind: PostgreSQL
carries privileges along with a rename, so a backup holding a copy of the
live data stayed exactly as readable as the table it used to be.  The
policy is authoritative for the roles it names -- the managed actions are
revoked from them before granting -- so a relation ends up with the policy
rather than the policy plus whatever it inherited.  Roles a policy does
not name are never touched.

grant_select and its siblings keep working but require their users
argument: a public method for granting to roles you name is useful, one
that quietly grants to lmfdb and webserver is not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roed-math
roed-math force-pushed the security-grant-policy branch from a56d49b to 0182800 Compare August 3, 2026 08:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants