Give the store's reader only the columns a module declares readable, never a secret, so a query cannot read the controller's signing key (hq issue 337)
mesh_store_reader held pg_read_all_data, so every caller of mesh-store.query and postgres_query could read every column the store holds: the signing key nodes trust, sealed secrets, password hashes. The reader now holds no role; each minute, on every database, its memberships are revoked and its grants brought to what the consumer's contribution lists under readable, minus any column or table whose name says it holds a secret. Before a statement runs, the reader's own session is asked which secret columns it can read and the statement is refused if any; a statement naming a known secret table is refused before it is sent.
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
package main
|
||||
|
||||
// The store's reader reads only what a module declares readable, never a secret (novox/hq issue 337).
|
||||
// What the server does with the grants is proven against a real one (live_reader_test.go); here, that
|
||||
// the module asks for the right ones.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadableFromAContribution(t *testing.T) {
|
||||
got, err := ParseReadable(map[string]any{"name": "x", "readable": []any{"agents", "audit.events", "steps( id , created_at )"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"public.agents", "audit.events", "public.steps(id, created_at)"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i].String() != want[i] {
|
||||
t.Fatalf("%d: %s, want %s", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
if got[0].Columns != nil {
|
||||
t.Fatal("a bare table is every column, not none")
|
||||
}
|
||||
if none, err := ParseReadable(map[string]any{"name": "x"}); err != nil || none != nil {
|
||||
t.Fatal("absent is nothing readable:", none, err)
|
||||
}
|
||||
for _, bad := range []any{
|
||||
"agents", // not a list
|
||||
[]any{"agents; drop table x"}, // not a name
|
||||
[]any{"steps(id, created_at"}, // unbalanced
|
||||
[]any{"steps(id, \"x\")"}, // a quoted column
|
||||
[]any{"agents", "public.agents"}, // twice
|
||||
[]any{42}, // not a string
|
||||
[]any{"a.b.c"}, // three parts
|
||||
} {
|
||||
if _, err := ParseReadable(map[string]any{"readable": bad}); err == nil {
|
||||
t.Errorf("%v was accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretNames(t *testing.T) {
|
||||
for _, secret := range []string{"private", "signing_key", "sealed", "operator_sealed", "module_secret", "secret",
|
||||
"operator_key", "node_key", "overlay_key", "sealing_key", "password_hash", "passwd", "salt", "rands", "token",
|
||||
"refresh_token", "secret_data", "credential_data", "api_key", "apikey", "totp_secret", "otp", "key", "client_secret"} {
|
||||
if !SecretName(secret) {
|
||||
t.Errorf("%s is not taken for a secret", secret)
|
||||
}
|
||||
}
|
||||
for _, plain := range []string{"name", "created", "commit_hash", "status", "node", "keyboard", "monkey", "footprint",
|
||||
"description", "id", "retired", "public"} {
|
||||
if SecretName(plain) {
|
||||
t.Errorf("%s is taken for a secret", plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheReaderIsGivenOnlyWhatIsDeclaredAndNeverASecret(t *testing.T) {
|
||||
decl, _ := ParseReadable(map[string]any{"readable": []any{"node", "signing_key", "build(id, sealed, nope)", "gone"}})
|
||||
existing := []column{
|
||||
{"public", "node", "name"}, {"public", "node", "overlay_key"}, {"public", "node", "role"},
|
||||
{"public", "signing_key", "public"}, {"public", "signing_key", "private"},
|
||||
{"public", "build", "id"}, {"public", "build", "sealed"}, {"public", "build", "log"},
|
||||
}
|
||||
stmts, rep := planDatabase(decl, existing, held{Tables: map[table]string{}, Columns: map[column][]string{}}, map[string]bool{})
|
||||
sql := strings.Join(stmts, "\n")
|
||||
if !strings.Contains(sql, `GRANT SELECT ("name", "role") ON "public"."node" TO "mesh_store_reader"`) ||
|
||||
!strings.Contains(sql, `GRANT SELECT ("id") ON "public"."build" TO "mesh_store_reader"`) {
|
||||
t.Fatal(sql)
|
||||
}
|
||||
for _, never := range []string{"overlay_key", "private", "sealed", "signing_key", `"log"`} {
|
||||
if strings.Contains(sql, never) {
|
||||
t.Fatalf("%s granted:\n%s", never, sql)
|
||||
}
|
||||
}
|
||||
if len(rep.Refused) != 3 || len(rep.Missing) != 2 {
|
||||
t.Fatalf("refused %v, missing %v", rep.Refused, rep.Missing)
|
||||
}
|
||||
if strings.Join(rep.Readable, ",") != "public.build.id,public.node.name,public.node.role" {
|
||||
t.Fatal(rep.Readable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatTheReaderHoldsAndIsNotDeclaredIsRevoked(t *testing.T) {
|
||||
// An install before issue 337, or a grant by hand: a table-level grant, a column no longer
|
||||
// declared, and a declared column held with more than SELECT.
|
||||
h := held{
|
||||
Tables: map[table]string{{"public", "secret"}: "r", {"public", "seq"}: "S"},
|
||||
Columns: map[column][]string{
|
||||
{"public", "node", "name"}: {"SELECT"},
|
||||
{"public", "node", "role"}: {"SELECT", "UPDATE"},
|
||||
{"public", "node", "old"}: {"SELECT"},
|
||||
},
|
||||
}
|
||||
decl, _ := ParseReadable(map[string]any{"readable": []any{"node(name, role)"}})
|
||||
existing := []column{{"public", "node", "name"}, {"public", "node", "role"}, {"public", "node", "old"}}
|
||||
stmts, rep := planDatabase(decl, existing, h, map[string]bool{})
|
||||
want := []string{
|
||||
`REVOKE ALL ON TABLE "public"."secret" FROM "mesh_store_reader"`,
|
||||
`REVOKE ALL ON SEQUENCE "public"."seq" FROM "mesh_store_reader"`,
|
||||
`REVOKE ALL ("old", "role") ON "public"."node" FROM "mesh_store_reader"`,
|
||||
`GRANT SELECT ("role") ON "public"."node" TO "mesh_store_reader"`,
|
||||
}
|
||||
if strings.Join(stmts, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("got\n%s\nwant\n%s", strings.Join(stmts, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
if len(rep.Revoked) != 4 {
|
||||
t.Fatal(rep.Revoked)
|
||||
}
|
||||
// Undeclared: everything held goes, and nothing is granted.
|
||||
stmts, _ = planDatabase(nil, nil, h, map[string]bool{})
|
||||
for _, s := range stmts {
|
||||
if strings.HasPrefix(s, "GRANT") {
|
||||
t.Fatal(s)
|
||||
}
|
||||
}
|
||||
if len(stmts) != 3 {
|
||||
t.Fatal(stmts)
|
||||
}
|
||||
// Held exactly as declared: nothing to do.
|
||||
exact := held{Tables: map[table]string{}, Columns: map[column][]string{{"public", "node", "name"}: {"SELECT"}}}
|
||||
decl, _ = ParseReadable(map[string]any{"readable": []any{"node(name)"}})
|
||||
if stmts, _ := planDatabase(decl, existing, exact, map[string]bool{}); len(stmts) != 0 {
|
||||
t.Fatal(stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASchemaTheReaderCannotUseIsGrantedUsage(t *testing.T) {
|
||||
decl, _ := ParseReadable(map[string]any{"readable": []any{"audit.events"}})
|
||||
stmts, _ := planDatabase(decl, []column{{"audit", "events", "at"}}, held{Tables: map[table]string{}, Columns: map[column][]string{}},
|
||||
map[string]bool{"audit": true})
|
||||
if len(stmts) != 2 || stmts[0] != `GRANT USAGE ON SCHEMA "audit" TO "mesh_store_reader"` {
|
||||
t.Fatal(stmts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStatementNamingASecretIsRefusedBeforeItIsSent(t *testing.T) {
|
||||
conn := admin
|
||||
conn.ReaderPassword = "r"
|
||||
for _, c := range []struct{ db, sql string }{
|
||||
{"identity", "select private from signing_key"},
|
||||
{"identity", "SELECT * FROM Authority"},
|
||||
{"identity", `select * from "signing_key"`},
|
||||
{"inventory", "select count(*) from module_secret"},
|
||||
{"inventory", "select * from bus_membership"},
|
||||
{"inventory", "select query_to_xml('select * from secret', true, true, '')"},
|
||||
{"anything", "select operator_sealed from t"},
|
||||
{"anything", "select client_secret from t"},
|
||||
{"anything", "select * from pg_authid"},
|
||||
} {
|
||||
f, cl := newFake(conn)
|
||||
_, err := cl.ReadOnlyQuery(ctx, c.db, c.sql)
|
||||
if err == nil || !strings.Contains(err.Error(), "issue 337") {
|
||||
t.Errorf("%s in %s: %v", c.sql, c.db, err)
|
||||
}
|
||||
if len(f.calls) != 0 {
|
||||
t.Errorf("%s in %s was sent: %v", c.sql, c.db, f.calls)
|
||||
}
|
||||
}
|
||||
// Ordinary words pass: a table called secret elsewhere is the privileges' to refuse, not the words'.
|
||||
for _, ok := range []string{"select jsonb_object_keys(x) from t", "select name, created from node", "select token_count from usage"} {
|
||||
if why := refusedStatement("inventory", ok); why != "" {
|
||||
t.Errorf("%s: %s", ok, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAQueryIsRefusedWhileTheReaderCanReadASecret(t *testing.T) {
|
||||
conn := admin
|
||||
conn.ReaderPassword = "r"
|
||||
f, c := newFake(conn)
|
||||
f.answer(`has_column_privilege`, []string{"nspname", "relname", "attname"},
|
||||
[]string{"public", "users", "name"}, []string{"public", "users", "password_hash"})
|
||||
f.answer(`^SELECT name FROM users`, []string{"name"}, []string{"a"})
|
||||
_, err := c.ReadOnlyQuery(ctx, "app", "SELECT name FROM users")
|
||||
if err == nil || !strings.Contains(err.Error(), "public.users.password_hash") || strings.Contains(err.Error(), "users.name") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range f.calls {
|
||||
if k.SQL == "SELECT name FROM users" {
|
||||
t.Fatal("the statement ran")
|
||||
}
|
||||
}
|
||||
// The probe runs as the reader, in the database asked about.
|
||||
var probed bool
|
||||
for _, k := range f.calls {
|
||||
if strings.Contains(k.SQL, "has_column_privilege") && k.User == Reader && k.Database == "app" {
|
||||
probed = true
|
||||
}
|
||||
}
|
||||
if !probed {
|
||||
t.Fatal("not probed as the reader")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclarationsFromTheContributionsFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "mesh.json")
|
||||
doc := `{"requirement":"postgres-database","given":[
|
||||
{"as":"letta","secret":"s","values":{"name":"letta","readable":["agents"]}},
|
||||
{"as":"gitea","secret":"s","values":{"name":"gitea"}},
|
||||
{"as":"bad","secret":"s","values":{"readable":"agents"}}]}`
|
||||
if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := DeclarationsFrom(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d.Tables) != 1 || d.Tables["letta"][0].Table != "agents" || d.Malformed["bad"] == "" {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
if _, err := DeclarationsFrom(filepath.Join(dir, "absent")); err == nil {
|
||||
t.Fatal("an unreadable file is said")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileRevokesTheRolesTheReaderHoldsAndGrantsPerDatabase(t *testing.T) {
|
||||
conn := admin
|
||||
conn.ReaderPassword = "r"
|
||||
f, c := newFake(conn)
|
||||
f.answer(`FROM pg_auth_members`, []string{"rolname"}, []string{"pg_read_all_data"})
|
||||
f.answer(`SELECT datname FROM pg_database WHERE datallowconn`, []string{"datname"}, []string{"identity"}, []string{"letta"})
|
||||
f.answer(`aclexplode\(c\.relacl\)`, []string{"nspname", "relname", "relkind"})
|
||||
f.answer(`aclexplode\(a\.attacl\)`, []string{"nspname", "relname", "attname", "privilege_type"})
|
||||
f.answer(`IN \(\('public', 'agents'\)\)`, []string{"nspname", "relname", "attname"},
|
||||
[]string{"public", "agents", "id"}, []string{"public", "agents", "api_key"})
|
||||
f.answer(`has_schema_privilege`, []string{"nspname"})
|
||||
rep := c.ReconcileReader(ctx, Declarations{Tables: map[string][]ReadableTable{"letta": {{Schema: "public", Table: "agents"}}}}, "")
|
||||
if rep.Problem != "" || len(rep.Databases) != 2 {
|
||||
t.Fatalf("%+v", rep)
|
||||
}
|
||||
var revoked, granted bool
|
||||
for _, k := range f.calls {
|
||||
if k.SQL == `REVOKE "pg_read_all_data" FROM "mesh_store_reader"` && k.Database == "postgres" {
|
||||
revoked = true
|
||||
}
|
||||
if k.SQL == `GRANT SELECT ("id") ON "public"."agents" TO "mesh_store_reader"` && k.Database == "letta" {
|
||||
granted = true
|
||||
}
|
||||
if strings.Contains(k.SQL, "GRANT") && k.Database == "identity" {
|
||||
t.Fatal("granted in an undeclared database:", k.SQL)
|
||||
}
|
||||
if strings.Contains(k.SQL, "GRANT") && strings.Contains(k.SQL, "api_key") {
|
||||
t.Fatal("granted a secret:", k.SQL)
|
||||
}
|
||||
}
|
||||
if !revoked || !granted {
|
||||
t.Fatalf("revoked %v, granted %v: %v", revoked, granted, f.calls)
|
||||
}
|
||||
if last := c.LastReaderReport(); len(last.Databases) != 2 || last.Databases[1].Refused == nil {
|
||||
t.Fatalf("%+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// Every module in the catalogue that declares what the reader may read declares it in a shape the
|
||||
// provider reads: a typo is found here, not as a refusal on a live store.
|
||||
func TestTheCataloguesReadableDeclarationsParse(t *testing.T) {
|
||||
manifests, _ := filepath.Glob("../../../*/module.json")
|
||||
if len(manifests) == 0 {
|
||||
t.Skip("not in the catalogue")
|
||||
}
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Contributes map[string]json.RawMessage `json:"contributes"`
|
||||
}
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
continue
|
||||
}
|
||||
var values map[string]any
|
||||
if json.Unmarshal(m.Contributes["postgres-database"], &values) != nil {
|
||||
continue
|
||||
}
|
||||
decl, err := ParseReadable(values)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", path, err)
|
||||
}
|
||||
for _, d := range decl {
|
||||
if SecretName(d.Table) {
|
||||
t.Errorf("%s declares %s readable, whose name says it holds secrets; it would never be granted", path, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user