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
- Variables
- func Apply(ctx context.Context, c Conn, script string) error
- func CredentialShaped(name string) bool
- func Render(tables []Table) (string, error)
- func RenderFor(role string, tables []Table) (string, error)
- func RoleExists(ctx context.Context, c Conn, name string) (bool, error)
- func SecretName(cluster string) string
- type Column
- type ColumnKind
- type Conn
- type Disposition
- type Finding
- type Table
Constants ¶
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 ¶
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.
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).
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 )