A reader given a privilege WITH GRANT OPTION that passed it on made the plain revoke fail with dependent privileges exist; the pass still listed the table as revoked and logged it every minute. The pass now retries such a change with CASCADE, which reaches only grants that came from the reader, reads the grants back, reports as revoked only what is gone, and says what is still held and why when anything is.
232 lines
8.6 KiB
Go
232 lines
8.6 KiB
Go
package main
|
|
|
|
// Against a real server (MESH_POSTGRES_LIVE), novox/hq issue 337: the reconcile gives the reader exactly
|
|
// what is declared and no secret, takes back what is no longer declared, and a grant it does not own
|
|
// that exposes a secret is found and refuses queries there.
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func columnReadable(t *testing.T, c *Client, db, tbl, col string) bool {
|
|
t.Helper()
|
|
r, err := c.Query(ctx, db, "SELECT has_column_privilege("+Literal(Reader)+", "+Literal(tbl)+", "+Literal(col)+", 'SELECT')")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cell(r.Rows[0], 0) == "t"
|
|
}
|
|
|
|
// secretsReadable is the probe every database is held to: the columns the reader can read whose names
|
|
// say they hold a secret, across every database the server holds.
|
|
func secretsReadable(t *testing.T, c *Client) []string {
|
|
t.Helper()
|
|
dbs, err := c.ListDatabases(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
probe := strings.Replace(ExposureProbe, "pg_catalog.has_column_privilege(c.oid", "pg_catalog.has_column_privilege("+Literal(Reader)+", c.oid", 1)
|
|
var out []string
|
|
for _, db := range dbs {
|
|
r, err := c.Query(ctx, db.Name, probe)
|
|
if err != nil {
|
|
t.Fatal(db.Name, err)
|
|
}
|
|
for _, e := range Exposed(r) {
|
|
out = append(out, db.Name+": "+e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestLiveTheReaderReadsWhatIsDeclaredAndNoSecret(t *testing.T) {
|
|
c := liveClient(t)
|
|
freshStore(t, c)
|
|
defer dropStore(c)
|
|
|
|
if got := secretsReadable(t, c); len(got) == 0 {
|
|
t.Fatal("the install before the fix was not reproduced: no secret readable")
|
|
}
|
|
decl := Declarations{Tables: map[string][]ReadableTable{liveStore: {
|
|
{Schema: "public", Table: "notes"},
|
|
{Schema: "public", Table: "vault"}, // every column: private is still refused, by its name
|
|
{Schema: "audit", Table: "events"},
|
|
}}}
|
|
rep := c.ReconcileReader(ctx, decl, "")
|
|
if rep.Problem != "" {
|
|
t.Fatal(rep.Problem)
|
|
}
|
|
if got := secretsReadable(t, c); len(got) != 0 {
|
|
t.Fatalf("after the reconcile the reader can read %v", got)
|
|
}
|
|
for _, want := range [][3]string{{liveStore, "notes", "body"}, {liveStore, "vault", "name"}, {liveStore, "audit.events", "what"}} {
|
|
if !columnReadable(t, c, want[0], want[1], want[2]) {
|
|
t.Errorf("%v is declared and not readable", want)
|
|
}
|
|
}
|
|
if columnReadable(t, c, liveStore, "vault", "private") || columnReadable(t, c, "identity", "signing_key", "public") {
|
|
t.Fatal("a secret, or an undeclared database, is readable")
|
|
}
|
|
r, err := c.ReadOnlyQuery(ctx, liveStore, "SELECT name FROM vault")
|
|
if err != nil || len(r.Rows) != 1 {
|
|
t.Fatal("a declared column cannot be read:", r, err)
|
|
}
|
|
if _, err := c.ReadOnlyQuery(ctx, liveStore, "SELECT * FROM vault"); err == nil {
|
|
t.Fatal("SELECT * read past the refused column")
|
|
}
|
|
if _, err := c.ReadOnlyQuery(ctx, "identity", "SELECT id FROM signing_key"); err == nil {
|
|
t.Fatal("the signing key's table answered")
|
|
}
|
|
// The same declaration again changes nothing.
|
|
again := c.ReconcileReader(ctx, decl, "")
|
|
for _, db := range again.Databases {
|
|
if len(db.Revoked) > 0 || db.Error != "" {
|
|
t.Fatalf("a second pass changed %s: %+v", db.Database, db)
|
|
}
|
|
}
|
|
|
|
// Narrowed: notes is no longer declared, and is taken back.
|
|
decl.Tables[liveStore] = []ReadableTable{{Schema: "public", Table: "vault", Columns: []string{"name"}}}
|
|
c.ReconcileReader(ctx, decl, "")
|
|
if columnReadable(t, c, liveStore, "notes", "body") || columnReadable(t, c, liveStore, "audit.events", "what") {
|
|
t.Fatal("a table no longer declared is still readable")
|
|
}
|
|
|
|
// A grant the provider does not own exposes a secret: found, said, and queries there refused.
|
|
if _, err := c.Query(ctx, liveStore, "GRANT SELECT ON vault TO PUBLIC"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rep = c.ReconcileReader(ctx, decl, "")
|
|
var exposed []string
|
|
for _, db := range rep.Databases {
|
|
if db.Database == liveStore {
|
|
exposed = db.Exposed
|
|
}
|
|
}
|
|
if strings.Join(exposed, ",") != "public.vault.private" {
|
|
t.Fatalf("exposure not found: %+v", rep.Databases)
|
|
}
|
|
if _, err := c.ReadOnlyQuery(ctx, liveStore, "SELECT name FROM vault"); err == nil || !strings.Contains(err.Error(), "public.vault.private") {
|
|
t.Fatal("a query ran while the reader can read a secret:", err)
|
|
}
|
|
}
|
|
|
|
// A grant to the reader made by a role holding GRANT OPTION — not the owner — is taken back as that
|
|
// role, and what still stands after a pass is a failure, not "revoked". SECURITY DEFINER functions and
|
|
// views the reader may use are reported.
|
|
func TestLiveAGrantByAnotherGrantorIsTakenBackAndDefinersAreSaid(t *testing.T) {
|
|
c := liveClient(t)
|
|
freshStore(t, c)
|
|
defer dropStore(c)
|
|
for _, sql := range []string{
|
|
"DROP ROLE IF EXISTS mesh_test_owner", "DROP ROLE IF EXISTS mesh_test_middle",
|
|
} {
|
|
if _, err := c.Query(ctx, "", sql); err != nil {
|
|
t.Fatal(sql, err)
|
|
}
|
|
}
|
|
defer func() {
|
|
_, _ = c.Query(ctx, "", "DROP DATABASE IF EXISTS "+Ident(liveStore)+" WITH (FORCE)")
|
|
_, _ = c.Query(ctx, "", "DROP ROLE IF EXISTS mesh_test_middle")
|
|
_, _ = c.Query(ctx, "", "DROP ROLE IF EXISTS mesh_test_owner")
|
|
}()
|
|
for _, sql := range []string{
|
|
"CREATE ROLE mesh_test_owner", "CREATE ROLE mesh_test_middle",
|
|
} {
|
|
if _, err := c.Query(ctx, "", sql); err != nil {
|
|
t.Fatal(sql, err)
|
|
}
|
|
}
|
|
for _, sql := range []string{
|
|
"ALTER TABLE notes OWNER TO mesh_test_owner",
|
|
"GRANT SELECT ON notes TO mesh_test_middle WITH GRANT OPTION",
|
|
"SET ROLE mesh_test_middle; GRANT SELECT ON notes TO " + Ident(Reader) + "; GRANT SELECT (body) ON notes TO " + Ident(Reader) + "; RESET ROLE",
|
|
"CREATE FUNCTION peek() RETURNS bytea LANGUAGE sql SECURITY DEFINER AS 'SELECT private FROM vault LIMIT 1'",
|
|
"CREATE VIEW plain_names AS SELECT name FROM vault",
|
|
"GRANT SELECT ON plain_names TO " + Ident(Reader),
|
|
} {
|
|
if _, err := c.Query(ctx, liveStore, sql); err != nil {
|
|
t.Fatal(sql, err)
|
|
}
|
|
}
|
|
var mine DatabaseReport
|
|
for _, db := range c.ReconcileReader(ctx, Declarations{}, "").Databases {
|
|
if db.Database == liveStore {
|
|
mine = db
|
|
}
|
|
}
|
|
if mine.Error != "" {
|
|
t.Fatal("left standing:", mine.Error)
|
|
}
|
|
if columnReadable(t, c, liveStore, "notes", "body") {
|
|
t.Fatal("the other grantor's grant survived")
|
|
}
|
|
if strings.Join(mine.Definers, ",") != "public.peek()" || strings.Join(mine.Views, ",") != "" {
|
|
t.Fatalf("definers %v, views %v", mine.Definers, mine.Views)
|
|
}
|
|
// A second pass finds nothing to take back.
|
|
for _, db := range c.ReconcileReader(ctx, Declarations{}, "").Databases {
|
|
if db.Database == liveStore && (len(db.Revoked) > 0 || db.Error != "") {
|
|
t.Fatalf("%+v", db)
|
|
}
|
|
}
|
|
// A view granted to PUBLIC is said.
|
|
if _, err := c.Query(ctx, liveStore, "GRANT SELECT ON plain_names TO PUBLIC"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, db := range c.ReconcileReader(ctx, Declarations{}, "").Databases {
|
|
if db.Database == liveStore && strings.Join(db.Views, ",") != "public.plain_names" {
|
|
t.Fatalf("views %v", db.Views)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The reader was given a table WITH GRANT OPTION and passed it on. A plain revoke then fails with
|
|
// "dependent privileges exist" and changes nothing; the pass must take back the reader's grant and
|
|
// what the reader passed on (CASCADE reaches only grants that came from the reader), report as
|
|
// revoked only what the read-back shows gone, and say what is still held and why if anything is.
|
|
func TestLiveAGrantTheReaderPassedOnIsTakenBack(t *testing.T) {
|
|
c := liveClient(t)
|
|
freshStore(t, c)
|
|
defer dropStore(c)
|
|
defer func() { _, _ = c.Query(ctx, "", "DROP ROLE IF EXISTS mesh_test_onward") }()
|
|
if _, err := c.Query(ctx, "", "DROP ROLE IF EXISTS mesh_test_onward; CREATE ROLE mesh_test_onward"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, sql := range []string{
|
|
"REVOKE ALL ON vault FROM " + Ident(Reader),
|
|
"GRANT SELECT ON notes TO " + Ident(Reader) + " WITH GRANT OPTION",
|
|
"SET ROLE " + Ident(Reader) + "; GRANT SELECT ON notes TO mesh_test_onward; RESET ROLE",
|
|
} {
|
|
if _, err := c.Query(ctx, liveStore, sql); err != nil {
|
|
t.Fatal(sql, err)
|
|
}
|
|
}
|
|
var mine DatabaseReport
|
|
for _, db := range c.ReconcileReader(ctx, Declarations{}, "").Databases {
|
|
if db.Database == liveStore {
|
|
mine = db
|
|
}
|
|
}
|
|
if mine.Error != "" {
|
|
t.Fatal("left standing:", mine.Error)
|
|
}
|
|
if columnReadable(t, c, liveStore, "notes", "body") {
|
|
t.Fatalf("the reader still reads notes, and the pass said revoked %v", mine.Revoked)
|
|
}
|
|
if strings.Join(mine.Revoked, ",") != "public.notes" {
|
|
t.Fatalf("revoked %v", mine.Revoked)
|
|
}
|
|
r, err := c.Query(ctx, liveStore, "SELECT has_table_privilege('mesh_test_onward', 'notes', 'SELECT')")
|
|
if err != nil || cell(r.Rows[0], 0) != "f" {
|
|
t.Fatal("what the reader passed on survived:", r.Rows, err)
|
|
}
|
|
// The next pass finds nothing held, and says nothing revoked.
|
|
for _, db := range c.ReconcileReader(ctx, Declarations{}, "").Databases {
|
|
if db.Database == liveStore && (len(db.Revoked) > 0 || db.Error != "") {
|
|
t.Fatalf("%+v", db)
|
|
}
|
|
}
|
|
}
|