dbgrants

package
v1.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

Documentation

Overview

Package dbgrants is the read-only database role every forge-managed Postgres carries, and the grants that bound what it can read.

The role exists so something outside the application — `forge db query`, the control plane's QueryDatabase — can look at data without being able to change it or to read what the application marked secret. The boundary is POSTGRES, not a redactor: a redactor cannot follow `upper(secret_col)`, but a column the role has no SELECT privilege on cannot be read through any expression.

The three pieces

  • The role itself is declared by the ManagedDatabase render (pkg/deploy adds a CNPG managed role, RoleName, whose password lives in SecretName).
  • The grants are a projection of the applied schema and the proto `forge:secret` markers, rendered here by Render into db/grants/readonly_gen.sql.
  • The migrator applies that file after every run (pkg/migratekit), through Apply. Every migration therefore converges the grants: a new table is granted, a new secret column stays excluded, and a column that stopped being secret becomes readable.

Why REVOKE then GRANT

The script is a CONVERGENCE, not a delta: per schema it revokes everything the role holds on tables and sequences, then grants exactly the declared columns, in one transaction. A delta would leave a table dropped from the declaration, or a column that became secret, readable until someone noticed. Revoking a table's privileges also revokes its column-level ones.

What the role is never given: INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, any sequence privilege, any view (a view can expose a secret column of its base table, and the catalog cannot say which), and any table-level SELECT — a table-level SELECT would cover columns added later, including secret ones.

Index

Constants

View Source
const (
	// RoleName is the read-only Postgres role.
	RoleName = "forge_ro"
	// SecretSuffix is appended to a cluster name to name the Secret that holds
	// the role's password.
	SecretSuffix = "-forge-ro"
	// GrantsPath is the generated grants file, relative to the project root.
	GrantsPath = "db/grants/readonly_gen.sql"
)

Variables

View Source
var CredentialNameDescriptors = []string{
	"id", "ids", "name", "names", "type", "kind", "status", "state",
	"count", "version", "provider", "scope", "scopes", "label",
}

CredentialNameDescriptors are final words that make a name describe or point at a credential rather than hold one: `token_id`, `secret_name`, `credential_type`, `api_key_status`. A name ending in one is not credential-shaped. The list is short on purpose: `_prefix`, `_ref` and `_hint` are absent, because a "reference" or "prefix" column is often the value or most of it.

View Source
var CredentialNameStems = []string{
	"password", "passwd", "passphrase",
	"secret", "credential",
	"token", "bearer",
	"ciphertext", "plaintext", "encrypted", "sealed",
	"apikey", "accesskey", "secretkey", "privatekey", "signingkey",
	"encryptionkey", "authkey", "masterkey", "licensekey", "sessionkey",
	"recoverycode", "backupcode", "resetcode", "verificationcode", "authcode",
}

CredentialNameStems are matched as substrings of the column name with its underscores removed, so `api_key`, `apikey` and `ApiKey` all match `apikey`. They are long enough to mean the thing wherever they sit in a name.

`token` is a stem on purpose: it catches `refresh_token`, `token_hash`, `access_token`, `session_token`, and a false positive (`tokenizer`) costs one forge:readable declaration. A numeric `input_tokens` is exempt by type, not by name (see KindScalar).

View Source
var CredentialNameWords = []string{
	"hash", "hashed", "hashes", "salt",
	"jwt", "otp", "totp", "pin", "pwd",
	"cred", "creds", "cipher", "cookie",
}

CredentialNameWords are matched as whole `_`-separated words only, because they are short enough to appear inside an innocent word (`pin` in `pinned`, `salt` in `basalt`). `hash` covers token and password hashes: a hash of a credential is not replayable but is an offline-guessing target, and a content hash that is not a credential takes a forge:readable.

Functions

func Apply

func Apply(ctx context.Context, c Conn, script string) error

Apply runs script, a Render result, on c. The script is one statement, so it is atomic: a failure leaves the previous grants in place.

func CredentialShaped

func CredentialShaped(name string) bool

CredentialShaped reports whether a column NAME looks like it holds a credential: a token, a password, a key, ciphertext, a hash of one. It judges the name only; the type check is ColumnKind.

It is a deliberate over-approximation. Failing closed means a false positive costs one `forge:readable <reason>` and a false negative ships a credential, so the list errs toward matching. What it cannot catch is a credential in a column with an innocent name (`value`, `data`, `config`): mark those forge:secret.

func Render

func Render(tables []Table) (string, error)

Render returns the grants script for tables.

It is deterministic: schemas, tables and columns are sorted, so the same schema renders the same bytes. A table whose every column is secret is declared with no readable column and is granted nothing.

THE SCRIPT IS DATA PLUS A FIXED LOOP, not a list of GRANT statements. The declaration is a VALUES list of (schema, table, readable columns), and the loop grants the intersection of that with what the database has. A file that is older than the schema (a migration landed and `forge generate` has not run) can therefore never fail the migration that carries it by naming a table or column that has since been dropped, and can never grant more than it declares: an unlisted table or column is simply not granted.

The script is a no-op when the role does not exist, so it is safe to apply to a database that has none (a local compose Postgres).

func RenderFor

func RenderFor(role string, tables []Table) (string, error)

RenderFor is Render for a named role. Production has exactly one role, RoleName; the parameter exists because roles are cluster-wide, so tests that share a Postgres server cannot all create forge_ro.

func RoleExists

func RoleExists(ctx context.Context, c Conn, name string) (bool, error)

RoleExists reports whether the database cluster has a role called name. A database no one provisioned RoleName for (a local compose Postgres) has none, and that is a normal state, not a fault.

func SecretName

func SecretName(cluster string) string

SecretName is the kubernetes.io/basic-auth Secret holding the role's credentials for the ManagedDatabase called cluster, in the cluster's namespace. It is a contract with CNPG's managed-role reconciler (the role's passwordSecret) and with whoever connects as the role.

Types

type Column

type Column struct {
	Name string
	// Secret: declared forge:secret (proto field or SQL comment). Withheld.
	Secret bool
	// Readable: declared forge:readable <reason>. Granted even when the name
	// looks like a credential. Secret wins over it.
	Readable bool
	// Kind is the column's type class. The zero value, [KindText], is the
	// stricter reading.
	Kind ColumnKind
}

Column is one column of a Table, with the declarations that decide whether the role may read it (see Column.Disposition).

func (Column) Disposition

func (c Column) Disposition() Disposition

Disposition is the one place the three declarations are combined. Secret wins over Readable, because a column declared both ways is one somebody wanted hidden.

type ColumnKind

type ColumnKind uint8

ColumnKind is what a column's type says about whether it can hold a credential.

const (
	// KindText is a column that can hold a credential (text, json, uuid, a
	// native enum, anything not named below): its NAME decides. It is the zero
	// value, so a caller that sets no kind gets the stricter reading.
	KindText ColumnKind = iota
	// KindScalar is a number, boolean or timestamp. `input_tokens bigint` and
	// `token_expires_at timestamptz` are not credentials whatever they are
	// called, so the name check does not apply.
	KindScalar
	// KindBinary is bytea: opaque by construction, and in practice ciphertext,
	// a hash or a key. It needs a decision whatever it is called.
	KindBinary
)

type Conn

type Conn interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

Conn is the slice of *sql.Conn / *sql.DB / *sql.Tx that this package needs.

type Disposition

type Disposition uint8

Disposition is what the role is allowed to do with one column.

const (
	// Granted: the role may SELECT the column.
	Granted Disposition = iota
	// WithheldSecret: declared forge:secret.
	WithheldSecret
	// WithheldUndecided: credential-shaped, and nobody decided.
	WithheldUndecided
)

type Finding

type Finding struct {
	Schema string
	Table  string
	Column string
}

Finding is one undecided column.

func Undecided

func Undecided(tables []Table) []Finding

Undecided returns every credential-shaped column of tables that carries neither forge:secret nor forge:readable, sorted. They are the columns Render withholds without anyone having said so.

func (Finding) String

func (f Finding) String() string

String is the qualified name a report prints.

type Table

type Table struct {
	Schema  string
	Name    string
	Columns []Column
}

Table is one table the role may read.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL