574 lines
22 KiB
Go
574 lines
22 KiB
Go
package main
|
|
|
|
// What the store's reader may read (novox/hq issue 337).
|
|
//
|
|
// The reader (Reader) is the login every caller's statement runs as: the store seat's `query` verb and
|
|
// postgres_query. Until issue 337 it was granted `pg_read_all_data`, so it could read every column of
|
|
// every database the store holds — the controller's signing key and its sealed secrets among them — and
|
|
// anybody who could call the verb could read them through it.
|
|
//
|
|
// **Deny by default.** The reader holds no role and no grant but what this file gives it, and it gives
|
|
// only what a consuming module declares readable in its contribution to `postgres-database`:
|
|
//
|
|
// "contributes": { "postgres-database": { "name": "letta", "readable": ["agents", "steps(id, created_at)"] } }
|
|
//
|
|
// An entry is `[schema.]table` — every column it has at each pass — or `[schema.]table(col, col)` — those
|
|
// columns only, spelled as the catalogue spells them. A database no contribution names (the controller's
|
|
// own among them) gives the reader nothing. A list is used rather than an object so the controller's
|
|
// reading of `contributes` cannot mistake it for the several-instance shape.
|
|
//
|
|
// **A column or table whose name says it holds a secret is never granted, declared or not**
|
|
// (SecretName): private keys, sealed values, tokens, passwords, credentials, salts. The refusal is said
|
|
// in the reader's report (postgres_reader_grants), so a module that meant it learns why.
|
|
//
|
|
// **Reconciled every pass, on every database, not only made once.** The reader's role memberships are
|
|
// revoked (pg_read_all_data included, which is what an existing install holds), and in every database
|
|
// every grant the reader holds that is not declared is revoked, and every declared one that is missing
|
|
// is granted. A narrowed declaration narrows the reader at the next pass.
|
|
//
|
|
// **Checked where it is used.** Before a caller's statement runs, the reader's own session is asked
|
|
// which columns it can read whose name says secret; if any, the statement is refused and the columns
|
|
// named (ExposureProbe). That catches what the reconcile does not own — a grant to PUBLIC, a grant made
|
|
// by hand — and fails closed. And a statement that names a known secret table or a secret-sounding name
|
|
// is refused before it is sent (refusedStatement): defence in depth, not the guard — the privileges are.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ReadableTable is one entry of a contribution's `readable`.
|
|
type ReadableTable struct {
|
|
Schema string
|
|
Table string
|
|
Columns []string // nil: every column the table has
|
|
}
|
|
|
|
func (r ReadableTable) String() string {
|
|
s := r.Schema + "." + r.Table
|
|
if r.Columns != nil {
|
|
s += "(" + strings.Join(r.Columns, ", ") + ")"
|
|
}
|
|
return s
|
|
}
|
|
|
|
var (
|
|
readableEntry = regexp.MustCompile(`^(?:([A-Za-z_][A-Za-z0-9_$]*)\.)?([A-Za-z_][A-Za-z0-9_$]*)\s*(?:\(([^()]*)\))?$`)
|
|
identifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_$]*$`)
|
|
)
|
|
|
|
// ParseReadable reads a contribution's `readable`: absent is nothing readable. Anything malformed refuses
|
|
// the whole list, so a typo never grants more than was meant.
|
|
func ParseReadable(values map[string]any) ([]ReadableTable, error) {
|
|
raw, ok := values["readable"]
|
|
if !ok || raw == nil {
|
|
return nil, nil
|
|
}
|
|
list, ok := raw.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("readable must be a list of tables, as \"table\" or \"table(column, column)\", not %T", raw)
|
|
}
|
|
var out []ReadableTable
|
|
seen := map[string]bool{}
|
|
for _, v := range list {
|
|
entry, ok := v.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("readable: %v is not a table name", v)
|
|
}
|
|
m := readableEntry.FindStringSubmatch(strings.TrimSpace(entry))
|
|
if m == nil {
|
|
return nil, fmt.Errorf("readable: %q is not \"[schema.]table\" or \"[schema.]table(column, …)\"", entry)
|
|
}
|
|
t := ReadableTable{Schema: firstOf(m[1], "public"), Table: m[2]}
|
|
if strings.Contains(entry, "(") {
|
|
t.Columns = []string{}
|
|
for _, col := range strings.Split(m[3], ",") {
|
|
col = strings.TrimSpace(col)
|
|
if !identifier.MatchString(col) {
|
|
return nil, fmt.Errorf("readable: %q names a column %q that is not a plain name", entry, col)
|
|
}
|
|
t.Columns = append(t.Columns, col)
|
|
}
|
|
}
|
|
key := t.Schema + "." + t.Table
|
|
if seen[key] {
|
|
return nil, fmt.Errorf("readable: %s is named twice", key)
|
|
}
|
|
seen[key] = true
|
|
out = append(out, t)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// secretName is what a column or table is called when it holds a secret, or something a secret is
|
|
// made from: a private key, a sealed value, a token, a password or its hash, a credential, a salt, a
|
|
// one-time-password seed, or a key of any kind. Wide on purpose — a public key refused is a small cost;
|
|
// a private one granted is the whole mesh.
|
|
var secretName = regexp.MustCompile(`(?i)(private|secret|seal|token|passw|pwd|credential|salt|cookie|nonce|` +
|
|
`api_?key|(^|_)(otp|totp|mfa|key|keys|rands)(_|$))`)
|
|
|
|
// SecretName says whether a column or table name says it holds a secret.
|
|
func SecretName(name string) bool { return secretName.MatchString(name) }
|
|
|
|
// knownSecretTables are tables whose secret is not in their columns' names — the mesh's own, in the
|
|
// controller's databases. A statement naming one is refused before it is sent.
|
|
var knownSecretTables = map[string][]string{
|
|
"identity": {"signing_key", "authority", "node_key"},
|
|
"inventory": {"secret", "module_secret", "operator_key", "bus_membership", "bus_user", "enrolment_token"},
|
|
"licences": {"licence_holder", "refresh_grant"},
|
|
}
|
|
|
|
// statementWord is what the statement guard reads: every word of the statement, literals included, so
|
|
// a name in a string handed to a function is read as well.
|
|
var statementWord = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_$]*`)
|
|
|
|
// guardName is narrower than secretName, because it is read over a caller's whole statement, where
|
|
// `key` and `token` are ordinary words (jsonb_object_keys).
|
|
var guardName = regexp.MustCompile(`(?i)(private|secret|sealed|passw|credential|api_?key|signing_key|` +
|
|
`sealing_key|overlay_key|operator_key|refresh_token|access_token|authtoken|pg_authid|pg_shadow|pg_user_mapping)`)
|
|
|
|
// refusedStatement says why a caller's statement is refused before it is sent, or "".
|
|
func refusedStatement(database, sql string) string {
|
|
known := map[string]bool{}
|
|
for _, t := range knownSecretTables[database] {
|
|
known[t] = true
|
|
}
|
|
for _, w := range statementWord.FindAllString(sql, -1) {
|
|
lw := strings.ToLower(w)
|
|
if known[lw] {
|
|
return fmt.Sprintf("the statement names %s.%s, which holds the mesh's keys or secrets; the store's reader is "+
|
|
"never given it (novox/hq issue 337)", database, lw)
|
|
}
|
|
if guardName.MatchString(lw) {
|
|
return fmt.Sprintf("the statement names %q, which reads as a key, secret or credential; the store's reader is "+
|
|
"never given one, and statements naming one are refused (novox/hq issue 337)", w)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ---- the probe --------------------------------------------------------------------------------
|
|
|
|
// ExposureProbe lists every column the session's own login may SELECT outside the system schemas — by
|
|
// table grant, column grant, PUBLIC or a role it holds. Exposed filters it to the secret ones.
|
|
const ExposureProbe = `SELECT n.nspname, c.relname, a.attname FROM pg_catalog.pg_attribute a ` +
|
|
`JOIN pg_catalog.pg_class c ON c.oid = a.attrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace ` +
|
|
`WHERE c.relkind IN ('r','v','m','p','f') AND a.attnum > 0 AND NOT a.attisdropped ` +
|
|
`AND n.nspname NOT IN ('pg_catalog','information_schema') AND n.nspname NOT LIKE 'pg\_toast%' ` +
|
|
`AND pg_catalog.has_column_privilege(c.oid, a.attnum, 'SELECT') ORDER BY 1, 2, 3`
|
|
|
|
// Exposed is the probe's rows that name a secret: "schema.table.column".
|
|
func Exposed(r Result) []string {
|
|
var out []string
|
|
for _, row := range r.Rows {
|
|
if len(row) < 3 || row[0] == nil || row[1] == nil || row[2] == nil {
|
|
continue
|
|
}
|
|
if SecretName(*row[1]) || SecretName(*row[2]) {
|
|
out = append(out, *row[0]+"."+*row[1]+"."+*row[2])
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---- the plan ---------------------------------------------------------------------------------
|
|
|
|
// column is one column of one table.
|
|
type column struct{ Schema, Table, Name string }
|
|
|
|
type table struct{ Schema, Table string }
|
|
|
|
// held is what the reader holds in one database, read from the catalogue as the admin.
|
|
type held struct {
|
|
// Tables are those the reader holds any table-level privilege on, with their kind ('S': sequence).
|
|
Tables map[table]string
|
|
// Columns are the column privileges it holds, each column's privilege types.
|
|
Columns map[column][]string
|
|
}
|
|
|
|
// DatabaseReport is what the reader may read in one database, and what was refused or missing.
|
|
type DatabaseReport struct {
|
|
Database string `json:"database"`
|
|
Declared bool `json:"declared"`
|
|
Readable []string `json:"readable,omitempty"`
|
|
Refused []string `json:"refused,omitempty"`
|
|
Missing []string `json:"missing,omitempty"`
|
|
Revoked []string `json:"revoked,omitempty"`
|
|
Exposed []string `json:"exposed,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// planDatabase is the DDL that brings the reader's grants in one database to exactly what is declared,
|
|
// minus every secret: revoke what is held and not wanted, grant what is wanted and not held.
|
|
// existing is every column of every declared table that the database has. Pure; the tests' subject.
|
|
func planDatabase(decl []ReadableTable, existing []column, h held, noUsage map[string]bool) ([]string, DatabaseReport) {
|
|
var rep DatabaseReport
|
|
byTable := map[table][]string{}
|
|
for _, c := range existing {
|
|
t := table{c.Schema, c.Table}
|
|
byTable[t] = append(byTable[t], c.Name)
|
|
}
|
|
want := map[column]bool{}
|
|
for _, d := range decl {
|
|
t := table{d.Schema, d.Table}
|
|
cols, ok := byTable[t]
|
|
if !ok {
|
|
rep.Missing = append(rep.Missing, d.String())
|
|
continue
|
|
}
|
|
if SecretName(d.Table) {
|
|
rep.Refused = append(rep.Refused, d.Schema+"."+d.Table+" (its name says it holds secrets)")
|
|
continue
|
|
}
|
|
named := cols
|
|
if d.Columns != nil {
|
|
// A declared column the table lacks is said and left out: granting it would fail the
|
|
// database's whole change.
|
|
named = nil
|
|
has := map[string]bool{}
|
|
for _, c := range cols {
|
|
has[c] = true
|
|
}
|
|
for _, c := range d.Columns {
|
|
if has[c] {
|
|
named = append(named, c)
|
|
} else {
|
|
rep.Missing = append(rep.Missing, d.Schema+"."+d.Table+"."+c)
|
|
}
|
|
}
|
|
}
|
|
for _, c := range named {
|
|
if SecretName(c) {
|
|
rep.Refused = append(rep.Refused, d.Schema+"."+d.Table+"."+c+" (its name says it holds a secret)")
|
|
continue
|
|
}
|
|
want[column{d.Schema, d.Table, c}] = true
|
|
}
|
|
}
|
|
// Every table-level privilege is revoked: the reader is only ever given columns. REVOKE on a table
|
|
// takes its column privileges with it, so those are granted again below if wanted.
|
|
var stmts []string
|
|
cleared := map[table]bool{}
|
|
for _, t := range sortedTables(h.Tables) {
|
|
kind := "TABLE"
|
|
if h.Tables[t] == "S" {
|
|
kind = "SEQUENCE"
|
|
}
|
|
stmts = append(stmts, fmt.Sprintf("REVOKE ALL ON %s %s.%s FROM %s", kind, Ident(t.Schema), Ident(t.Table), Ident(Reader)))
|
|
rep.Revoked = append(rep.Revoked, t.Schema+"."+t.Table+" (every privilege on the table)")
|
|
cleared[t] = true
|
|
}
|
|
have := map[column]bool{}
|
|
unwanted := map[table][]string{}
|
|
for c, privs := range h.Columns {
|
|
if cleared[table{c.Schema, c.Table}] {
|
|
continue
|
|
}
|
|
onlySelect := len(privs) == 1 && privs[0] == "SELECT"
|
|
if want[c] && onlySelect {
|
|
have[c] = true
|
|
continue
|
|
}
|
|
// Not wanted, or held with more than SELECT: revoked, and granted SELECT again below if wanted.
|
|
t := table{c.Schema, c.Table}
|
|
unwanted[t] = append(unwanted[t], c.Name)
|
|
}
|
|
for _, t := range sortedKeys(unwanted) {
|
|
cols := unwanted[t]
|
|
sort.Strings(cols)
|
|
stmts = append(stmts, fmt.Sprintf("REVOKE ALL (%s) ON %s.%s FROM %s", idents(cols), Ident(t.Schema), Ident(t.Table), Ident(Reader)))
|
|
for _, c := range cols {
|
|
rep.Revoked = append(rep.Revoked, t.Schema+"."+t.Table+"."+c)
|
|
}
|
|
}
|
|
grant := map[table][]string{}
|
|
schemas := map[string]bool{}
|
|
for c := range want {
|
|
rep.Readable = append(rep.Readable, c.Schema+"."+c.Table+"."+c.Name)
|
|
if !have[c] {
|
|
t := table{c.Schema, c.Table}
|
|
grant[t] = append(grant[t], c.Name)
|
|
schemas[c.Schema] = true
|
|
}
|
|
}
|
|
sort.Strings(rep.Readable)
|
|
for _, s := range sortedSet(schemas) {
|
|
if noUsage[s] {
|
|
stmts = append(stmts, fmt.Sprintf("GRANT USAGE ON SCHEMA %s TO %s", Ident(s), Ident(Reader)))
|
|
}
|
|
}
|
|
for _, t := range sortedKeys(grant) {
|
|
cols := grant[t]
|
|
sort.Strings(cols)
|
|
stmts = append(stmts, fmt.Sprintf("GRANT SELECT (%s) ON %s.%s TO %s", idents(cols), Ident(t.Schema), Ident(t.Table), Ident(Reader)))
|
|
}
|
|
return stmts, rep
|
|
}
|
|
|
|
func idents(names []string) string {
|
|
out := make([]string, len(names))
|
|
for i, n := range names {
|
|
out[i] = Ident(n)
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
func sortedTables(m map[table]string) []table {
|
|
out := make([]table, 0, len(m))
|
|
for t := range m {
|
|
out = append(out, t)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Schema+"."+out[i].Table < out[j].Schema+"."+out[j].Table })
|
|
return out
|
|
}
|
|
|
|
func sortedKeys(m map[table][]string) []table {
|
|
out := make([]table, 0, len(m))
|
|
for t := range m {
|
|
out = append(out, t)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Schema+"."+out[i].Table < out[j].Schema+"."+out[j].Table })
|
|
return out
|
|
}
|
|
|
|
func sortedSet(m map[string]bool) []string {
|
|
out := make([]string, 0, len(m))
|
|
for s := range m {
|
|
out = append(out, s)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// ---- the reconcile ------------------------------------------------------------------------------
|
|
|
|
// Declarations are what each consumer's database lets the reader read, by database; a database whose
|
|
// declaration was malformed is in Malformed and is given nothing.
|
|
type Declarations struct {
|
|
Tables map[string][]ReadableTable
|
|
Malformed map[string]string
|
|
}
|
|
|
|
// DeclarationsFrom reads the contributions file the mesh delivers (MESH_RECEIVES): each consumer's
|
|
// database is its login (`as`), and its `readable` is in its values. Unreadable is no declarations:
|
|
// the reader is then given nothing, and the reason is answered.
|
|
func DeclarationsFrom(path string) (Declarations, error) {
|
|
d := Declarations{Tables: map[string][]ReadableTable{}, Malformed: map[string]string{}}
|
|
if path == "" {
|
|
return d, fmt.Errorf("no contributions file is named (MESH_RECEIVES); the reader is given nothing")
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return d, fmt.Errorf("contributions file unreadable (%s), the reader is given nothing: %w", path, err)
|
|
}
|
|
var doc struct {
|
|
Given []struct {
|
|
As string `json:"as"`
|
|
Values map[string]any `json:"values"`
|
|
} `json:"given"`
|
|
}
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
return d, fmt.Errorf("contributions file is not JSON (%s), the reader is given nothing: %w", path, err)
|
|
}
|
|
for _, g := range doc.Given {
|
|
if g.As == "" {
|
|
continue
|
|
}
|
|
tables, err := ParseReadable(g.Values)
|
|
if err != nil {
|
|
d.Malformed[g.As] = err.Error()
|
|
continue
|
|
}
|
|
if len(tables) > 0 {
|
|
d.Tables[g.As] = tables
|
|
}
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// ReaderReport is the last reconcile's answer, for postgres_reader_grants.
|
|
type ReaderReport struct {
|
|
At time.Time `json:"at"`
|
|
Problem string `json:"problem,omitempty"`
|
|
Databases []DatabaseReport `json:"databases"`
|
|
}
|
|
|
|
type readerState struct {
|
|
mu sync.Mutex
|
|
last ReaderReport
|
|
}
|
|
|
|
// ReconcileReader brings the reader to exactly what is declared, in every database the server holds.
|
|
// Each database is its own: one that fails is said in its report and the rest go on.
|
|
func (c *Client) ReconcileReader(ctx context.Context, d Declarations, problem string) ReaderReport {
|
|
rep := ReaderReport{At: time.Now().UTC(), Problem: problem}
|
|
if err := c.ensureReaderRole(ctx); err != nil {
|
|
rep.Problem = strings.TrimSpace(rep.Problem + "; the reader's role: " + err.Error())
|
|
c.keep(rep)
|
|
return rep
|
|
}
|
|
r, err := c.Query(ctx, "", "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY datname")
|
|
if err != nil {
|
|
rep.Problem = strings.TrimSpace(rep.Problem + "; listing the databases: " + err.Error())
|
|
c.keep(rep)
|
|
return rep
|
|
}
|
|
present := map[string]bool{}
|
|
for _, row := range r.Rows {
|
|
if len(row) == 0 || row[0] == nil {
|
|
continue
|
|
}
|
|
db := *row[0]
|
|
present[db] = true
|
|
dr := c.reconcileDatabase(ctx, db, d.Tables[db])
|
|
if why, ok := d.Malformed[db]; ok {
|
|
dr.Error = strings.TrimSpace(dr.Error + " its readable declaration is refused, nothing granted: " + why)
|
|
}
|
|
rep.Databases = append(rep.Databases, dr)
|
|
}
|
|
for db := range d.Tables {
|
|
if !present[db] {
|
|
rep.Databases = append(rep.Databases, DatabaseReport{Database: db, Declared: true, Error: "declared readable, but the server holds no such database"})
|
|
}
|
|
}
|
|
sort.Slice(rep.Databases, func(i, j int) bool { return rep.Databases[i].Database < rep.Databases[j].Database })
|
|
c.keep(rep)
|
|
return rep
|
|
}
|
|
|
|
func (c *Client) keep(rep ReaderReport) {
|
|
c.reader.mu.Lock()
|
|
c.reader.last = rep
|
|
c.reader.mu.Unlock()
|
|
}
|
|
|
|
// LastReaderReport is what the last reconcile found.
|
|
func (c *Client) LastReaderReport() ReaderReport {
|
|
c.reader.mu.Lock()
|
|
defer c.reader.mu.Unlock()
|
|
return c.reader.last
|
|
}
|
|
|
|
func (c *Client) reconcileDatabase(ctx context.Context, db string, decl []ReadableTable) DatabaseReport {
|
|
fail := func(err error) DatabaseReport {
|
|
return DatabaseReport{Database: db, Declared: decl != nil, Error: err.Error()}
|
|
}
|
|
h := held{Tables: map[table]string{}, Columns: map[column][]string{}}
|
|
readerOID := "(SELECT oid FROM pg_catalog.pg_roles WHERE rolname = " + Literal(Reader) + ")"
|
|
r, err := c.Query(ctx, db, "SELECT n.nspname, c.relname, c.relkind::text FROM pg_catalog.pg_class c "+
|
|
"JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) x "+
|
|
"WHERE x.grantee = "+readerOID+" GROUP BY 1, 2, 3")
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
for _, row := range r.Rows {
|
|
h.Tables[table{cell(row, 0), cell(row, 1)}] = cell(row, 2)
|
|
}
|
|
r, err = c.Query(ctx, db, "SELECT n.nspname, c.relname, a.attname, x.privilege_type FROM pg_catalog.pg_attribute a "+
|
|
"JOIN pg_catalog.pg_class c ON c.oid = a.attrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace "+
|
|
"CROSS JOIN LATERAL pg_catalog.aclexplode(a.attacl) x WHERE x.grantee = "+readerOID)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
for _, row := range r.Rows {
|
|
k := column{cell(row, 0), cell(row, 1), cell(row, 2)}
|
|
h.Columns[k] = append(h.Columns[k], cell(row, 3))
|
|
}
|
|
var existing []column
|
|
noUsage := map[string]bool{}
|
|
if len(decl) > 0 {
|
|
var pairs []string
|
|
for _, d := range decl {
|
|
pairs = append(pairs, "("+Literal(d.Schema)+", "+Literal(d.Table)+")")
|
|
}
|
|
r, err = c.Query(ctx, db, "SELECT n.nspname, c.relname, a.attname FROM pg_catalog.pg_attribute a "+
|
|
"JOIN pg_catalog.pg_class c ON c.oid = a.attrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace "+
|
|
"WHERE c.relkind IN ('r','v','m','p','f') AND a.attnum > 0 AND NOT a.attisdropped "+
|
|
"AND (n.nspname, c.relname) IN ("+strings.Join(pairs, ", ")+") ORDER BY a.attnum")
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
for _, row := range r.Rows {
|
|
existing = append(existing, column{cell(row, 0), cell(row, 1), cell(row, 2)})
|
|
}
|
|
r, err = c.Query(ctx, db, "SELECT nspname FROM pg_catalog.pg_namespace WHERE NOT pg_catalog.has_schema_privilege("+
|
|
Literal(Reader)+", oid, 'USAGE')")
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
for _, row := range r.Rows {
|
|
noUsage[cell(row, 0)] = true
|
|
}
|
|
}
|
|
stmts, rep := planDatabase(decl, existing, h, noUsage)
|
|
rep.Database, rep.Declared = db, decl != nil
|
|
if len(stmts) > 0 {
|
|
// One string: the simple protocol runs it as one implicit transaction, all or nothing.
|
|
if _, err := c.Query(ctx, db, strings.Join(stmts, "; ")); err != nil {
|
|
rep.Error = "changing the reader's grants: " + err.Error()
|
|
return rep
|
|
}
|
|
}
|
|
// Asked as the admin on the reader's behalf: whatever else gives the reader a secret (PUBLIC, a
|
|
// grant made by hand) is found here, said, and makes the reader's statements against it refused.
|
|
probe := strings.Replace(ExposureProbe, "pg_catalog.has_column_privilege(c.oid", "pg_catalog.has_column_privilege("+Literal(Reader)+", c.oid", 1)
|
|
if r, err = c.Query(ctx, db, probe); err != nil {
|
|
rep.Error = "probing what the reader can read: " + err.Error()
|
|
return rep
|
|
}
|
|
rep.Exposed = Exposed(r)
|
|
return rep
|
|
}
|
|
|
|
// RunReader reconciles the reader at once and then every `every`, until ctx ends, reading the
|
|
// declarations from the contributions file each time. A change, a refusal and an exposure are said.
|
|
func (c *Client) RunReader(ctx context.Context, receives string, every time.Duration, say func(string, ...any)) {
|
|
lastSaid := ""
|
|
for {
|
|
d, err := DeclarationsFrom(receives)
|
|
problem := ""
|
|
if err != nil {
|
|
problem = err.Error()
|
|
}
|
|
rep := c.ReconcileReader(ctx, d, problem)
|
|
var lines []string
|
|
if rep.Problem != "" {
|
|
lines = append(lines, "reader: "+rep.Problem)
|
|
}
|
|
for _, db := range rep.Databases {
|
|
if len(db.Exposed) > 0 {
|
|
lines = append(lines, fmt.Sprintf("reader: EXPOSED in %s — the reader can read %s; its statements there are refused until it cannot",
|
|
db.Database, strings.Join(db.Exposed, ", ")))
|
|
}
|
|
if db.Error != "" {
|
|
lines = append(lines, fmt.Sprintf("reader: %s: %s", db.Database, db.Error))
|
|
}
|
|
if len(db.Refused) > 0 {
|
|
lines = append(lines, fmt.Sprintf("reader: %s: not granted, %s", db.Database, strings.Join(db.Refused, "; ")))
|
|
}
|
|
if len(db.Revoked) > 0 {
|
|
say("reader: %s: revoked %s", db.Database, strings.Join(db.Revoked, ", "))
|
|
}
|
|
}
|
|
// The same standing is said once, not every pass.
|
|
if now := strings.Join(lines, "\n"); now != lastSaid {
|
|
for _, l := range lines {
|
|
say("%s", l)
|
|
}
|
|
lastSaid = now
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(every):
|
|
}
|
|
}
|
|
}
|