Revoke the reader's grants as their own grantors and read them back, alter its role only on drift, and report definer functions and views (review of hq issue 337)

A superuser's REVOKE acts as the table owner, so a grant to the reader made
by a role holding GRANT OPTION survived it while the report said revoked.
Each grant is now revoked under SET ROLE to its grantor, the grants are read
back, and anything still held is reported as a failure. The role was
altered every minute with its password in the statement, which a server
logging DDL would record and which could collide with a first query; it is
now altered only when an attribute, a setting or the password has drifted,
one ensure at a time. SECURITY DEFINER functions and views the reader may
use reach data without a grant on it, so every pass lists them. The README
says the name filter is a backstop and that the read-only session default
can be turned off by a statement.
This commit is contained in:
jochen
2026-10-08 22:01:43 +02:00
parent fb07d552a0
commit a643e28083
7 changed files with 431 additions and 92 deletions
@@ -24,10 +24,12 @@ import (
"github.com/jackc/pgx/v5/pgconn"
)
// Reader is the login a caller's statement runs as (novox/hq issue 193). It changes nothing — every
// transaction it opens is read-only by the server's own setting — and it reads only the columns a
// consuming module declares readable, never one that holds a secret (readable.go, novox/hq issue 337).
// It holds no role: until issue 337 it held `pg_read_all_data`, and with it the controller's signing key. A statement cannot climb out of a login the way it can out of a
// Reader is the login a caller's statement runs as (novox/hq issue 193). It changes nothing because it
// holds no grant to write: its sessions are read-only by default, but a statement may turn that off
// (SET transaction_read_only = off), so the default is a convenience and the missing grants are the
// guard. It reads only the columns a consuming module declares readable, never one that holds a
// secret (readable.go, novox/hq issue 337). It holds no role: until issue 337 it held
// `pg_read_all_data`, and with it the controller's signing key. A statement cannot climb out of a login the way it can out of a
// transaction wrapped around it as text: `COMMIT; DROP …` ended the old wrapper and ran the rest as
// the superuser, and even one read-only statement as a superuser can run a program on the server.
const Reader = "mesh_store_reader"
@@ -104,6 +106,7 @@ type Client struct {
readerMu sync.Mutex
readerReady bool
roleMu sync.Mutex
// reader is what the last reconcile of the reader's grants found (readable.go).
reader readerState
@@ -506,13 +509,21 @@ func (c *Client) ensureReaderRole(ctx context.Context) error {
if c.conn.ReaderPassword == "" {
return errReaderMissing
}
has, err := c.exists(ctx, "SELECT 1 FROM pg_roles WHERE rolname = "+Literal(Reader))
// One at a time: the reconcile loop and a first query may both ask, and two ALTER ROLEs at once
// fail with "tuple concurrently updated".
c.roleMu.Lock()
defer c.roleMu.Unlock()
has, drifted, err := c.readerDrifted(ctx)
if err != nil {
return err
}
for _, sql := range ReaderStatements(has, c.conn.ReaderPassword) {
if _, err := c.Query(ctx, "", sql); err != nil {
return err
// The role is altered only when it has drifted: a statement carrying the password is sent as
// seldom as possible, since a server logging DDL would write it down.
if !has || drifted {
for _, sql := range ReaderStatements(has, c.conn.ReaderPassword) {
if _, err := c.Query(ctx, "", sql); err != nil {
return err
}
}
}
r, err := c.Query(ctx, "", "SELECT r.rolname FROM pg_auth_members m JOIN pg_roles r ON r.oid = m.roleid "+
@@ -530,6 +541,47 @@ func (c *Client) ensureReaderRole(ctx context.Context) error {
return nil
}
// readerAttributes is what the reader's role must say, in the order readerDrifted asks for them:
// not a superuser, cannot make databases or roles, replicate or bypass row security; inherits; logs in;
// its password never expires.
var readerAttributes = []string{"f", "f", "f", "f", "f", "t", "t", "t"}
// readerDrifted says whether the reader's role exists, and whether it differs from what
// ReaderStatements makes: an attribute, a session setting, or a password that no longer logs in.
func (c *Client) readerDrifted(ctx context.Context) (exists, drifted bool, err error) {
r, err := c.Query(ctx, "", "SELECT rolsuper, rolcreatedb, rolcreaterole, rolreplication, rolbypassrls, rolinherit, rolcanlogin, "+
"COALESCE(rolvaliduntil = 'infinity', false), COALESCE(array_to_string(rolconfig, ','), '') FROM pg_roles WHERE rolname = "+Literal(Reader))
if err != nil {
return false, false, err
}
if len(r.Rows) == 0 {
return false, false, nil
}
row := r.Rows[0]
for i, want := range readerAttributes {
if cell(row, i) != want {
return true, true, nil
}
}
config := map[string]bool{}
for _, kv := range strings.Split(cell(row, len(readerAttributes)), ",") {
config[kv] = true
}
if !config["default_transaction_read_only=on"] || !config["statement_timeout=60s"] {
return true, true, nil
}
// The password: asked by logging in with it, which is the only way to know without reading a hash.
s, err := c.dial(ctx, Login{Database: "postgres", User: Reader, Password: c.conn.ReaderPassword, Options: readerOptions})
if err != nil {
if IsLost(err) {
return true, true, nil
}
return true, false, err
}
s.Close()
return true, false, nil
}
var firstWord = regexp.MustCompile(`^\s*([A-Za-z]+)`)
// ReadOnlyQuery runs a caller's statement against a named database as the read-only login, for the