diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index e1eed7a80..28108e6a7 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -72,7 +72,6 @@ jobs: run: | mkdir -p .bench git worktree add .bench/base "${{ github.event.pull_request.base.sha }}" - cp bench/bench_test.go .bench/base/bench/bench_test.go - name: Build benchmark (base) run: | cd .bench/base diff --git a/AGENTS.md b/AGENTS.md index 9cd7e6289..ed972f09f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,10 +15,11 @@ RLS policies filter database rows based on JWT claims passed via PostgREST, ensu ### Policy Patterns -**Direct Policies**: Tables with direct RLS use the `match_scope()` function to evaluate JWT claims against row attributes (tags, agents, names, id). +**Direct Policies**: Tables with direct RLS default to `match_scope()` against JWT claims. -- Examples: `config_items`, `canaries`, `components`, `playbooks` -- Policy checks row attributes directly using `match_scope(jwt_claims, row.tags, row.agent_id, row.name, row.id)` +- Examples: `config_items`, `canaries`, `components`, `playbooks`, `views` +- Default policy uses `match_scope(claims, tags, agent_id, name, id)` with wildcard support. +- When `rls.precomputed_scope` is enabled at migration time, policies switch to `__scope && rls_scope_access()` instead. **Inherited Policies**: Child tables inherit access control from their parent using `EXISTS` clauses. @@ -29,7 +30,8 @@ RLS policies filter database rows based on JWT claims passed via PostgREST, ensu 1. Add RLS enable logic to `@views/9998_rls_enable.sql` - Enable RLS on the table - - Create the policy (either direct with `match_scope()` or inherited with `EXISTS`) + - Create the default match_scope policy (or inherited with `EXISTS`) + - If needed, mirror the change in `@views/9998_rls_enable_precomputed.sql` for precomputed mode 2. Add counterpart disable logic to `@views/9999_rls_disable.sql` - Disable RLS on the table - Drop the policy @@ -42,7 +44,7 @@ RLS policies filter database rows based on JWT claims passed via PostgREST, ensu The RLS policies work by injecting JWT claims into PostgreSQL session variables via `request.jwt.claims`. The flow is: -- Go code builds an RLS Payload (scopes for config, component, playbook, canary, view) in `@rls/payload.go` +- Go code builds an RLS Payload (scope UUIDs + wildcard scopes) in `@rls/payload.go` - `SetPostgresSessionRLS()` serializes the Payload to JSON and executes: `SET request.jwt.claims TO ` - PostgreSQL RLS policies read `(current_setting('request.jwt.claims')::jsonb)` to enforce access control diff --git a/Makefile b/Makefile index d149c5998..b5e8bdf1d 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,10 @@ ginkgo: go install github.com/onsi/ginkgo/v2/ginkgo test: ginkgo + # cleanup git directories that were downloaded from previous test run + # cuz we don't want to run their unit tests + rm -rf tests/e2e/exec-checkout + ginkgo -r -v --skip-package=tests/e2e .PHONY: test-e2e diff --git a/api/config.go b/api/config.go index bf04567f8..3dccd0a13 100644 --- a/api/config.go +++ b/api/config.go @@ -10,12 +10,13 @@ import ( var DefaultConfig = Config{ Postgrest: PostgrestConfig{ - Version: "v10.0.0", - DBRole: "postgrest_api", - AnonDBRole: "", - Port: 3000, - AdminPort: 3001, - MaxRows: 2000, + Version: "v10.0.0", + DBRole: "postgrest_api", + DBRoleBypass: "rls_bypasser", + AnonDBRole: "", + Port: 3000, + AdminPort: 3001, + MaxRows: 2000, }, } @@ -123,15 +124,22 @@ func (c Config) GetUsername() string { } type PostgrestConfig struct { - Port int - Disable bool - LogLevel string - URL string - Version string - JWTSecret string - DBRole string + Port int + Disable bool + LogLevel string + URL string + Version string + JWTSecret string + AdminPort int + + // DBRole is the PostgREST role used for authenticated requests. + DBRole string + + // DBRoleBypass is the PostgREST role used to bypass RLS for admin requests. + DBRoleBypass string + + // AnonDBRole is the PostgREST role used for unauthenticated requests. AnonDBRole string - AdminPort int // A hard limit to the number of rows PostgREST will fetch from a view, table, or stored procedure. // Limits payload size for accidental or malicious requests. diff --git a/bench/bench_test.go b/bench/bench_test.go index eacf4eac4..8e25fb88b 100644 --- a/bench/bench_test.go +++ b/bench/bench_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/flanksource/commons/logger" + "github.com/google/uuid" "github.com/flanksource/duty/context" pkgRLS "github.com/flanksource/duty/rls" @@ -165,9 +166,9 @@ func runBenchmark(b *testing.B, config DistinctBenchConfig) { var payload pkgRLS.Payload if rls { b.StopTimer() - payload = pkgRLS.Payload{Config: []pkgRLS.Scope{{Tags: sampleTags[i%len(sampleTags)]}}} + payload = pkgRLS.Payload{Scopes: []uuid.UUID{benchScopeIDs[i%len(benchScopeIDs)]}} if err := payload.SetGlobalPostgresSessionRLS(testCtx.DB()); err != nil { - b.Fatalf("failed to setup rls payload with tag(%v): %v", payload, err) + b.Fatalf("failed to setup rls payload with scope(%v): %v", payload, err) } if err := verifyRLSPayload(testCtx); err != nil { diff --git a/bench/utils_test.go b/bench/utils_test.go index f436da77c..9464b5ce4 100644 --- a/bench/utils_test.go +++ b/bench/utils_test.go @@ -2,6 +2,7 @@ package bench_test import ( "database/sql" + "encoding/json" "errors" "fmt" "testing" @@ -28,6 +29,8 @@ var sampleTags = []map[string]string{ {"region": "us-east-2"}, } +var benchScopeIDs []uuid.UUID + func generateConfigItems(ctx context.Context, count int) error { var iter int for { @@ -118,6 +121,25 @@ func setupConfigsForSize(ctx context.Context, size int) ([]uuid.UUID, error) { return nil, fmt.Errorf("failed to generate configs: %w", err) } + benchScopeIDs = make([]uuid.UUID, len(sampleTags)) + for i, tag := range sampleTags { + scopeID := uuid.New() + benchScopeIDs[i] = scopeID + tagJSON, err := json.Marshal(tag) + if err != nil { + return nil, fmt.Errorf("failed to serialize bench scope tag: %w", err) + } + + if err := ctx.DB().Exec(` + UPDATE config_items + SET __scope = array_append(COALESCE(__scope, '{}'::uuid[]), ?) + WHERE tags @> ?::jsonb + AND NOT (COALESCE(__scope, '{}'::uuid[]) @> ARRAY[?]::uuid[]) + `, scopeID, string(tagJSON), scopeID).Error; err != nil { + return nil, fmt.Errorf("failed to materialize bench scope: %w", err) + } + } + var configIDs []uuid.UUID if err := ctx.DB().Select("id").Model(&models.ConfigItem{}).Find(&configIDs).Error; err != nil { return nil, err diff --git a/db.go b/db.go index 4c9c59db3..f42739e22 100644 --- a/db.go +++ b/db.go @@ -11,6 +11,7 @@ import ( "github.com/flanksource/commons/logger" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" + "github.com/lib/pq" gormpostgres "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -255,19 +256,25 @@ func verifyKratosMigration(db *gorm.DB) error { func setStatementTimeouts(ctx dutyContext.Context, config api.Config) { postgrestTimeout := ctx.Properties().Duration("db.postgrest.timeout", 1*time.Minute) - if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, config.Postgrest.DBRole, postgrestTimeout.Seconds())).Error; err != nil { + if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, pq.QuoteIdentifier(config.Postgrest.DBRole), postgrestTimeout.Seconds())).Error; err != nil { logger.Errorf(err.Error()) } + if config.Postgrest.DBRoleBypass != "" { + if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, pq.QuoteIdentifier(config.Postgrest.DBRoleBypass), postgrestTimeout.Seconds())).Error; err != nil { + logger.Errorf(err.Error()) + } + } + if config.Postgrest.AnonDBRole != "" { - if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, config.Postgrest.AnonDBRole, postgrestTimeout.Seconds())).Error; err != nil { + if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, pq.QuoteIdentifier(config.Postgrest.AnonDBRole), postgrestTimeout.Seconds())).Error; err != nil { logger.Errorf(err.Error()) } } statementTimeout := ctx.Properties().Duration("db.connection.timeout", 1*time.Hour) if username := config.GetUsername(); username != "" { - if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, username, statementTimeout.Seconds())).Error; err != nil { + if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, pq.QuoteIdentifier(username), statementTimeout.Seconds())).Error; err != nil { logger.Errorf(err.Error()) } } diff --git a/migrate/migrate.go b/migrate/migrate.go index 16761cf4a..d650ddc6d 100644 --- a/migrate/migrate.go +++ b/migrate/migrate.go @@ -13,6 +13,7 @@ import ( "github.com/flanksource/commons/logger" "github.com/flanksource/commons/properties" + "github.com/lib/pq" "github.com/samber/lo" "github.com/samber/oops" @@ -232,7 +233,7 @@ func createRole(db *sql.DB, roleName string, config api.Config, grants ...string if err := db.QueryRow("SELECT count(*) FROM pg_catalog.pg_roles WHERE rolname = $1 LIMIT 1", roleName).Scan(&count); err != nil { return err } else if count == 0 { - if _, err := db.Exec(fmt.Sprintf("CREATE ROLE %s", roleName)); err != nil { + if _, err := db.Exec(fmt.Sprintf("CREATE ROLE %s", pq.QuoteIdentifier(roleName))); err != nil { return err } else { log.Infof("Created role %s", roleName) @@ -245,7 +246,7 @@ func createRole(db *sql.DB, roleName string, config api.Config, grants ...string if granted, err := checkIfRoleIsGranted(db, roleName, user); err != nil { return err } else if !granted { - if _, err := db.Exec(fmt.Sprintf(`GRANT %s TO "%s"`, roleName, user)); err != nil { + if _, err := db.Exec(fmt.Sprintf(`GRANT %s TO "%s"`, pq.QuoteIdentifier(roleName), user)); err != nil { log.Errorf("Failed to grant role %s to %s", roleName, user) } else { log.Infof("Granted %s to %s", roleName, user) @@ -254,7 +255,7 @@ func createRole(db *sql.DB, roleName string, config api.Config, grants ...string } for _, grant := range grants { - if _, err := db.Exec(fmt.Sprintf(grant, roleName)); err != nil { + if _, err := db.Exec(fmt.Sprintf(grant, pq.QuoteIdentifier(roleName))); err != nil { log.Errorf("Failed to apply grant[%s] for %s: %+v", grant, roleName, err) } } @@ -270,6 +271,19 @@ func grantPostgrestRolesToCurrentUser(pool *sql.DB, config api.Config) error { "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO %s"); err != nil { return err } + + if config.Postgrest.DBRoleBypass != "" { + if err := createRole(pool, config.Postgrest.DBRoleBypass, config, + "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO %s", + "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO %s", + "GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO %s", + "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO %s"); err != nil { + return err + } + if _, err := pool.Exec(fmt.Sprintf("ALTER ROLE %s BYPASSRLS", pq.QuoteIdentifier(config.Postgrest.DBRoleBypass))); err != nil { + logger.GetLogger("migrate").Errorf("Failed to set BYPASSRLS for role %s: %v", config.Postgrest.DBRoleBypass, err) + } + } if err := createRole(pool, config.Postgrest.AnonDBRole, config, "GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s", "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s"); err != nil { diff --git a/rls/payload.go b/rls/payload.go index 1d98208de..71bad9770 100644 --- a/rls/payload.go +++ b/rls/payload.go @@ -8,6 +8,7 @@ import ( "github.com/flanksource/commons/collections" "github.com/flanksource/commons/hash" + "github.com/google/uuid" "github.com/lib/pq" "gorm.io/gorm" ) @@ -46,8 +47,7 @@ type Payload struct { View []Scope `json:"view,omitempty"` // Scopes contains the list of scope UUIDs the user has access to. - // This is used for generated view tables only (for now). - Scopes []string `json:"scopes,omitempty"` + Scopes []uuid.UUID `json:"scopes,omitempty"` Disable bool `json:"disable_rls,omitempty"` } @@ -102,9 +102,11 @@ func (t *Payload) EvalFingerprint() { } } - // Include scope UUIDs in fingerprint if len(t.Scopes) > 0 { - scopesCopy := slices.Clone(t.Scopes) + scopesCopy := make([]string, 0, len(t.Scopes)) + for _, scope := range t.Scopes { + scopesCopy = append(scopesCopy, scope.String()) + } slices.Sort(scopesCopy) parts = append(parts, strings.Join(scopesCopy, ",")) } @@ -128,15 +130,26 @@ func (t *Payload) Fingerprint() string { // Injects the payload as local parameter func (t Payload) SetPostgresSessionRLS(db *gorm.DB) error { - return t.setPostgresSessionRLS(db, true) + return t.setPostgresSessionRLS(db, true, "postgrest_api") } // Injects the payload as sessions parameter func (t Payload) SetGlobalPostgresSessionRLS(db *gorm.DB) error { - return t.setPostgresSessionRLS(db, false) + return t.setPostgresSessionRLS(db, false, "postgrest_api") +} + +func (t Payload) SetPostgresSessionRLSWithRole(db *gorm.DB, role string) error { + return t.setPostgresSessionRLS(db, true, role) } -func (t Payload) setPostgresSessionRLS(db *gorm.DB, local bool) error { +func (t Payload) SetGlobalPostgresSessionRLSWithRole(db *gorm.DB, role string) error { + return t.setPostgresSessionRLS(db, false, role) +} + +func (t Payload) setPostgresSessionRLS(db *gorm.DB, local bool, role string) error { + if role == "" { + return fmt.Errorf("role is required") + } rlsJSON, err := json.Marshal(t) if err != nil { return fmt.Errorf("failed to marshall to json: %w", err) @@ -147,7 +160,7 @@ func (t Payload) setPostgresSessionRLS(db *gorm.DB, local bool) error { scope = "LOCAL" } - if err := db.Exec(fmt.Sprintf("SET %s ROLE postgrest_api", scope)).Error; err != nil { + if err := db.Exec(fmt.Sprintf("SET %s ROLE %s", scope, pq.QuoteIdentifier(role))).Error; err != nil { return fmt.Errorf("failed to set role: %w", err) } diff --git a/schema/checks.hcl b/schema/checks.hcl index ab9333e99..58aff641a 100644 --- a/schema/checks.hcl +++ b/schema/checks.hcl @@ -22,6 +22,10 @@ table "canaries" { null = true type = jsonb } + column "__scope" { + null = true + type = sql("uuid[]") + } column "annotations" { null = true type = jsonb @@ -84,6 +88,10 @@ table "canaries" { index "canaries_source_idx" { columns = [column.source] } + index "canaries__scope_gin_idx" { + columns = [column.__scope] + type = GIN + } } table "check_statuses" { diff --git a/schema/components.hcl b/schema/components.hcl index 8f7b72ca8..9e090918d 100644 --- a/schema/components.hcl +++ b/schema/components.hcl @@ -197,6 +197,10 @@ table "components" { null = true type = jsonb } + column "__scope" { + null = true + type = sql("uuid[]") + } column "hidden" { null = false type = boolean @@ -364,6 +368,10 @@ table "components" { columns = [column.properties] type = GIN } + index "components__scope_gin_idx" { + columns = [column.__scope] + type = GIN + } index "components_topology_id_type_name_parent_id_key" { unique = true columns = [column.topology_id, column.type, column.name, column.parent_id] diff --git a/schema/config.hcl b/schema/config.hcl index 45af62a4b..75454ee24 100644 --- a/schema/config.hcl +++ b/schema/config.hcl @@ -308,6 +308,10 @@ table "config_items" { type = jsonb comment = "contains a list of tags" } + column "__scope" { + null = true + type = sql("uuid[]") + } column "tags_values" { null = true type = jsonb @@ -408,6 +412,10 @@ table "config_items" { columns = [column.tags] type = GIN } + index "config_items__scope_gin_idx" { + columns = [column.__scope] + type = GIN + } index "idx_config_items_tags_values" { columns = [column.tags_values] type = GIN diff --git a/schema/playbooks.hcl b/schema/playbooks.hcl index 93a9ff08d..88cdc4a90 100644 --- a/schema/playbooks.hcl +++ b/schema/playbooks.hcl @@ -31,6 +31,10 @@ table "playbooks" { null = false type = jsonb } + column "__scope" { + null = true + type = sql("uuid[]") + } column "created_by" { null = true type = uuid @@ -66,6 +70,10 @@ table "playbooks" { columns = [column.namespace, column.name, column.category] where = "deleted_at IS NULL" } + index "playbooks__scope_gin_idx" { + columns = [column.__scope] + type = GIN + } foreign_key "playbook_created_by_fkey" { columns = [column.created_by] ref_columns = [table.people.column.id] diff --git a/schema/views.hcl b/schema/views.hcl index c8e4f0bfc..add5c9888 100644 --- a/schema/views.hcl +++ b/schema/views.hcl @@ -26,6 +26,10 @@ table "views" { null = true type = jsonb } + column "__scope" { + null = true + type = sql("uuid[]") + } column "created_by" { null = true type = uuid @@ -61,6 +65,10 @@ table "views" { on_update = NO_ACTION on_delete = NO_ACTION } + index "views__scope_gin_idx" { + columns = [column.__scope] + type = GIN + } } table "view_panels" { @@ -109,4 +117,4 @@ table "view_panels" { index "idx_view_panels_request_fingerprint" { columns = [column.view_id, column.request_fingerprint] } -} \ No newline at end of file +} diff --git a/tests/rls_test.go b/tests/rls_test.go index 3c54e3c87..ee99bbc1f 100644 --- a/tests/rls_test.go +++ b/tests/rls_test.go @@ -2219,8 +2219,8 @@ var _ = Describe("RLS test", Ordered, ContinueOnFailure, func() { var _ = Describe("view_panels query", func() { var ( - tx *gorm.DB - totalViewPanels int64 + tx *gorm.DB + totalViewPanels int64 podViewPanelCount int64 devViewPanelCount int64 ) diff --git a/types/resource_selector.go b/types/resource_selector.go index a3d7f40fc..84404b5ff 100644 --- a/types/resource_selector.go +++ b/types/resource_selector.go @@ -328,10 +328,6 @@ func (rs ResourceSelector) Matches(s ResourceSelectable) bool { return false } - if rs.Wildcard() { - return true - } - peg := rs.ToPeg(true) if peg == "" { return false diff --git a/views/035_rls_utils.sql b/views/035_rls_utils.sql index 958d7e15d..9edc82f8f 100644 --- a/views/035_rls_utils.sql +++ b/views/035_rls_utils.sql @@ -1,4 +1,4 @@ --- isolated from 9998_rls_enable.sql because generated tables in the view use it. +-- is_rls_disabled checks JWT claims for disable flag. CREATE OR REPLACE FUNCTION is_rls_disabled() RETURNS BOOLEAN AS $$ DECLARE @@ -9,4 +9,22 @@ BEGIN OR jwt_claims = '' OR jwt_claims::jsonb ->> 'disable_rls' IS NOT NULL); END; -$$ LANGUAGE plpgsql SECURITY INVOKER; \ No newline at end of file +$$ LANGUAGE plpgsql SECURITY INVOKER; + +-- rls_scope_access returns scope UUIDs from request.jwt.claims (empty when missing). +CREATE +OR REPLACE FUNCTION rls_scope_access() RETURNS UUID[] AS $$ +DECLARE + jwt_claims TEXT; +BEGIN + jwt_claims := current_setting('request.jwt.claims', TRUE); + IF jwt_claims IS NULL OR jwt_claims = '' THEN + RETURN '{}'::uuid[]; + END IF; + + RETURN COALESCE( + ARRAY(SELECT jsonb_array_elements_text(jwt_claims::jsonb -> 'scopes')::uuid), + '{}'::uuid[] + ); +END; +$$ LANGUAGE plpgsql STABLE SECURITY INVOKER; diff --git a/views/043_scope_permission_events.sql b/views/043_scope_permission_events.sql new file mode 100644 index 000000000..6131b83d9 --- /dev/null +++ b/views/043_scope_permission_events.sql @@ -0,0 +1,64 @@ +-- Emits scope/permission materialization events into event_queue. +-- This keeps precomputed __scope columns in sync for both CRD and UI writes +-- by delegating all updates to the application event processor. +CREATE OR REPLACE FUNCTION insert_scope_materialization_event() +RETURNS TRIGGER AS $$ +DECLARE + action TEXT; +BEGIN + IF TG_OP = 'INSERT' THEN + action := 'rebuild'; + ELSIF TG_OP = 'UPDATE' THEN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + action := 'remove'; + ELSE + action := 'rebuild'; + END IF; + ELSE + RETURN NEW; + END IF; + + INSERT INTO event_queue(name, properties) + VALUES ('scope.materialize', jsonb_build_object('id', NEW.id::text, 'action', action)) + ON CONFLICT (name, properties) DO UPDATE + SET created_at = NOW(), last_attempt = NULL, attempts = 0; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER scopes_materialize_event_trigger +AFTER INSERT OR UPDATE ON scopes +FOR EACH ROW +EXECUTE FUNCTION insert_scope_materialization_event(); + +CREATE OR REPLACE FUNCTION insert_permission_materialization_event() +RETURNS TRIGGER AS $$ +DECLARE + action TEXT; +BEGIN + IF TG_OP = 'INSERT' THEN + action := 'rebuild'; + ELSIF TG_OP = 'UPDATE' THEN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + action := 'remove'; + ELSE + action := 'rebuild'; + END IF; + ELSE + RETURN NEW; + END IF; + + INSERT INTO event_queue(name, properties) + VALUES ('permission.materialize', jsonb_build_object('id', NEW.id::text, 'action', action)) + ON CONFLICT (name, properties) DO UPDATE + SET created_at = NOW(), last_attempt = NULL, attempts = 0; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER permissions_materialize_event_trigger +AFTER INSERT OR UPDATE ON permissions +FOR EACH ROW +EXECUTE FUNCTION insert_permission_materialization_event(); diff --git a/views/9998_rls_enable_precomputed.sql b/views/9998_rls_enable_precomputed.sql new file mode 100644 index 000000000..b3aeac1dc --- /dev/null +++ b/views/9998_rls_enable_precomputed.sql @@ -0,0 +1,262 @@ +-- Enable RLS for tables +DO $$ +BEGIN + IF NOT (SELECT relrowsecurity FROM pg_class WHERE relname = 'config_items') THEN + EXECUTE 'ALTER TABLE config_items ENABLE ROW LEVEL SECURITY;'; + END IF; + + IF NOT (SELECT relrowsecurity FROM pg_class WHERE relname = 'config_changes') THEN + EXECUTE 'ALTER TABLE config_changes ENABLE ROW LEVEL SECURITY;'; + END IF; + + IF NOT (SELECT relrowsecurity FROM pg_class WHERE relname = 'config_analysis') THEN + EXECUTE 'ALTER TABLE config_analysis ENABLE ROW LEVEL SECURITY;'; + END IF; + + IF NOT (SELECT relrowsecurity FROM pg_class WHERE relname = 'components') THEN + EXECUTE 'ALTER TABLE components ENABLE ROW LEVEL SECURITY;'; + END IF; + + IF NOT (SELECT relrowsecurity FROM pg_class WHERE relname = 'config_component_relationships') THEN + EXECUTE 'ALTER TABLE config_component_relationships ENABLE ROW LEVEL SECURITY;'; + END IF; + + IF NOT (SELECT relrowsecurity FROM pg_class WHERE relname = 'config_relationships') THEN + EXECUTE 'ALTER TABLE config_relationships ENABLE ROW LEVEL SECURITY;'; + END IF; + + IF NOT (SELECT relrowsecurity FROM pg_class WHERE relname = 'canaries') THEN + EXECUTE 'ALTER TABLE canaries ENABLE ROW LEVEL SECURITY;'; + END IF; + + IF NOT (SELECT relrowsecurity FROM pg_class WHERE relname = 'playbooks') THEN + EXECUTE 'ALTER TABLE playbooks ENABLE ROW LEVEL SECURITY;'; + END IF; + + IF NOT (SELECT relrowsecurity FROM pg_class WHERE relname = 'playbook_runs') THEN + EXECUTE 'ALTER TABLE playbook_runs ENABLE ROW LEVEL SECURITY;'; + END IF; + + IF NOT (SELECT relrowsecurity FROM pg_class WHERE relname = 'checks') THEN + EXECUTE 'ALTER TABLE checks ENABLE ROW LEVEL SECURITY;'; + END IF; + + -- Another relation called "views" exists in the information_schema schema. + IF NOT (SELECT c.relrowsecurity FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid WHERE c.relname = 'views' AND n.nspname = 'public') THEN + EXECUTE 'ALTER TABLE views ENABLE ROW LEVEL SECURITY;'; + END IF; + + IF NOT (SELECT relrowsecurity FROM pg_class WHERE relname = 'view_panels') THEN + EXECUTE 'ALTER TABLE view_panels ENABLE ROW LEVEL SECURITY;'; + END IF; +END $$; + +-- Policy config items +DROP POLICY IF EXISTS config_items_auth ON config_items; + +CREATE POLICY config_items_auth ON config_items + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE (config_items.__scope && rls_scope_access()) + END + ); + +-- Policy config_changes +DROP POLICY IF EXISTS config_changes_auth ON config_changes; + +CREATE POLICY config_changes_auth ON config_changes + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE EXISTS ( + -- just leverage the RLS on config_items + SELECT 1 + FROM config_items + WHERE config_items.id = config_changes.config_id + ) + END + ); + +-- Policy config_analysis +DROP POLICY IF EXISTS config_analysis_auth ON config_analysis; + +CREATE POLICY config_analysis_auth ON config_analysis + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE EXISTS ( + -- just leverage the RLS on config_items + SELECT 1 + FROM config_items + WHERE config_items.id = config_analysis.config_id + ) + END + ); + +-- Policy config_relationships +DROP POLICY IF EXISTS config_relationships_auth ON config_relationships; + +CREATE POLICY config_relationships_auth ON config_relationships + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE ( + -- just leverage the RLS on config_items - user must have access to both items + EXISTS (SELECT 1 FROM config_items WHERE config_items.id = config_relationships.config_id) + AND EXISTS (SELECT 1 FROM config_items WHERE config_items.id = config_relationships.related_id) + ) + END + ); + +-- Policy config_component_relationships +DROP POLICY IF EXISTS config_component_relationships_auth ON config_component_relationships; + +CREATE POLICY config_component_relationships_auth ON config_component_relationships + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE EXISTS ( + -- just leverage the RLS on config_items + SELECT 1 + FROM config_items + WHERE config_items.id = config_component_relationships.config_id + ) + END + ); + +-- Policy components +DROP POLICY IF EXISTS components_auth ON components; + +CREATE POLICY components_auth ON components + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE (components.__scope && rls_scope_access()) + END + ); + +-- Policy canaries +DROP POLICY IF EXISTS canaries_auth ON canaries; + +CREATE POLICY canaries_auth ON canaries + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE (canaries.__scope && rls_scope_access()) + END + ); + +-- Policy playbooks +DROP POLICY IF EXISTS playbooks_auth ON playbooks; + +CREATE POLICY playbooks_auth ON playbooks + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE (playbooks.__scope && rls_scope_access()) + END + ); + +-- Policy playbook_runs +DROP POLICY IF EXISTS playbook_runs_auth ON playbook_runs; + +CREATE POLICY playbook_runs_auth ON playbook_runs + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE ( + -- User must have access to the playbook + EXISTS ( + SELECT 1 + FROM playbooks + WHERE playbooks.id = playbook_runs.playbook_id + ) + AND + -- AND if run has a config_id, user must have access to that config + (playbook_runs.config_id IS NULL OR EXISTS ( + SELECT 1 + FROM config_items + WHERE config_items.id = playbook_runs.config_id + )) + AND + -- AND if run has a check_id, user must have access to that check (via its canary) + (playbook_runs.check_id IS NULL OR EXISTS ( + SELECT 1 + FROM checks + WHERE checks.id = playbook_runs.check_id + )) + -- Note: component_id check omitted (phasing out topology soon) + ) + END + ); + +-- Policy checks +DROP POLICY IF EXISTS checks_auth ON checks; + +CREATE POLICY checks_auth ON checks + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE EXISTS ( + -- just leverage the RLS on canaries + SELECT 1 + FROM canaries + WHERE canaries.id = checks.canary_id + ) + END + ); + +-- Policy views +DROP POLICY IF EXISTS views_auth ON views; + +CREATE POLICY views_auth ON views + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE (views.__scope && rls_scope_access()) + END + ); + +-- Policy view_panels (inherits from parent views table) +DROP POLICY IF EXISTS view_panels_auth ON view_panels; + +CREATE POLICY view_panels_auth ON view_panels + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE EXISTS ( + SELECT 1 FROM views + WHERE views.id = view_panels.view_id + ) + END + ); + +ALTER VIEW analysis_by_config SET (security_invoker = true); +ALTER VIEW catalog_changes SET (security_invoker = true); +ALTER VIEW check_summary SET (security_invoker = true); +ALTER VIEW check_summary_by_config SET (security_invoker = true); +ALTER VIEW check_summary_for_config SET (security_invoker = true); +ALTER VIEW checks_by_config SET (security_invoker = true); +ALTER VIEW checks_labels_keys SET (security_invoker = true); +ALTER VIEW component_labels_keys SET (security_invoker = true); +ALTER VIEW config_analysis_analyzers SET (security_invoker = true); +ALTER VIEW config_analysis_by_severity SET (security_invoker = true); +ALTER VIEW config_analysis_items SET (security_invoker = true); +ALTER VIEW config_changes_by_types SET (security_invoker = true); +ALTER VIEW config_class_summary SET (security_invoker = true); +ALTER VIEW config_classes SET (security_invoker = true); +ALTER VIEW config_detail SET (security_invoker = true); +ALTER VIEW config_labels SET (security_invoker = true); +ALTER VIEW config_names SET (security_invoker = true); +ALTER VIEW config_scrapers_with_status SET (security_invoker = true); +ALTER VIEW config_statuses SET (security_invoker = true); +ALTER VIEW config_summary SET (security_invoker = true); +ALTER VIEW config_tags SET (security_invoker = true); +ALTER VIEW config_tags_labels_keys SET (security_invoker = true); +ALTER VIEW config_types SET (security_invoker = true); +ALTER VIEW configs SET (security_invoker = true); +ALTER VIEW topology SET (security_invoker = true); +ALTER VIEW incidents_by_config SET (security_invoker = true); +ALTER VIEW playbook_names SET (security_invoker = true); +ALTER VIEW views_summary SET (security_invoker = true); diff --git a/views/views.go b/views/views.go index a0a0c3732..3d312028b 100644 --- a/views/views.go +++ b/views/views.go @@ -1,6 +1,10 @@ package views -import "embed" +import ( + "embed" + + "github.com/flanksource/commons/properties" +) //go:embed *.sql var views embed.FS @@ -18,5 +22,13 @@ func GetViews() (map[string]string, error) { } funcs[file.Name()] = string(script) } + + usePrecomputed := properties.On(false, "rls.precomputed_scope") + if precomputed, ok := funcs["9998_rls_enable_precomputed.sql"]; ok { + if usePrecomputed { + funcs["9998_rls_enable.sql"] = precomputed + } + delete(funcs, "9998_rls_enable_precomputed.sql") + } return funcs, nil }