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 ROLE evaluates. A SECURITY DEFINER function is the exception: it evaluates its body as its owner, so one the role may execute (PUBLIC holds EXECUTE on every new function by default) returns whatever its body selects, secret columns included. forge generates none; SecurityDefinerExposure finds them and the migrator logs a warning. Revoke PUBLIC's EXECUTE on such a function, or do not write one over a table with a secret column.
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.
"Everything the role holds" is read from the catalog, not from the declaration, so a privilege the declaration never named converges too:
- schema, table, view, sequence and routine privileges in every declared schema, and column privileges column by column (a table-level REVOKE cascades to them in Postgres, but the script does not rely on that);
- ALTER DEFAULT PRIVILEGES entries that name the role, in any schema, which would otherwise hand it every object created later;
- a leftover verification: a privilege the migrating role cannot revoke (it was granted by another role, or sits on an object the migrator does not own) fails the run instead of standing behind a converged-looking script.
What a script about ONE role cannot converge, and does not try to:
- privileges PUBLIC holds, which every role inherits (Postgres grants EXECUTE on new functions to PUBLIC by default, which is how a SECURITY DEFINER function becomes a way around the column grants, above);
- the role's memberships in other roles (pg_read_all_data, an application role): revoking one needs ADMIN OPTION on it, and the ManagedDatabase render declares none (pkg/deploy);
- database-level privileges (CONNECT, TEMP, CREATE) and row-level security policies that name the role;
- objects in a schema the declaration does not name.
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 ApplyDashboard(ctx context.Context, c Conn, script string) error
- func Assemble(main string, rows []string) (string, error)
- func CredentialShaped(name string) bool
- func DashboardSecretName(cluster string) string
- func FileName(schema, table string) string
- func Render(tables []Table) (string, error)
- func RenderDashboard(tables []Table) (string, error)
- func RenderDashboardFor(role string, tables []Table) (string, error)
- func RenderFor(role string, tables []Table) (string, error)
- func RoleExists(ctx context.Context, c Conn, name string) (bool, error)
- func RoleLogin(ctx context.Context, c Conn, name string) (exists, canLogin bool, err error)
- func SecretName(cluster string) string
- func SecurityDefinerExposure(ctx context.Context, c Conn, role string) ([]string, error)
- type Column
- type ColumnKind
- type Conn
- type Disposition
- type Files
- type Finding
- type Table
- type TableFile
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 script, relative to the project root. GrantsPath = "db/grants/readonly_gen.sql" // GrantsRowsDir holds the script's per-table rows, one [FileName] each, // relative to the project root (see [Files]). GrantsRowsDir = "db/grants/readonly" // DashboardRoleName is the dashboard Postgres role: the opt-in role live // dashboard panels query as (ManagedDatabase.dashboards in KCL). It reads // a strict subset of what [RoleName] reads; see [DashboardTables]. DashboardRoleName = "forge_dash" // DashboardSecretSuffix is appended to a cluster name to name the Secret // that holds [DashboardRoleName]'s password. DashboardSecretSuffix = "-forge-dash" // DashboardGrantsPath is the generated dashboard grants file, relative to // the project root. DashboardGrantsPath = "db/grants/dashboard_gen.sql" // DashboardGrantsRowsDir is [GrantsRowsDir] for the dashboard role. DashboardGrantsRowsDir = "db/grants/dashboard" )
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 ApplyDashboard ¶ added in v1.0.9
ApplyDashboard is Apply for a RenderDashboard script, so a failure names the role it was converging.
func Assemble ¶ added in v1.0.12
Assemble is the script the migrator runs: main with rows spliced in, in order. A main with no placeholder is a script rendered before the rows had files of their own: it is returned whole when there are no rows (so a binary built from a project not yet regenerated applies what it embeds), and refused when there are.
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 DashboardSecretName ¶ added in v1.0.9
DashboardSecretName is the kubernetes.io/basic-auth Secret holding the dashboard role's credentials for the ManagedDatabase called cluster, in the cluster's namespace. Same contract as SecretName, for DashboardRoleName.
func FileName ¶ added in v1.0.12
FileName is a table's row file name within its role's directory: <schema>.<table>_gen.sql, with any byte outside [A-Za-z0-9_-] of either name written %XX so the name is one path segment on every filesystem.
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).
The script is Assemble over RenderFiles: what a project commits is the files, and what the migrator runs is this.
func RenderDashboard ¶ added in v1.0.9
RenderDashboard is the dashboard role's grants script. tables is the same set Render takes (every base table, marked or not); it is deterministic, like Render.
func RenderDashboardFor ¶ added in v1.0.9
RenderDashboardFor is RenderDashboard for a named role, which roles being cluster-wide only tests need. It is the one place the login gate is on: see "Switching dashboards off" above.
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 RoleLogin ¶ added in v1.0.9
RoleLogin reports whether the cluster has a role called name and whether it can log in. The dashboard role is rendered NOLOGIN while dashboards are off (pkg/deploy), which is how the migrator, which never sees the KCL, learns the role is switched off.
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.
func SecurityDefinerExposure ¶ added in v1.0.9
SecurityDefinerExposure lists the SECURITY DEFINER functions role can execute in a schema it can see: the one way a column the role has no SELECT on is still readable, because the function runs as its owner. It is a WARNING for the caller to log, not a failure: forge generates no such function, and revoking PUBLIC's default EXECUTE would change the application's own semantics. Names come back as regprocedure text, already quoted.
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
// NoDashboard: declared forge:no-dashboard. Only [DashboardTables] reads
// it; the read-only role is unaffected.
NoDashboard bool
}
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 Files ¶ added in v1.0.12
type Files struct {
// Main is the role's convergence script, with [rowsPlaceholder] where the
// rows go. It depends on nothing but the role, so it never changes with
// the schema.
Main string
// Tables are the per-table rows, in [FileName] order.
Tables []TableFile
}
Files is one role's grants as a project commits them.
func RenderDashboardFiles ¶ added in v1.0.12
RenderDashboardFiles is RenderDashboard as a project commits it; see Files.
func RenderFiles ¶ added in v1.0.12
RenderFiles is the grants for tables as a project commits them; see Files. Render is the same declaration assembled.
func RenderFilesFor ¶ added in v1.0.12
RenderFilesFor is RenderFiles for a named role, as RenderFor is Render's.
type Finding ¶
Finding is one undecided column.
type Table ¶
type Table struct {
Schema string
Name string
Columns []Column
// Dashboard: declared forge:dashboard. Only [DashboardTables] reads it.
Dashboard bool
}
Table is one table the role may read.
func DashboardTables ¶ added in v1.0.9
DashboardTables projects tables (every applied base table, as Table/Column declare them) onto the dashboard role: the same tables, each with only the columns the role may read.
An unmarked table stays in the result with no columns rather than being dropped. The script revokes per SCHEMA it declares, so keeping the table is what makes removing the last forge:dashboard marker from a schema take the access away on the next run instead of leaving the old grant behind.
Columns are judged by Column.Disposition first, so a forge:secret column or a credential-shaped one nobody decided is withheld from the dashboard role exactly as from the read-only role, whatever forge:no-dashboard says.