From f9ddc8bac6054bbd7f00ce37699fc7d7248ccf07 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 14 Jan 2026 13:18:39 +0545 Subject: [PATCH 01/10] feat: add __scope columns to primary tables --- schema/checks.hcl | 8 ++++++++ schema/components.hcl | 8 ++++++++ schema/config.hcl | 8 ++++++++ schema/playbooks.hcl | 8 ++++++++ schema/views.hcl | 10 +++++++++- 5 files changed, 41 insertions(+), 1 deletion(-) 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 +} From bed035bf89cb05fc233dba7e48bf257a3fbfaac2 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 14 Jan 2026 14:02:59 +0545 Subject: [PATCH 02/10] feat: move to scope based matching and remove resource selector matcher --- views/035_rls_utils.sql | 41 +++++++- views/035_view_rls.sql | 14 ++- views/043_scope_permission_events.sql | 64 ++++++++++++ views/9998_rls_enable.sql | 145 ++------------------------ 4 files changed, 120 insertions(+), 144 deletions(-) create mode 100644 views/043_scope_permission_events.sql diff --git a/views/035_rls_utils.sql b/views/035_rls_utils.sql index 958d7e15d..1a22ec5c3 100644 --- a/views/035_rls_utils.sql +++ b/views/035_rls_utils.sql @@ -9,4 +9,43 @@ 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; + +-- rls_has_wildcard reports whether request.jwt.claims includes the given wildcard scope type. +CREATE +OR REPLACE FUNCTION rls_has_wildcard(scope_type TEXT) RETURNS BOOLEAN AS $$ +DECLARE + jwt_claims TEXT; +BEGIN + jwt_claims := current_setting('request.jwt.claims', TRUE); + IF jwt_claims IS NULL OR jwt_claims = '' THEN + RETURN FALSE; + END IF; + + RETURN EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text( + COALESCE(jwt_claims::jsonb -> 'wildcard_scopes', '[]'::jsonb) + ) AS wildcard + WHERE wildcard = scope_type + ); +END; +$$ LANGUAGE plpgsql STABLE SECURITY INVOKER; diff --git a/views/035_view_rls.sql b/views/035_view_rls.sql index fb741cc93..ed4723283 100644 --- a/views/035_view_rls.sql +++ b/views/035_view_rls.sql @@ -11,19 +11,17 @@ CREATE OR REPLACE FUNCTION check_view_grants(grants jsonb) RETURNS BOOLEAN AS $$ BEGIN + IF rls_has_wildcard('view') THEN + RETURN TRUE; + END IF; + IF grants IS NULL OR jsonb_array_length(grants) = 0 THEN RETURN FALSE; END IF; RETURN EXISTS ( SELECT 1 FROM jsonb_array_elements_text(grants) AS grant_uuid - WHERE grant_uuid = ANY( - COALESCE( - ARRAY(SELECT jsonb_array_elements_text( - COALESCE(current_setting('request.jwt.claims', TRUE)::jsonb -> 'scopes', '[]'::jsonb) - )), '{}'::text[] - ) - ) + WHERE grant_uuid::uuid = ANY(rls_scope_access()) ); END; -$$ LANGUAGE plpgsql VOLATILE; \ No newline at end of file +$$ LANGUAGE plpgsql VOLATILE; 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.sql b/views/9998_rls_enable.sql index a8fa794f0..540654170 100644 --- a/views/9998_rls_enable.sql +++ b/views/9998_rls_enable.sql @@ -1,103 +1,3 @@ --- Generic function to match a row against an array of scopes --- Returns TRUE if the row matches ANY scope in the array (OR logic between scopes) --- Within a scope, ALL non-empty fields must match (AND logic within scope) -CREATE OR REPLACE FUNCTION match_scope( - scopes jsonb, -- Array of scope objects from JWT claims - row_tags jsonb, -- The row's tags (can be NULL) - row_agent uuid, -- The row's agent_id (can be NULL) - row_name text, -- The row's name (can be NULL) - row_id uuid -- The row's ID (can be NULL) -) RETURNS BOOLEAN AS $$ -DECLARE - scope jsonb; - scope_tags jsonb; - scope_agents jsonb; - scope_names jsonb; - scope_id text; - tags_match boolean; - agents_match boolean; - names_match boolean; - id_match boolean; -BEGIN - -- If scopes is NULL or not an array or empty, deny access - IF scopes IS NULL - OR jsonb_typeof(scopes) != 'array' - OR jsonb_array_length(scopes) = 0 THEN - RETURN FALSE; - END IF; - - -- Iterate through each scope (OR logic between scopes) - FOR scope IN SELECT * FROM jsonb_array_elements(scopes) - LOOP - -- Extract fields from scope - scope_tags := scope->'tags'; - scope_agents := scope->'agents'; - scope_names := scope->'names'; - scope_id := NULLIF(btrim(scope->>'id'), ''); - - -- Check if scope has any fields applicable to this resource type - -- A field is applicable if: scope defines it AND resource supports it (row param not NULL) - -- If no applicable fields, scope is effectively empty for this resource type - IF ((scope_tags IS NULL OR scope_tags = '{}'::jsonb) OR row_tags IS NULL) - AND (COALESCE(jsonb_array_length(scope_agents), 0) = 0 OR row_agent IS NULL) - AND (COALESCE(jsonb_array_length(scope_names), 0) = 0 OR row_name IS NULL) - AND (scope_id IS NULL OR row_id IS NULL) THEN - CONTINUE; - END IF; - - -- Check tags match (row must contain all scope tags) - IF scope_tags IS NULL OR jsonb_typeof(scope_tags) = 'null' OR scope_tags = '{}'::jsonb THEN - tags_match := TRUE; - ELSIF row_tags IS NULL THEN - tags_match := TRUE; -- Resource doesn't have tags, ignore this check - ELSE - tags_match := row_tags @> scope_tags; - END IF; - - -- Check agents match (row agent must be in list or wildcard) - IF scope_agents IS NULL OR jsonb_typeof(scope_agents) = 'null' OR jsonb_array_length(scope_agents) = 0 THEN - agents_match := TRUE; - ELSIF row_agent IS NULL THEN - agents_match := TRUE; -- Resource doesn't have agents, ignore this check - ELSIF scope_agents = '["*"]'::jsonb THEN - agents_match := row_agent IS NOT NULL; - ELSE - agents_match := scope_agents @> to_jsonb(row_agent::text); - END IF; - - -- Check names match (row name must be in list or wildcard) - IF scope_names IS NULL OR jsonb_typeof(scope_names) = 'null' OR jsonb_array_length(scope_names) = 0 THEN - names_match := TRUE; - ELSIF scope_names = '["*"]'::jsonb THEN - names_match := row_name IS NOT NULL; - ELSIF row_name IS NULL THEN - names_match := FALSE; - ELSE - names_match := scope_names @> to_jsonb(row_name); - END IF; - - -- Check ID match (row ID must match if provided) - IF scope_id IS NULL THEN - id_match := TRUE; - ELSIF row_id IS NULL THEN - id_match := FALSE; - ELSIF scope_id = '*' THEN - id_match := row_id IS NOT NULL; - ELSE - id_match := lower(scope_id) = row_id::text; - END IF; - - -- If ALL conditions match (AND logic within scope), return TRUE - IF tags_match AND agents_match AND names_match AND id_match THEN - RETURN TRUE; - END IF; - END LOOP; - - -- No scope matched - RETURN FALSE; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - -- Enable RLS for tables DO $$ BEGIN @@ -159,13 +59,8 @@ CREATE POLICY config_items_auth ON config_items USING ( CASE WHEN (SELECT is_rls_disabled()) THEN TRUE ELSE - match_scope( - current_setting('request.jwt.claims', TRUE)::jsonb -> 'config', - config_items.tags, - config_items.agent_id, - config_items.name, - config_items.id - ) + rls_has_wildcard('config') + OR (COALESCE(config_items.__scope, '{}'::uuid[]) && rls_scope_access()) END ); @@ -240,13 +135,8 @@ CREATE POLICY components_auth ON components USING ( CASE WHEN (SELECT is_rls_disabled()) THEN TRUE ELSE - match_scope( - current_setting('request.jwt.claims', TRUE)::jsonb -> 'component', - NULL, - components.agent_id, - components.name, - components.id - ) + rls_has_wildcard('component') + OR (COALESCE(components.__scope, '{}'::uuid[]) && rls_scope_access()) END ); @@ -258,13 +148,8 @@ CREATE POLICY canaries_auth ON canaries USING ( CASE WHEN (SELECT is_rls_disabled()) THEN TRUE ELSE - match_scope( - current_setting('request.jwt.claims', TRUE)::jsonb -> 'canary', - NULL, - canaries.agent_id, - canaries.name, - canaries.id - ) + rls_has_wildcard('canary') + OR (COALESCE(canaries.__scope, '{}'::uuid[]) && rls_scope_access()) END ); @@ -276,13 +161,8 @@ CREATE POLICY playbooks_auth ON playbooks USING ( CASE WHEN (SELECT is_rls_disabled()) THEN TRUE ELSE - match_scope( - current_setting('request.jwt.claims', TRUE)::jsonb -> 'playbook', - NULL, - NULL, - playbooks.name, - playbooks.id - ) + rls_has_wildcard('playbook') + OR (COALESCE(playbooks.__scope, '{}'::uuid[]) && rls_scope_access()) END ); @@ -343,13 +223,8 @@ CREATE POLICY views_auth ON views USING ( CASE WHEN (SELECT is_rls_disabled()) THEN TRUE ELSE - match_scope( - current_setting('request.jwt.claims', TRUE)::jsonb -> 'view', - NULL, - NULL, - views.name, - views.id - ) + rls_has_wildcard('view') + OR (COALESCE(views.__scope, '{}'::uuid[]) && rls_scope_access()) END ); From 0fdc1b460ae6a41dac97b215a0ed7817412c7772 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 14 Jan 2026 14:19:55 +0545 Subject: [PATCH 03/10] update RLSPayload struct: remove matchers and add scope uuids --- rls/payload.go | 72 +- rls/payload_test.go | 34 +- tests/rls_test.go | 2352 ++----------------------------------------- 3 files changed, 118 insertions(+), 2340 deletions(-) diff --git a/rls/payload.go b/rls/payload.go index 1d98208de..3e8178423 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" ) @@ -19,6 +20,16 @@ type Scope struct { ID string `json:"id,omitempty"` } +type WildcardResourceScope string + +const ( + WildcardResourceScopeConfig WildcardResourceScope = "config" + WildcardResourceScopeComponent WildcardResourceScope = "component" + WildcardResourceScopeCanary WildcardResourceScope = "canary" + WildcardResourceScopePlaybook WildcardResourceScope = "playbook" + WildcardResourceScopeView WildcardResourceScope = "view" +) + func (s Scope) IsEmpty() bool { return len(s.Tags) == 0 && len(s.Agents) == 0 && len(s.Names) == 0 && strings.TrimSpace(s.ID) == "" } @@ -39,15 +50,13 @@ type Payload struct { // cached fingerprint fingerprint string - Config []Scope `json:"config,omitempty"` - Component []Scope `json:"component,omitempty"` - Playbook []Scope `json:"playbook,omitempty"` - Canary []Scope `json:"canary,omitempty"` - 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"` + + // WildcardScopes contains resource types that grant access to all rows of that type. + // Wildcard scopes are not materialized directly into the table rows to avoid high writes/updates. + // Instead, if a user has wildcard scope to a resource type, then the RLS policy matches immediately. + WildcardScopes []WildcardResourceScope `json:"wildcard_scopes,omitempty"` Disable bool `json:"disable_rls,omitempty"` } @@ -60,30 +69,14 @@ func (t Payload) JWTClaims() map[string]any { return claims } - if len(t.Config) > 0 { - claims["config"] = t.Config - } - - if len(t.Component) > 0 { - claims["component"] = t.Component - } - - if len(t.Playbook) > 0 { - claims["playbook"] = t.Playbook - } - - if len(t.Canary) > 0 { - claims["canary"] = t.Canary - } - - if len(t.View) > 0 { - claims["view"] = t.View - } - if len(t.Scopes) > 0 { claims["scopes"] = t.Scopes } + if len(t.WildcardScopes) > 0 { + claims["wildcard_scopes"] = t.WildcardScopes + } + return claims } @@ -94,21 +87,24 @@ func (t *Payload) EvalFingerprint() { } parts := []string{} - for _, scopeArray := range [][]Scope{t.Config, t.Component, t.Playbook, t.Canary, t.View} { - for _, scope := range scopeArray { - if !scope.IsEmpty() { - parts = append(parts, scope.Fingerprint()) - } - } - } - - // 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, ",")) } + if len(t.WildcardScopes) > 0 { + wildcardsCopy := make([]string, 0, len(t.WildcardScopes)) + for _, wildcard := range t.WildcardScopes { + wildcardsCopy = append(wildcardsCopy, string(wildcard)) + } + slices.Sort(wildcardsCopy) + parts = append(parts, strings.Join(wildcardsCopy, ",")) + } + if len(parts) == 0 { t.fingerprint = "empty" return diff --git a/rls/payload_test.go b/rls/payload_test.go index 0eb25fdab..87b671687 100644 --- a/rls/payload_test.go +++ b/rls/payload_test.go @@ -3,6 +3,7 @@ package rls import ( "testing" + "github.com/google/uuid" "github.com/onsi/gomega" ) @@ -22,12 +23,11 @@ func TestPayload_EvalFingerprint(t *testing.T) { g := gomega.NewWithT(t) payload := &Payload{ - Config: []Scope{ - { - Tags: map[string]string{"z": "value1", "a": "value2"}, - Agents: []string{"agent2", "agent1"}, - }, + Scopes: []uuid.UUID{ + uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2"), + uuid.MustParse("0a1ce1b2-5d90-4e74-8d30-2f4f0d30f8e4"), }, + WildcardScopes: []WildcardResourceScope{WildcardResourceScopePlaybook, WildcardResourceScopeConfig}, } payload.EvalFingerprint() @@ -50,18 +50,20 @@ func TestPayload_EvalFingerprint(t *testing.T) { g := gomega.NewWithT(t) payload1 := &Payload{ - Config: []Scope{ - {Tags: map[string]string{"a": "value1"}}, - {Tags: map[string]string{"b": "value2"}}, + Scopes: []uuid.UUID{ + uuid.MustParse("0a1ce1b2-5d90-4e74-8d30-2f4f0d30f8e4"), + uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2"), }, + WildcardScopes: []WildcardResourceScope{WildcardResourceScopeView, WildcardResourceScopeConfig}, } payload1.EvalFingerprint() payload2 := &Payload{ - Config: []Scope{ - {Tags: map[string]string{"b": "value2"}}, - {Tags: map[string]string{"a": "value1"}}, + Scopes: []uuid.UUID{ + uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2"), + uuid.MustParse("0a1ce1b2-5d90-4e74-8d30-2f4f0d30f8e4"), }, + WildcardScopes: []WildcardResourceScope{WildcardResourceScopeConfig, WildcardResourceScopeView}, } payload2.EvalFingerprint() @@ -73,18 +75,14 @@ func TestPayload_EvalFingerprint(t *testing.T) { g := gomega.NewWithT(t) payload := &Payload{ - Config: []Scope{ - { - Tags: map[string]string{"x": "value4"}, - Agents: []string{"agentX"}, - }, - }, + Scopes: []uuid.UUID{uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2")}, + WildcardScopes: []WildcardResourceScope{WildcardResourceScopeCanary}, } payload.EvalFingerprint() firstFingerprint := payload.Fingerprint() // Modify the underlying data to see if the cached fingerprint remains unchanged - payload.Config[0].Tags["x"] = "modified_value" + payload.Scopes[0] = uuid.MustParse("f4a1fcb2-4cf7-48f2-9e68-6457e8c4e9e6") g.Expect(payload.Fingerprint()).To(gomega.Equal(firstFingerprint)) }) } diff --git a/tests/rls_test.go b/tests/rls_test.go index 3c54e3c87..dcb29e609 100644 --- a/tests/rls_test.go +++ b/tests/rls_test.go @@ -1,39 +1,19 @@ package tests import ( - "database/sql" - "fmt" "os" - "strings" "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/samber/lo" "gorm.io/gorm" "github.com/flanksource/duty/api" - "github.com/flanksource/duty/job" "github.com/flanksource/duty/migrate" "github.com/flanksource/duty/models" "github.com/flanksource/duty/rls" - "github.com/flanksource/duty/tests/fixtures/dummy" - "github.com/flanksource/duty/types" ) -type testCase struct { - rlsPayload rls.Payload - expectedCount *int64 -} - -func verifyConfigCount(tx *gorm.DB, rlsPayload rls.Payload, expectedCount int64) { - Expect(rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) - - var count int64 - Expect(tx.Model(&models.ConfigItem{}).Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(expectedCount)) -} - var _ = Describe("RLS test", Ordered, ContinueOnFailure, func() { BeforeAll(func() { if os.Getenv("DUTY_DB_DISABLE_RLS") == "true" { @@ -41,2289 +21,93 @@ var _ = Describe("RLS test", Ordered, ContinueOnFailure, func() { } }) - var _ = Describe("views query", func() { - var ( - tx *gorm.DB - totalConfigs int64 - awsConfigs int64 - ) - - BeforeAll(func() { - Expect(DefaultContext.DB().Model(&models.ConfigItem{}).Count(&totalConfigs).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws'").Model(&models.ConfigItem{}).Count(&awsConfigs).Error).To(BeNil()) - - Expect(totalConfigs).To(Not(Equal(awsConfigs))) - - sqldb, err := DefaultContext.DB().DB() - Expect(err).To(BeNil()) - - // The migration_dependency_test can mess with the migration_logs so we clean and run migrations again - Expect(DefaultContext.DB().Exec("DELETE FROM migration_logs").Error).To(BeNil()) - - connString := DefaultContext.Value("db_url").(string) - err = migrate.RunMigrations(sqldb, api.Config{ConnectionString: connString, EnableRLS: true}) - Expect(err).To(BeNil()) - - tx = DefaultContext.DB().Begin() - - Expect(tx.Exec("SET LOCAL ROLE 'postgrest_api'").Error).To(BeNil()) - - payload := rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster": "aws"}}, - }, - } - Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) - - err = job.RefreshConfigItemSummary7d(DefaultContext) - Expect(err).To(BeNil()) - }) - - AfterAll(func() { - payload := rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster": "aws"}}, - }, - } - Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) - Expect(tx.Commit().Error).To(BeNil()) - }) - - It("should call configs", func() { - var count int64 - err := tx.Raw("SELECT COUNT(*) FROM configs").Scan(&count).Error - Expect(err).To(BeNil()) - - Expect(count).To(Equal(awsConfigs)) - }) - - It("should call config_detail", func() { - var count int64 - err := tx.Raw("SELECT COUNT(*) FROM config_detail").Scan(&count).Error - Expect(err).To(BeNil()) - - Expect(count).To(Equal(awsConfigs)) - }) - - It("should call config_item_summary_7d", func() { - var count int64 - err := tx.Raw("SELECT COUNT(*) FROM config_item_summary_7d").Scan(&count).Error - Expect(err).To(BeNil()) - - Expect(count).To(Equal(totalConfigs)) - }) - }) - - var _ = Describe("config_items query", func() { - var ( - tx *gorm.DB - totalConfigs int64 - numConfigsWithAgent int64 - numConfigsWithFlanksourceTag int64 - awsConfigs int64 - awsAndDemoCluster int64 - awsTagAndNilAgent int64 - awsTagAndEKSName int64 - awsAndFlanksourceTags int64 - ) - - BeforeAll(func() { - tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) - - Expect(DefaultContext.DB().Model(&models.ConfigItem{}).Count(&totalConfigs).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("tags->>'account' = 'flanksource'").Model(&models.ConfigItem{}).Count(&numConfigsWithFlanksourceTag).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("agent_id = ?", uuid.Nil).Model(&models.ConfigItem{}).Count(&numConfigsWithAgent).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws'").Model(&models.ConfigItem{}).Count(&awsConfigs).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws' OR tags->>'cluster' = 'demo'").Model(&models.ConfigItem{}).Count(&awsAndDemoCluster).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws' AND agent_id = ?", uuid.Nil).Model(&models.ConfigItem{}).Count(&awsTagAndNilAgent).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws' AND name = ?", *dummy.EKSCluster.Name).Model(&models.ConfigItem{}).Count(&awsTagAndEKSName).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws' AND tags->>'account' = 'flanksource'").Model(&models.ConfigItem{}).Count(&awsAndFlanksourceTags).Error).To(BeNil()) - }) - - AfterAll(func() { - Expect(tx.Commit().Error).To(BeNil()) - }) - - for _, role := range []string{"postgrest_anon", "postgrest_api"} { - Context(role, Ordered, func() { - BeforeAll(func() { - Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) - - var currentRole string - Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) - Expect(currentRole).To(Equal(role)) - }) - - It("should allow access to all records when RLS is disabled", func() { - payload := rls.Payload{ - Disable: true, - } - verifyConfigCount(tx, payload, totalConfigs) - }) - - DescribeTable("JWT claim tests", - func(tc testCase) { - verifyConfigCount(tx, tc.rlsPayload, *tc.expectedCount) - }, - Entry("no permissions", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Tags: map[string]string{"cluster": "testing-cluster"}, - Agents: []string{"10000000-0000-0000-0000-000000000000"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("correct agent", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Agents: []string{"00000000-0000-0000-0000-000000000000"}, - }, - }, - }, - expectedCount: &numConfigsWithAgent, - }), - Entry("correct tag", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Tags: map[string]string{"account": "flanksource"}, - }, - }, - }, - expectedCount: &numConfigsWithFlanksourceTag, - }), - Entry("multiple tags (OR logic between scopes)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster": "aws"}}, - {Tags: map[string]string{"cluster": "demo"}}, - }, - }, - expectedCount: &awsAndDemoCluster, - }), - Entry("specific name", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Names: []string{*dummy.EKSCluster.Name}}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("wildcard name (match all)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Names: []string{"*"}}, - }, - }, - expectedCount: &totalConfigs, - }), - Entry("wildcard agent (match all)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Agents: []string{"*"}}, - }, - }, - expectedCount: &totalConfigs, - }), - Entry("tags AND agents (within scope)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Tags: map[string]string{"cluster": "aws"}, - Agents: []string{uuid.Nil.String()}, - }, - }, - }, - expectedCount: &awsTagAndNilAgent, - }), - Entry("tags AND names (within scope)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Tags: map[string]string{"cluster": "aws"}, - Names: []string{*dummy.EKSCluster.Name}, - }, - }, - }, - expectedCount: &awsTagAndEKSName, - }), - Entry("empty payload (no scopes)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{}, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple names (OR within names array)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Names: []string{*dummy.EKSCluster.Name, "non-existent-config"}}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("mixed scope criteria (OR logic between scopes)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster": "aws"}}, - {Agents: []string{uuid.Nil.String()}}, - {Names: []string{*dummy.EKSCluster.Name}}, - }, - }, - expectedCount: &numConfigsWithAgent, // Should be union of all three scopes - }), - Entry("invalid agent UUID (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Agents: []string{"not-a-valid-uuid"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("empty string in agents array (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Agents: []string{""}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("empty string in names array (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Names: []string{""}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("empty tag value (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster": ""}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("case sensitivity - uppercase name (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Names: []string{strings.ToUpper(*dummy.EKSCluster.Name)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("case sensitivity - uppercase tag value (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster": "AWS"}}, // uppercase - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("duplicate scopes (should work same as single)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster": "aws"}}, - {Tags: map[string]string{"cluster": "aws"}}, // duplicate - }, - }, - expectedCount: &awsConfigs, // Should be same as single scope - }), - Entry("conflicting criteria within scope (agent matches but name doesn't)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Agents: []string{uuid.Nil.String()}, // matches many - Names: []string{"non-existent-config-name"}, // matches none - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), // AND logic means both must match - }), - Entry("special characters in name (unicode)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Names: []string{"config-名前-🚀"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple agents in single scope (OR within agents array)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Agents: []string{ - uuid.Nil.String(), - "10000000-0000-0000-0000-000000000000", - }, - }, - }, - }, - expectedCount: &numConfigsWithAgent, - }), - Entry("multiple tags in single scope (AND logic)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Tags: map[string]string{ - "cluster": "aws", - "account": "flanksource", - }, - }, - }, - }, - expectedCount: &awsAndFlanksourceTags, - }), - Entry("mixed valid and invalid agents", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Agents: []string{ - "not-a-uuid", - uuid.Nil.String(), - "also-invalid", - }, - }, - }, - }, - expectedCount: &numConfigsWithAgent, - }), - Entry("very long agent list (stress test)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Agents: append( - []string{uuid.Nil.String()}, - func() []string { - agents := make([]string, 99) - for i := range agents { - agents[i] = uuid.New().String() - } - return agents - }()..., - ), - }, - }, - }, - expectedCount: &numConfigsWithAgent, - }), - Entry("very long names list (stress test)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Names: append( - []string{*dummy.EKSCluster.Name}, - func() []string { - names := make([]string, 99) - for i := range names { - names[i] = fmt.Sprintf("non-existent-config-%d", i) - } - return names - }()..., - ), - }, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("very many scopes (stress test)", testCase{ - rlsPayload: rls.Payload{ - Config: append( - []rls.Scope{{Tags: map[string]string{"cluster": "aws"}}}, - func() []rls.Scope { - scopes := make([]rls.Scope, 49) - for i := range scopes { - scopes[i] = rls.Scope{ - Tags: map[string]string{"cluster": fmt.Sprintf("non-existent-%d", i)}, - } - } - return scopes - }()..., - ), - }, - expectedCount: &awsConfigs, - }), - Entry("tag with special characters in key", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster-name-with-dashes": "value"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("tag key exists but value doesn't match", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster": "non-existent-value"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple tags where only one matches", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Tags: map[string]string{ - "cluster": "aws", - "nonexistent": "should-fail", - }, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("empty tag map in scope", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Tags: map[string]string{}, - Agents: []string{uuid.Nil.String()}, - }, - }, - }, - expectedCount: &numConfigsWithAgent, - }), - Entry("whitespace-only values", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Names: []string{" "}, - Tags: map[string]string{"cluster": " "}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("extremely long name string", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Names: []string{strings.Repeat("a", 1000)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("extremely long tag value", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster": strings.Repeat("x", 1000)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("name with wildcard in middle", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Names: []string{"Production*EKS"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("name with wildcard prefix", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Names: []string{"*EKS"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple scopes with overlapping results", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster": "aws"}}, - {Names: []string{*dummy.EKSCluster.Name}}, - }, - }, - expectedCount: &awsConfigs, - }), - Entry("agent UUID with uppercase", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Agents: []string{strings.ToUpper(uuid.Nil.String())}}, - }, - }, - expectedCount: &numConfigsWithAgent, - }), - Entry("newline in tag value", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster": "aws\nmalicious"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("empty scope object", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - {}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("valid tag + valid agent + invalid name (AND within scope)", testCase{ - rlsPayload: rls.Payload{ - Config: []rls.Scope{ - { - Tags: map[string]string{"cluster": "aws"}, - Agents: []string{uuid.Nil.String()}, - Names: []string{"non-existent"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - ) - }) - } - }) - - var _ = Describe("components query", func() { - var ( - tx *gorm.DB - totalComponents int64 - numComponentsWithAgent int64 - agentAndLogisticsName int64 - ) - - BeforeAll(func() { - tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) - - Expect(DefaultContext.DB().Model(&models.Component{}).Count(&totalComponents).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("agent_id = ?", uuid.Nil).Model(&models.Component{}).Count(&numComponentsWithAgent).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("agent_id = ? AND name = ?", uuid.Nil, dummy.Logistics.Name).Model(&models.Component{}).Count(&agentAndLogisticsName).Error).To(BeNil()) - }) - - AfterAll(func() { - Expect(tx.Commit().Error).To(BeNil()) - }) - - for _, role := range []string{"postgrest_anon", "postgrest_api"} { - Context(role, Ordered, func() { - BeforeAll(func() { - Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) - - var currentRole string - Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) - Expect(currentRole).To(Equal(role)) - }) - - DescribeTable("JWT claim tests", - func(tc testCase) { - Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) - - var count int64 - Expect(tx.Model(&models.Component{}).Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(*tc.expectedCount)) - }, - Entry("no permissions", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - { - Agents: []string{"10000000-0000-0000-0000-000000000000"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("correct agent", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - { - Agents: []string{uuid.Nil.String()}, - }, - }, - }, - expectedCount: &numComponentsWithAgent, - }), - Entry("specific name", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Names: []string{dummy.Logistics.Name}}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("wildcard name (match all)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Names: []string{"*"}}, - }, - }, - expectedCount: &totalComponents, - }), - Entry("agents AND names (within scope)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - { - Agents: []string{uuid.Nil.String()}, - Names: []string{dummy.Logistics.Name}, - }, - }, - }, - expectedCount: &agentAndLogisticsName, - }), - Entry("empty payload (no scopes)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{}, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple names (OR within names array)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Names: []string{dummy.Logistics.Name, "non-existent-component"}}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("mixed scope criteria (OR logic between scopes)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Agents: []string{uuid.Nil.String()}}, - {Names: []string{dummy.Logistics.Name}}, - }, - }, - expectedCount: &numComponentsWithAgent, // Should be union of both scopes - }), - Entry("invalid agent UUID (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Agents: []string{"invalid-uuid"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("empty string in agents array (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Agents: []string{""}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("empty string in names array (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Names: []string{""}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("case sensitivity - uppercase name (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Names: []string{strings.ToUpper(dummy.Logistics.Name)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("conflicting criteria within scope (agent matches but name doesn't)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - { - Agents: []string{uuid.Nil.String()}, - Names: []string{"non-existent-component"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), // AND logic means both must match - }), - Entry("multiple agents in single scope", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - { - Agents: []string{ - uuid.Nil.String(), - "10000000-0000-0000-0000-000000000000", - }, - }, - }, - }, - expectedCount: &numComponentsWithAgent, - }), - Entry("mixed valid and invalid agents", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - { - Agents: []string{ - "not-a-uuid", - uuid.Nil.String(), - }, - }, - }, - }, - expectedCount: &numComponentsWithAgent, - }), - Entry("very long agent list (stress test)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - { - Agents: append( - []string{uuid.Nil.String()}, - func() []string { - agents := make([]string, 99) - for i := range agents { - agents[i] = uuid.New().String() - } - return agents - }()..., - ), - }, - }, - }, - expectedCount: &numComponentsWithAgent, - }), - Entry("very long names list (stress test)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - { - Names: append( - []string{dummy.Logistics.Name}, - func() []string { - names := make([]string, 99) - for i := range names { - names[i] = fmt.Sprintf("non-existent-component-%d", i) - } - return names - }()..., - ), - }, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("whitespace-only name", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Names: []string{" "}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("extremely long name string", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Names: []string{strings.Repeat("a", 1000)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("name with wildcard in middle", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Names: []string{"Log*tics"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple scopes with overlapping results", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Agents: []string{uuid.Nil.String()}}, - {Names: []string{dummy.Logistics.Name}}, - }, - }, - expectedCount: &numComponentsWithAgent, - }), - Entry("agent UUID with uppercase", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {Agents: []string{strings.ToUpper(uuid.Nil.String())}}, - }, - }, - expectedCount: &numComponentsWithAgent, - }), - Entry("empty scope object", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - {}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("valid agent + invalid name (AND within scope)", testCase{ - rlsPayload: rls.Payload{ - Component: []rls.Scope{ - { - Agents: []string{uuid.Nil.String()}, - Names: []string{"non-existent"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - ) - }) - } - }) - - var _ = Describe("playbooks query", func() { - var ( - tx *gorm.DB - totalPlaybooks int64 - ) - - BeforeAll(func() { - tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) - - Expect(DefaultContext.DB().Model(&models.Playbook{}).Count(&totalPlaybooks).Error).To(BeNil()) - }) + var ( + tx *gorm.DB + totalConfigs int64 + awsConfigs int64 + gcpConfigs int64 + awsOrGcpConfigs int64 + awsConfigChanges int64 + awsScopeID uuid.UUID + gcpScopeID uuid.UUID + ) - AfterAll(func() { - Expect(tx.Commit().Error).To(BeNil()) - }) - - for _, role := range []string{"postgrest_anon", "postgrest_api"} { - Context(role, Ordered, func() { - BeforeAll(func() { - Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) - - var currentRole string - Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) - Expect(currentRole).To(Equal(role)) - }) - - DescribeTable("JWT claim tests", - func(tc testCase) { - Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) - - var count int64 - Expect(tx.Model(&models.Playbook{}).Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(*tc.expectedCount)) - }, - Entry("no permissions", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - { - Names: []string{"non-existent-playbook"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("specific name", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{dummy.EchoConfig.Name}}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("wildcard name (match all)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{"*"}}, - }, - }, - expectedCount: &totalPlaybooks, - }), - Entry("empty payload (no scopes)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{}, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple names (OR within names array)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{dummy.EchoConfig.Name, "non-existent-playbook"}}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("empty string in names array (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{""}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("case sensitivity - uppercase name (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{strings.ToUpper(dummy.EchoConfig.Name)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("duplicate scopes (should work same as single)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{dummy.EchoConfig.Name}}, - {Names: []string{dummy.EchoConfig.Name}}, // duplicate - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("very long names list (stress test)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - { - Names: append( - []string{dummy.EchoConfig.Name}, - func() []string { - names := make([]string, 99) - for i := range names { - names[i] = fmt.Sprintf("non-existent-playbook-%d", i) - } - return names - }()..., - ), - }, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("whitespace-only name", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{" "}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("extremely long name string", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{strings.Repeat("a", 1000)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("name with wildcard in middle", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{"Echo*Config"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("name with wildcard prefix", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{"*Config"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple scopes with overlapping results", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{dummy.EchoConfig.Name}}, - {Names: []string{dummy.EchoConfig.Name}}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("empty scope object", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("agents defined in scope (should be ignored for playbooks)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - { - Agents: []string{"10000000-0000-0000-0000-000000000000"}, - Names: []string{dummy.EchoConfig.Name}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(1)), // Should match because agents should be ignored - }), - Entry("tags only in scope (should deny access - no applicable fields)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - { - Tags: map[string]string{"cluster": "homelab", "namespace": "default"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), // Should deny because playbooks don't support tags - }), - Entry("tags and agents only in scope (should deny access - no applicable fields)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - { - Tags: map[string]string{"cluster": "aws"}, - Agents: []string{"10000000-0000-0000-0000-000000000000"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), // Should deny because playbooks support neither tags nor agents - }), - Entry("specific ID (should grant access)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {ID: dummy.EchoConfig.ID.String()}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("wrong ID (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {ID: "00000000-0000-0000-0000-000000000000"}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("ID + matching name (AND logic - should grant access)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - { - ID: dummy.EchoConfig.ID.String(), - Names: []string{dummy.EchoConfig.Name}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("ID + non-matching name (AND logic - should deny access)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - { - ID: dummy.EchoConfig.ID.String(), - Names: []string{"wrong-name"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple scopes with different IDs (OR logic)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {ID: dummy.EchoConfig.ID.String()}, - {ID: dummy.RestartPod.ID.String()}, - }, - }, - expectedCount: lo.ToPtr(int64(2)), - }), - ) - }) - } + BeforeAll(func() { + Expect(DefaultContext.DB().Model(&models.ConfigItem{}).Count(&totalConfigs).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws'").Model(&models.ConfigItem{}).Count(&awsConfigs).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("tags->>'cluster' = 'gcp'").Model(&models.ConfigItem{}).Count(&gcpConfigs).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("tags->>'cluster' IN ('aws', 'gcp')").Model(&models.ConfigItem{}).Count(&awsOrGcpConfigs).Error).To(BeNil()) + Expect(DefaultContext.DB().Table("config_changes"). + Joins("JOIN config_items ON config_items.id = config_changes.config_id"). + Where("config_items.tags->>'cluster' = 'aws'"). + Count(&awsConfigChanges).Error).To(BeNil()) + + sqldb, err := DefaultContext.DB().DB() + Expect(err).To(BeNil()) + + Expect(DefaultContext.DB().Exec("DELETE FROM migration_logs").Error).To(BeNil()) + + connString := DefaultContext.Value("db_url").(string) + err = migrate.RunMigrations(sqldb, api.Config{ConnectionString: connString, EnableRLS: true}) + Expect(err).To(BeNil()) + + awsScopeID = uuid.New() + gcpScopeID = uuid.New() + Expect(DefaultContext.DB().Exec("UPDATE config_items SET __scope = ARRAY[?]::uuid[] WHERE tags->>'cluster' = 'aws'", awsScopeID).Error).To(BeNil()) + Expect(DefaultContext.DB().Exec("UPDATE config_items SET __scope = ARRAY[?]::uuid[] WHERE tags->>'cluster' = 'gcp'", gcpScopeID).Error).To(BeNil()) + + tx = DefaultContext.DB().Begin() + Expect(tx.Exec("SET LOCAL ROLE 'postgrest_api'").Error).To(BeNil()) }) - var _ = Describe("canaries query", func() { - var ( - tx *gorm.DB - totalCanaries int64 - numCanariesWithAgent int64 - agentAndCanaryName int64 - ) - - BeforeAll(func() { - tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) - - Expect(DefaultContext.DB().Model(&models.Canary{}).Count(&totalCanaries).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("agent_id = ?", uuid.Nil).Model(&models.Canary{}).Count(&numCanariesWithAgent).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("agent_id = ? AND name = ?", uuid.Nil, dummy.LogisticsAPICanary.Name).Model(&models.Canary{}).Count(&agentAndCanaryName).Error).To(BeNil()) - }) - - AfterAll(func() { + AfterAll(func() { + if tx != nil { Expect(tx.Commit().Error).To(BeNil()) - }) - - for _, role := range []string{"postgrest_anon", "postgrest_api"} { - Context(role, Ordered, func() { - BeforeAll(func() { - Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) - - var currentRole string - Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) - Expect(currentRole).To(Equal(role)) - }) - - DescribeTable("JWT claim tests", - func(tc testCase) { - Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) - - var count int64 - Expect(tx.Model(&models.Canary{}).Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(*tc.expectedCount)) - }, - Entry("no permissions", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Agents: []string{"10000000-0000-0000-0000-000000000000"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("correct agent", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Agents: []string{uuid.Nil.String()}, - }, - }, - }, - expectedCount: &numCanariesWithAgent, - }), - Entry("specific name", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{dummy.LogisticsAPICanary.Name}}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("wildcard name (match all)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{"*"}}, - }, - }, - expectedCount: &totalCanaries, - }), - Entry("agents AND names (within scope)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Agents: []string{uuid.Nil.String()}, - Names: []string{dummy.LogisticsAPICanary.Name}, - }, - }, - }, - expectedCount: &agentAndCanaryName, - }), - Entry("empty payload (no scopes)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{}, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple names (OR within names array)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{dummy.LogisticsAPICanary.Name, "non-existent-canary"}}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("mixed scope criteria (OR logic between scopes)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Agents: []string{uuid.Nil.String()}}, - {Names: []string{dummy.LogisticsAPICanary.Name}}, - }, - }, - expectedCount: &numCanariesWithAgent, // Should be union of both scopes - }), - Entry("invalid agent UUID (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Agents: []string{"not-valid-uuid"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("empty string in agents array (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Agents: []string{""}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("empty string in names array (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{""}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("case sensitivity - uppercase name (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{strings.ToUpper(dummy.LogisticsAPICanary.Name)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("conflicting criteria within scope (agent matches but name doesn't)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Agents: []string{uuid.Nil.String()}, - Names: []string{"non-existent-canary"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), // AND logic means both must match - }), - Entry("multiple agents in single scope", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Agents: []string{ - uuid.Nil.String(), - "10000000-0000-0000-0000-000000000000", - }, - }, - }, - }, - expectedCount: &numCanariesWithAgent, - }), - Entry("mixed valid and invalid agents", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Agents: []string{ - "not-a-uuid", - uuid.Nil.String(), - }, - }, - }, - }, - expectedCount: &numCanariesWithAgent, - }), - Entry("very long agent list (stress test)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Agents: append( - []string{uuid.Nil.String()}, - func() []string { - agents := make([]string, 99) - for i := range agents { - agents[i] = uuid.New().String() - } - return agents - }()..., - ), - }, - }, - }, - expectedCount: &numCanariesWithAgent, - }), - Entry("very long names list (stress test)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Names: append( - []string{dummy.LogisticsAPICanary.Name}, - func() []string { - names := make([]string, 99) - for i := range names { - names[i] = fmt.Sprintf("non-existent-canary-%d", i) - } - return names - }()..., - ), - }, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("whitespace-only name", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{" "}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("extremely long name string", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{strings.Repeat("a", 1000)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("name with wildcard in middle", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{"Logistics*Canary"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("name with wildcard prefix", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{"*Canary"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple scopes with overlapping results", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Agents: []string{uuid.Nil.String()}}, - {Names: []string{dummy.LogisticsAPICanary.Name}}, - }, - }, - expectedCount: &numCanariesWithAgent, - }), - Entry("agent UUID with uppercase", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Agents: []string{strings.ToUpper(uuid.Nil.String())}}, - }, - }, - expectedCount: &numCanariesWithAgent, - }), - Entry("empty scope object", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("valid agent + invalid name (AND within scope)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Agents: []string{uuid.Nil.String()}, - Names: []string{"non-existent"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - ) - }) } }) - var _ = Describe("playbook_runs query", func() { - var ( - tx *gorm.DB - totalPlaybookRuns int64 - echoConfigRunsCount int64 - restartPodRunsCount int64 - ) - - BeforeAll(func() { - tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) - - Expect(DefaultContext.DB().Model(&models.PlaybookRun{}).Count(&totalPlaybookRuns).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("playbook_id = ?", dummy.EchoConfig.ID).Model(&models.PlaybookRun{}).Count(&echoConfigRunsCount).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("playbook_id = ?", dummy.RestartPod.ID).Model(&models.PlaybookRun{}).Count(&restartPodRunsCount).Error).To(BeNil()) - - Expect(totalPlaybookRuns).To(BeNumerically(">", 0), "No playbook runs found in test data") - Expect(echoConfigRunsCount).To(BeNumerically(">", 0), "No playbook runs found for EchoConfig playbook") - Expect(restartPodRunsCount).To(BeNumerically(">", 0), "No playbook runs found for RestartPod playbook") - Expect(totalPlaybookRuns).To(Equal(echoConfigRunsCount + restartPodRunsCount)) - }) - - AfterAll(func() { - Expect(tx.Commit().Error).To(BeNil()) - }) - - for _, role := range []string{"postgrest_anon", "postgrest_api"} { - Context(role, Ordered, func() { - BeforeAll(func() { - Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) - - var currentRole string - Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) - Expect(currentRole).To(Equal(role)) - }) + It("should filter config_items by scope", func() { + payload := rls.Payload{Scopes: []uuid.UUID{awsScopeID}} + Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) - DescribeTable("JWT claim tests", - func(tc testCase) { - Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) - - var count int64 - Expect(tx.Model(&models.PlaybookRun{}).Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(*tc.expectedCount)) - }, - Entry("no permissions (empty scopes array)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{}, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("no permissions (non-existent playbook)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - { - Names: []string{"non-existent-playbook"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("access only echo-config playbook runs", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{dummy.EchoConfig.Name}}, - }, - Config: []rls.Scope{ - {Names: []string{"*"}}, - }, - }, - expectedCount: &echoConfigRunsCount, - }), - Entry("access echo-config playbook runs but no access to the config", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{dummy.EchoConfig.Name}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("can access echo-config playbook but only 1 config", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{dummy.EchoConfig.Name}}, - }, - Config: []rls.Scope{ - {ID: dummy.KubernetesNodeA.ID.String()}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("access echo-config playbook runs but no access to the config", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{dummy.EchoConfig.Name}}, - }, - Config: []rls.Scope{ - {ID: dummy.EC2InstanceA.ID.String()}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("access only restart-pod playbook runs", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{dummy.RestartPod.Name}}, - }, - Config: []rls.Scope{ - {Names: []string{"*"}}, - }, - }, - expectedCount: &restartPodRunsCount, - }), - Entry("access both playbooks (OR logic)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{dummy.EchoConfig.Name, dummy.RestartPod.Name}}, - }, - Config: []rls.Scope{ - {Names: []string{"*"}}, - }, - }, - expectedCount: &totalPlaybookRuns, - }), - Entry("wildcard playbook name (match all runs)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{"*"}}, - }, - Config: []rls.Scope{ - {Names: []string{"*"}}, - }, - }, - expectedCount: &totalPlaybookRuns, - }), - Entry("empty string in names array (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{""}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("case sensitivity - uppercase playbook name (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{strings.ToUpper(dummy.EchoConfig.Name)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("empty scope object", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("whitespace-only name", testCase{ - rlsPayload: rls.Payload{ - Playbook: []rls.Scope{ - {Names: []string{" "}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - ) - }) - } + var count int64 + Expect(tx.Model(&models.ConfigItem{}).Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(awsConfigs)) }) - var _ = Describe("INSERT QUERY", func() { - var tx *gorm.DB - - // Verify that the implicit WITH CHECK clause works correctly for INSERT operations. - // PostgreSQL RLS policies without an explicit WITH CHECK clause will use the USING clause - // for both SELECT (read) and INSERT/UPDATE (write) operations. - BeforeAll(func() { - tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin() - Expect(tx.Exec("SET LOCAL ROLE 'postgrest_api'").Error).To(BeNil()) - }) - - AfterAll(func() { - Expect(tx.Rollback().Error).To(BeNil()) - }) - - It("should allow INSERT when user has access to the config tags", func() { - payload := rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"test-cluster": "test-value"}}, - }, - } - Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) + It("should allow OR behavior across scopes", func() { + payload := rls.Payload{Scopes: []uuid.UUID{awsScopeID, gcpScopeID}} + Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) - newConfig := models.ConfigItem{ - ID: uuid.New(), - ConfigClass: "TestClass", - Type: lo.ToPtr("Test::Type"), - Name: lo.ToPtr("test-config-insert-allowed"), - Tags: types.JSONStringMap{ - "test-cluster": "test-value", - }, - } - - err := tx.Create(&newConfig).Error - Expect(err).To(BeNil(), "Should allow INSERT when user has access to the tags") - }) - - It("should deny INSERT when user doesn't have access to the config tags", func() { - payload := rls.Payload{ - Config: []rls.Scope{ - {Tags: map[string]string{"cluster": "aws"}}, - }, - } - Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) - - newConfig := models.ConfigItem{ - ID: uuid.New(), - ConfigClass: "TestClass", - Type: lo.ToPtr("Test::Type"), - Name: lo.ToPtr("test-config-insert-denied"), - Tags: types.JSONStringMap{ - "cluster": "unauthorized-cluster", - }, - } - - err := tx.Create(&newConfig).Error - Expect(err).ToNot(BeNil(), "Should deny INSERT when user doesn't have access to the tags") - Expect(err.Error()).To(ContainSubstring("new row violates row-level security policy")) - }) + var count int64 + Expect(tx.Model(&models.ConfigItem{}).Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(awsOrGcpConfigs)) }) - var _ = Describe("checks query", func() { - var ( - tx *gorm.DB - totalChecks int64 - logisticsAPICanaryChecksCount int64 - logisticsDBCanaryChecksCount int64 - cartAPICanaryAgentChecksCount int64 - logisticsAPIAndDBCanaryChecks int64 - ) - - BeforeAll(func() { - tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) - - Expect(DefaultContext.DB().Model(&models.Check{}).Count(&totalChecks).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("canary_id = ?", dummy.LogisticsAPICanary.ID).Model(&models.Check{}).Count(&logisticsAPICanaryChecksCount).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("canary_id = ?", dummy.LogisticsDBCanary.ID).Model(&models.Check{}).Count(&logisticsDBCanaryChecksCount).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("canary_id = ?", dummy.CartAPICanaryAgent.ID).Model(&models.Check{}).Count(&cartAPICanaryAgentChecksCount).Error).To(BeNil()) - logisticsAPIAndDBCanaryChecks = logisticsAPICanaryChecksCount + logisticsDBCanaryChecksCount + It("should allow wildcard config access", func() { + payload := rls.Payload{WildcardScopes: []rls.WildcardResourceScope{rls.WildcardResourceScopeConfig}} + Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) - Expect(totalChecks).To(BeNumerically(">", 0), "No checks found in test data") - Expect(logisticsAPICanaryChecksCount).To(BeNumerically(">", 0), "No checks found for LogisticsAPICanary") - Expect(logisticsDBCanaryChecksCount).To(BeNumerically(">", 0), "No checks found for LogisticsDBCanary") - Expect(cartAPICanaryAgentChecksCount).To(BeNumerically(">", 0), "No checks found for CartAPICanaryAgent") - }) - - AfterAll(func() { - Expect(tx.Commit().Error).To(BeNil()) - }) - - for _, role := range []string{"postgrest_anon", "postgrest_api"} { - Context(role, Ordered, func() { - BeforeAll(func() { - Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) - - var currentRole string - Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) - Expect(currentRole).To(Equal(role)) - }) - - DescribeTable("JWT claim tests", - func(tc testCase) { - Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) - - var count int64 - Expect(tx.Model(&models.Check{}).Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(*tc.expectedCount)) - }, - Entry("no permissions (empty scopes array)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{}, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("no permissions (non-existent canary)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Names: []string{"non-existent-canary"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("access checks via canary name (LogisticsAPICanary)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{dummy.LogisticsAPICanary.Name}}, - }, - }, - expectedCount: &logisticsAPICanaryChecksCount, - }), - Entry("access checks via canary name (LogisticsDBCanary)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{dummy.LogisticsDBCanary.Name}}, - }, - }, - expectedCount: &logisticsDBCanaryChecksCount, - }), - Entry("access checks via canary agent (CartAPICanaryAgent)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Agents: []string{dummy.GCPAgent.ID.String()}}, - }, - }, - expectedCount: &cartAPICanaryAgentChecksCount, - }), - Entry("access checks from multiple canaries (OR logic)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{dummy.LogisticsAPICanary.Name, dummy.LogisticsDBCanary.Name}}, - }, - }, - expectedCount: &logisticsAPIAndDBCanaryChecks, - }), - Entry("wildcard canary name (match all checks)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{"*"}}, - }, - }, - expectedCount: &totalChecks, - }), - Entry("empty string in canary names array (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{""}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("case sensitivity - uppercase canary name (should deny access)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{strings.ToUpper(dummy.LogisticsAPICanary.Name)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("empty scope object", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("whitespace-only canary name", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{" "}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("conflicting criteria within scope (agent matches but name doesn't)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Agents: []string{dummy.GCPAgent.ID.String()}, - Names: []string{"non-existent-canary"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), // AND logic means both must match - }), - Entry("valid canary agent + valid canary name (AND within scope)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Agents: []string{dummy.GCPAgent.ID.String()}, - Names: []string{dummy.CartAPICanaryAgent.Name}, - }, - }, - }, - expectedCount: &cartAPICanaryAgentChecksCount, - }), - Entry("multiple scopes with different canaries (OR logic)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{dummy.LogisticsAPICanary.Name}}, - {Names: []string{dummy.LogisticsDBCanary.Name}}, - }, - }, - expectedCount: &logisticsAPIAndDBCanaryChecks, - }), - Entry("tags only in scope (should deny access - canaries don't support tags)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Tags: map[string]string{"cluster": "test"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), // Should deny because canaries don't support tags - }), - Entry("mixed valid canary name and irrelevant tags", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Names: []string{dummy.LogisticsAPICanary.Name}, - Tags: map[string]string{"cluster": "test"}, - }, - }, - }, - expectedCount: &logisticsAPICanaryChecksCount, // Tags should be ignored for canaries - }), - Entry("very long canary names list (stress test)", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Names: append( - []string{dummy.LogisticsAPICanary.Name}, - func() []string { - names := make([]string, 99) - for i := range names { - names[i] = fmt.Sprintf("non-existent-canary-%d", i) - } - return names - }()..., - ), - }, - }, - }, - expectedCount: &logisticsAPICanaryChecksCount, - }), - Entry("multiple canary agents in single scope", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - { - Agents: []string{ - dummy.GCPAgent.ID.String(), - uuid.New().String(), - }, - }, - }, - }, - expectedCount: &cartAPICanaryAgentChecksCount, - }), - Entry("multiple scopes with overlapping results", testCase{ - rlsPayload: rls.Payload{ - Canary: []rls.Scope{ - {Names: []string{dummy.LogisticsAPICanary.Name}}, - {Agents: []string{uuid.Nil.String()}}, - }, - }, - expectedCount: &logisticsAPIAndDBCanaryChecks, // Union of both scopes - }), - ) - }) - } + var count int64 + Expect(tx.Model(&models.ConfigItem{}).Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(totalConfigs)) }) - var _ = Describe("views query", func() { - var ( - tx *gorm.DB - totalViews int64 - podsViewCount int64 - devDashboardCount int64 - podsAndDevDashboard int64 - ) - - BeforeAll(func() { - tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) - - Expect(DefaultContext.DB().Model(&models.View{}).Where("deleted_at IS NULL").Count(&totalViews).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("name = ? AND deleted_at IS NULL", dummy.PodView.Name).Model(&models.View{}).Count(&podsViewCount).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("name = ? AND deleted_at IS NULL", dummy.ViewDev.Name).Model(&models.View{}).Count(&devDashboardCount).Error).To(BeNil()) - podsAndDevDashboard = podsViewCount + devDashboardCount - - Expect(totalViews).To(BeNumerically(">", 0), "No views found in test data") - Expect(podsViewCount).To(BeNumerically(">", 0), "No pods view found") - Expect(devDashboardCount).To(BeNumerically(">", 0), "No dev dashboard view found") - }) - - AfterAll(func() { - Expect(tx.Commit().Error).To(BeNil()) - }) - - for _, role := range []string{"postgrest_anon", "postgrest_api"} { - Context(role, Ordered, func() { - BeforeAll(func() { - Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + It("should inherit RLS for config_changes", func() { + payload := rls.Payload{Scopes: []uuid.UUID{awsScopeID}} + Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) - var currentRole string - Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) - Expect(currentRole).To(Equal(role)) - }) - - DescribeTable("JWT claim tests", - func(tc testCase) { - Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) - - var count int64 - Expect(tx.Model(&models.View{}).Where("deleted_at IS NULL").Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(*tc.expectedCount)) - }, - Entry("no permissions (empty scopes array)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{}, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("no permissions (non-existent view)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - { - Names: []string{"non-existent-view"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("access specific view by name (pods)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{dummy.PodView.Name}}, - }, - }, - expectedCount: &podsViewCount, - }), - Entry("access specific view by name (Dev Dashboard)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{dummy.ViewDev.Name}}, - }, - }, - expectedCount: &devDashboardCount, - }), - Entry("access specific view by ID", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {ID: dummy.PodView.ID.String()}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("wildcard name (match all views)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{"*"}}, - }, - }, - expectedCount: &totalViews, - }), - Entry("wildcard ID (match all views)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {ID: "*"}, - }, - }, - expectedCount: &totalViews, - }), - Entry("multiple view names (OR within names array)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{dummy.PodView.Name, dummy.ViewDev.Name}}, - }, - }, - expectedCount: &podsAndDevDashboard, - }), - Entry("mixed scope criteria (OR logic between scopes)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{dummy.PodView.Name}}, - {Names: []string{dummy.ViewDev.Name}}, - }, - }, - expectedCount: &podsAndDevDashboard, - }), - Entry("ID + matching name (AND logic - should grant access)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - { - ID: dummy.PodView.ID.String(), - Names: []string{dummy.PodView.Name}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("ID + non-matching name (AND logic - should deny access)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - { - ID: dummy.PodView.ID.String(), - Names: []string{"wrong-name"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple scopes with different IDs (OR logic)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {ID: dummy.PodView.ID.String()}, - {ID: dummy.ViewDev.ID.String()}, - }, - }, - expectedCount: lo.ToPtr(int64(2)), - }), - Entry("empty string in names array (should deny access)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{""}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("case sensitivity - uppercase name (should deny access)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{strings.ToUpper(dummy.PodView.Name)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("duplicate scopes (should work same as single)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{dummy.PodView.Name}}, - {Names: []string{dummy.PodView.Name}}, // duplicate - }, - }, - expectedCount: &podsViewCount, - }), - Entry("very long names list (stress test)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - { - Names: append( - []string{dummy.PodView.Name}, - func() []string { - names := make([]string, 99) - for i := range names { - names[i] = fmt.Sprintf("non-existent-view-%d", i) - } - return names - }()..., - ), - }, - }, - }, - expectedCount: &podsViewCount, - }), - Entry("whitespace-only name", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{" "}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("extremely long name string", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{strings.Repeat("a", 1000)}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("name with wildcard in middle", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{"pod*view"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("name with wildcard prefix", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{"*view"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("multiple scopes with overlapping results", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{dummy.PodView.Name}}, - {ID: dummy.PodView.ID.String()}, - }, - }, - expectedCount: &podsViewCount, - }), - Entry("empty scope object", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("wrong ID (should deny access)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {ID: "00000000-0000-0000-0000-000000000000"}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("tags only in scope (should deny access - views don't support tags)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - { - Tags: map[string]string{"environment": "production"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), // Should deny because views don't support tags - }), - Entry("agents only in scope (should deny access - views don't support agents)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - { - Agents: []string{"00000000-0000-0000-0000-000000000000"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), // Should deny because views don't support agents - }), - Entry("tags and agents only in scope (should deny access - no applicable fields)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - { - Tags: map[string]string{"environment": "production"}, - Agents: []string{"00000000-0000-0000-0000-000000000000"}, - }, - }, - }, - expectedCount: lo.ToPtr(int64(0)), // Should deny because views support neither tags nor agents - }), - Entry("valid name + irrelevant tags (tags should be ignored)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - { - Names: []string{dummy.PodView.Name}, - Tags: map[string]string{"environment": "production"}, - }, - }, - }, - expectedCount: &podsViewCount, // Tags should be ignored for views - }), - Entry("valid name + irrelevant agents (agents should be ignored)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - { - Names: []string{dummy.PodView.Name}, - Agents: []string{"00000000-0000-0000-0000-000000000000"}, - }, - }, - }, - expectedCount: &podsViewCount, // Agents should be ignored for views - }), - Entry("mixed valid and invalid names", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - { - Names: []string{ - dummy.PodView.Name, - "non-existent-1", - dummy.ViewDev.Name, - "non-existent-2", - }, - }, - }, - }, - expectedCount: &podsAndDevDashboard, - }), - Entry("newline in name", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{"pods\nmalicious"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("special characters in name (unicode)", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{"view-名前-🚀"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("very many scopes (stress test)", testCase{ - rlsPayload: rls.Payload{ - View: append( - []rls.Scope{{Names: []string{dummy.PodView.Name}}}, - func() []rls.Scope { - scopes := make([]rls.Scope, 49) - for i := range scopes { - scopes[i] = rls.Scope{ - Names: []string{fmt.Sprintf("non-existent-%d", i)}, - } - } - return scopes - }()..., - ), - }, - expectedCount: &podsViewCount, - }), - ) - }) - } + var count int64 + Expect(tx.Table("config_changes").Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(awsConfigChanges)) }) - var _ = Describe("view_panels query", func() { - var ( - tx *gorm.DB - totalViewPanels int64 - podViewPanelCount int64 - devViewPanelCount int64 - ) - - BeforeAll(func() { - tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) - - Expect(DefaultContext.DB().Model(&models.ViewPanel{}).Count(&totalViewPanels).Error).To(BeNil()) - Expect(totalViewPanels).To(Equal(int64(2)), "Expected exactly 2 view panels in test data") - - // Count panels for PodView specifically - Expect(DefaultContext.DB().Where("view_id = ?", dummy.PodView.ID).Model(&models.ViewPanel{}).Count(&podViewPanelCount).Error).To(BeNil()) - Expect(podViewPanelCount).To(Equal(int64(1)), "Expected exactly 1 panel for PodView") - - // Count panels for DevView specifically - Expect(DefaultContext.DB().Where("view_id = ?", dummy.ViewDev.ID).Model(&models.ViewPanel{}).Count(&devViewPanelCount).Error).To(BeNil()) - Expect(devViewPanelCount).To(Equal(int64(1)), "Expected exactly 1 panel for DevView") - }) + It("should deny access for unknown scope", func() { + payload := rls.Payload{Scopes: []uuid.UUID{uuid.New()}} + Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) - AfterAll(func() { - Expect(tx.Commit().Error).To(BeNil()) - }) - - for _, role := range []string{"postgrest_anon", "postgrest_api"} { - Context(role, Ordered, func() { - BeforeAll(func() { - Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) - - var currentRole string - Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) - Expect(currentRole).To(Equal(role)) - }) - - DescribeTable("JWT claim tests", - func(tc testCase) { - Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) - - var count int64 - Expect(tx.Model(&models.ViewPanel{}).Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(*tc.expectedCount)) - }, - Entry("user has permission to PodView - should see 1 view panel", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{dummy.PodView.Name}}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("user has no view permissions - should see 0 view panels", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{}, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("user has permission to non-existent view - should see 0 view panels", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{"non-existent-view"}}, - }, - }, - expectedCount: lo.ToPtr(int64(0)), - }), - Entry("user has permission to ViewDev - should see 1 view panel", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{dummy.ViewDev.Name}}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("user has permission to both views - should see 2 view panels", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{dummy.PodView.Name, dummy.ViewDev.Name}}, - }, - }, - expectedCount: lo.ToPtr(int64(2)), - }), - Entry("user has wildcard permission - should see all panels", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {Names: []string{"*"}}, - }, - }, - expectedCount: &totalViewPanels, - }), - Entry("user has permission by view ID - should see panel", testCase{ - rlsPayload: rls.Payload{ - View: []rls.Scope{ - {ID: dummy.PodView.ID.String()}, - }, - }, - expectedCount: lo.ToPtr(int64(1)), - }), - Entry("RLS disabled - should see all panels", testCase{ - rlsPayload: rls.Payload{ - Disable: true, - }, - expectedCount: &totalViewPanels, - }), - ) - }) - } + var count int64 + Expect(tx.Model(&models.ConfigItem{}).Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(int64(0))) }) }) From d1209d8d857e0283c4bcdd8170737ab7c9ecd248 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 14 Jan 2026 14:21:30 +0545 Subject: [PATCH 04/10] update AGENTs.md and makefile --- AGENTS.md | 10 +++++----- Makefile | 4 ++++ rls/payload.go | 23 ----------------------- 3 files changed, 9 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9cd7e6289..0a4ed2901 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,10 +15,10 @@ 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 use the `__scope` array column and compare it 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` +- Policy checks scope overlap using `COALESCE(__scope, '{}'::uuid[]) && rls_scope_access()` and wildcard via `rls_has_wildcard('')`. **Inherited Policies**: Child tables inherit access control from their parent using `EXISTS` clauses. @@ -29,7 +29,7 @@ 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 policy (either direct with `__scope` overlap or inherited with `EXISTS`) 2. Add counterpart disable logic to `@views/9999_rls_disable.sql` - Disable RLS on the table - Drop the policy @@ -42,7 +42,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/rls/payload.go b/rls/payload.go index 3e8178423..b301a8eae 100644 --- a/rls/payload.go +++ b/rls/payload.go @@ -6,20 +6,12 @@ import ( "slices" "strings" - "github.com/flanksource/commons/collections" "github.com/flanksource/commons/hash" "github.com/google/uuid" "github.com/lib/pq" "gorm.io/gorm" ) -type Scope struct { - Tags map[string]string `json:"tags,omitempty"` - Agents []string `json:"agents,omitempty"` - Names []string `json:"names,omitempty"` - ID string `json:"id,omitempty"` -} - type WildcardResourceScope string const ( @@ -30,21 +22,6 @@ const ( WildcardResourceScopeView WildcardResourceScope = "view" ) -func (s Scope) IsEmpty() bool { - return len(s.Tags) == 0 && len(s.Agents) == 0 && len(s.Names) == 0 && strings.TrimSpace(s.ID) == "" -} - -func (s Scope) Fingerprint() string { - tagSelectors := collections.SortedMap(s.Tags) - agentsCopy := slices.Clone(s.Agents) - namesCopy := slices.Clone(s.Names) - slices.Sort(agentsCopy) - slices.Sort(namesCopy) - - data := fmt.Sprintf("agents:%s | tags:%s | names:%s | id:%s", strings.Join(agentsCopy, "--"), tagSelectors, strings.Join(namesCopy, "--"), strings.TrimSpace(s.ID)) - return fmt.Sprintf("scope::%s", hash.Sha256Hex(data)) -} - // RLS Payload that's injected postgresl parameter `request.jwt.claims` type Payload struct { // cached fingerprint From cf11de5cfa54c44fe16af5ddc87bc6548349a40f Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 14 Jan 2026 14:34:12 +0545 Subject: [PATCH 05/10] fix: benchmark --- bench/bench_test.go | 5 +++-- bench/utils_test.go | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) 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 From a93313b64b64e6cd7e410c9cf94222baeed9c006 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 14 Jan 2026 18:15:18 +0545 Subject: [PATCH 06/10] bring back old tests --- tests/rls_test.go | 395 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 322 insertions(+), 73 deletions(-) diff --git a/tests/rls_test.go b/tests/rls_test.go index dcb29e609..d08986f28 100644 --- a/tests/rls_test.go +++ b/tests/rls_test.go @@ -1,113 +1,362 @@ package tests import ( + "database/sql" + "fmt" "os" "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/samber/lo" "gorm.io/gorm" - "github.com/flanksource/duty/api" - "github.com/flanksource/duty/migrate" "github.com/flanksource/duty/models" "github.com/flanksource/duty/rls" + "github.com/flanksource/duty/tests/fixtures/dummy" ) -var _ = Describe("RLS test", Ordered, ContinueOnFailure, func() { +type scopeCase struct { + payload func() rls.Payload + expectedCount *int64 +} + +func setRLS(tx *gorm.DB, payload rls.Payload) { + Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) +} + +func payloadNoScopes() func() rls.Payload { + return func() rls.Payload { + return rls.Payload{} + } +} + +func payloadWithScopes(scopeIDs ...*uuid.UUID) func() rls.Payload { + return func() rls.Payload { + ids := make([]uuid.UUID, 0, len(scopeIDs)) + for _, id := range scopeIDs { + if id != nil { + ids = append(ids, *id) + } + } + return rls.Payload{Scopes: ids} + } +} + +func payloadWildcard(scope rls.WildcardResourceScope) func() rls.Payload { + return func() rls.Payload { + return rls.Payload{WildcardScopes: []rls.WildcardResourceScope{scope}} + } +} + +func payloadDisabled() func() rls.Payload { + return func() rls.Payload { + return rls.Payload{Disable: true} + } +} + +func resetScopes(db *gorm.DB, tables ...string) { + for _, table := range tables { + Expect(db.Exec(fmt.Sprintf("UPDATE %s SET __scope = NULL", table)).Error).To(BeNil()) + } +} + +func assignScope(db *gorm.DB, table string, scopeID uuid.UUID, where string, args ...any) { + query := fmt.Sprintf("UPDATE %s SET __scope = array_append(COALESCE(__scope, '{}'::uuid[]), ?) WHERE %s", table, where) + Expect(db.Exec(query, append([]any{scopeID}, args...)...).Error).To(BeNil()) +} + +var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { BeforeAll(func() { if os.Getenv("DUTY_DB_DISABLE_RLS") == "true" { Skip("RLS tests are disabled because DUTY_DB_DISABLE_RLS is set to true") } }) - var ( - tx *gorm.DB - totalConfigs int64 - awsConfigs int64 - gcpConfigs int64 - awsOrGcpConfigs int64 - awsConfigChanges int64 - awsScopeID uuid.UUID - gcpScopeID uuid.UUID - ) + var _ = Describe("config_items", func() { + var ( + tx *gorm.DB + totalConfigs int64 + awsConfigs int64 + demoConfigs int64 + awsOrDemo int64 + awsScopeID = uuid.New() + demoScopeID = uuid.New() + ) - BeforeAll(func() { - Expect(DefaultContext.DB().Model(&models.ConfigItem{}).Count(&totalConfigs).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws'").Model(&models.ConfigItem{}).Count(&awsConfigs).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("tags->>'cluster' = 'gcp'").Model(&models.ConfigItem{}).Count(&gcpConfigs).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("tags->>'cluster' IN ('aws', 'gcp')").Model(&models.ConfigItem{}).Count(&awsOrGcpConfigs).Error).To(BeNil()) - Expect(DefaultContext.DB().Table("config_changes"). - Joins("JOIN config_items ON config_items.id = config_changes.config_id"). - Where("config_items.tags->>'cluster' = 'aws'"). - Count(&awsConfigChanges).Error).To(BeNil()) - - sqldb, err := DefaultContext.DB().DB() - Expect(err).To(BeNil()) - - Expect(DefaultContext.DB().Exec("DELETE FROM migration_logs").Error).To(BeNil()) - - connString := DefaultContext.Value("db_url").(string) - err = migrate.RunMigrations(sqldb, api.Config{ConnectionString: connString, EnableRLS: true}) - Expect(err).To(BeNil()) - - awsScopeID = uuid.New() - gcpScopeID = uuid.New() - Expect(DefaultContext.DB().Exec("UPDATE config_items SET __scope = ARRAY[?]::uuid[] WHERE tags->>'cluster' = 'aws'", awsScopeID).Error).To(BeNil()) - Expect(DefaultContext.DB().Exec("UPDATE config_items SET __scope = ARRAY[?]::uuid[] WHERE tags->>'cluster' = 'gcp'", gcpScopeID).Error).To(BeNil()) - - tx = DefaultContext.DB().Begin() - Expect(tx.Exec("SET LOCAL ROLE 'postgrest_api'").Error).To(BeNil()) - }) + BeforeAll(func() { + tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) + + Expect(DefaultContext.DB().Model(&models.ConfigItem{}).Count(&totalConfigs).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws'").Model(&models.ConfigItem{}).Count(&awsConfigs).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("tags->>'cluster' = 'demo'").Model(&models.ConfigItem{}).Count(&demoConfigs).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("tags->>'cluster' IN ('aws', 'demo')").Model(&models.ConfigItem{}).Count(&awsOrDemo).Error).To(BeNil()) + + resetScopes(DefaultContext.DB(), "config_items") + assignScope(DefaultContext.DB(), "config_items", awsScopeID, "tags->>'cluster' = ?", "aws") + assignScope(DefaultContext.DB(), "config_items", demoScopeID, "tags->>'cluster' = ?", "demo") + }) - AfterAll(func() { - if tx != nil { + AfterAll(func() { Expect(tx.Commit().Error).To(BeNil()) + }) + + for _, role := range []string{"postgrest_anon", "postgrest_api"} { + Context(role, Ordered, func() { + BeforeAll(func() { + Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + }) + + DescribeTable("RLS scope tests", + func(tc scopeCase) { + setRLS(tx, tc.payload()) + + var count int64 + Expect(tx.Model(&models.ConfigItem{}).Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(*tc.expectedCount)) + }, + Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), + Entry("aws scope", scopeCase{payload: payloadWithScopes(&awsScopeID), expectedCount: &awsConfigs}), + Entry("combined scopes", scopeCase{payload: payloadWithScopes(&awsScopeID, &demoScopeID), expectedCount: &awsOrDemo}), + Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopeConfig), expectedCount: &totalConfigs}), + Entry("rls disabled", scopeCase{payload: payloadDisabled(), expectedCount: &totalConfigs}), + ) + }) } }) - It("should filter config_items by scope", func() { - payload := rls.Payload{Scopes: []uuid.UUID{awsScopeID}} - Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) + var _ = Describe("components", func() { + var ( + tx *gorm.DB + totalComponents int64 + agentComponents int64 + logisticsComponents int64 + agentOrLogistics int64 + agentScopeID uuid.UUID + logisticsScopeID uuid.UUID + ) - var count int64 - Expect(tx.Model(&models.ConfigItem{}).Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(awsConfigs)) - }) + BeforeAll(func() { + tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) - It("should allow OR behavior across scopes", func() { - payload := rls.Payload{Scopes: []uuid.UUID{awsScopeID, gcpScopeID}} - Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) + Expect(DefaultContext.DB().Model(&models.Component{}).Count(&totalComponents).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("agent_id = ?", uuid.Nil).Model(&models.Component{}).Count(&agentComponents).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("name = ?", dummy.Logistics.Name).Model(&models.Component{}).Count(&logisticsComponents).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("agent_id = ? OR name = ?", uuid.Nil, dummy.Logistics.Name).Model(&models.Component{}).Count(&agentOrLogistics).Error).To(BeNil()) + + resetScopes(DefaultContext.DB(), "components") + agentScopeID = uuid.New() + logisticsScopeID = uuid.New() + assignScope(DefaultContext.DB(), "components", agentScopeID, "agent_id = ?", uuid.Nil) + assignScope(DefaultContext.DB(), "components", logisticsScopeID, "name = ?", dummy.Logistics.Name) + }) + + AfterAll(func() { + Expect(tx.Commit().Error).To(BeNil()) + }) - var count int64 - Expect(tx.Model(&models.ConfigItem{}).Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(awsOrGcpConfigs)) + for _, role := range []string{"postgrest_anon", "postgrest_api"} { + Context(role, Ordered, func() { + BeforeAll(func() { + Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + }) + + DescribeTable("RLS scope tests", + func(tc scopeCase) { + setRLS(tx, tc.payload()) + + var count int64 + Expect(tx.Model(&models.Component{}).Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(*tc.expectedCount)) + }, + Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), + Entry("agent scope", scopeCase{payload: payloadWithScopes(&agentScopeID), expectedCount: &agentComponents}), + Entry("name scope", scopeCase{payload: payloadWithScopes(&logisticsScopeID), expectedCount: &logisticsComponents}), + Entry("combined scopes", scopeCase{payload: payloadWithScopes(&agentScopeID, &logisticsScopeID), expectedCount: &agentOrLogistics}), + Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopeComponent), expectedCount: &totalComponents}), + ) + }) + } }) - It("should allow wildcard config access", func() { - payload := rls.Payload{WildcardScopes: []rls.WildcardResourceScope{rls.WildcardResourceScopeConfig}} - Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) + var _ = Describe("playbooks", func() { + var ( + tx *gorm.DB + totalPlaybooks int64 + combinedPlaybook int64 + echoScopeID uuid.UUID + restartScopeID uuid.UUID + ) + + BeforeAll(func() { + tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) + + Expect(DefaultContext.DB().Model(&models.Playbook{}).Count(&totalPlaybooks).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("id IN ?", []uuid.UUID{dummy.EchoConfig.ID, dummy.RestartPod.ID}).Model(&models.Playbook{}).Count(&combinedPlaybook).Error).To(BeNil()) + + resetScopes(DefaultContext.DB(), "playbooks") + echoScopeID = uuid.New() + restartScopeID = uuid.New() + assignScope(DefaultContext.DB(), "playbooks", echoScopeID, "id = ?", dummy.EchoConfig.ID) + assignScope(DefaultContext.DB(), "playbooks", restartScopeID, "id = ?", dummy.RestartPod.ID) + }) - var count int64 - Expect(tx.Model(&models.ConfigItem{}).Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(totalConfigs)) + AfterAll(func() { + Expect(tx.Commit().Error).To(BeNil()) + }) + + for _, role := range []string{"postgrest_anon", "postgrest_api"} { + Context(role, Ordered, func() { + BeforeAll(func() { + Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + }) + + DescribeTable("RLS scope tests", + func(tc scopeCase) { + setRLS(tx, tc.payload()) + + var count int64 + Expect(tx.Model(&models.Playbook{}).Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(*tc.expectedCount)) + }, + Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), + Entry("echo scope", scopeCase{payload: payloadWithScopes(&echoScopeID), expectedCount: lo.ToPtr(int64(1))}), + Entry("combined scopes", scopeCase{payload: payloadWithScopes(&echoScopeID, &restartScopeID), expectedCount: &combinedPlaybook}), + Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopePlaybook), expectedCount: &totalPlaybooks}), + ) + }) + } }) - It("should inherit RLS for config_changes", func() { - payload := rls.Payload{Scopes: []uuid.UUID{awsScopeID}} - Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) + var _ = Describe("canaries and checks", func() { + var ( + tx *gorm.DB + totalCanaries int64 + logisticsScopeID uuid.UUID + totalChecks int64 + logisticsChecks int64 + ) + + BeforeAll(func() { + tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) + + Expect(DefaultContext.DB().Model(&models.Canary{}).Count(&totalCanaries).Error).To(BeNil()) + Expect(DefaultContext.DB().Model(&models.Check{}).Count(&totalChecks).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("canary_id = ?", dummy.LogisticsAPICanary.ID).Model(&models.Check{}).Count(&logisticsChecks).Error).To(BeNil()) + + resetScopes(DefaultContext.DB(), "canaries") + logisticsScopeID = uuid.New() + assignScope(DefaultContext.DB(), "canaries", logisticsScopeID, "id = ?", dummy.LogisticsAPICanary.ID) + }) + + AfterAll(func() { + Expect(tx.Commit().Error).To(BeNil()) + }) + + for _, role := range []string{"postgrest_anon", "postgrest_api"} { + Context(role, Ordered, func() { + BeforeAll(func() { + Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + }) + + DescribeTable("canary scopes", + func(tc scopeCase) { + setRLS(tx, tc.payload()) + + var count int64 + Expect(tx.Model(&models.Canary{}).Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(*tc.expectedCount)) + }, + Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), + Entry("logistics scope", scopeCase{payload: payloadWithScopes(&logisticsScopeID), expectedCount: lo.ToPtr(int64(1))}), + Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopeCanary), expectedCount: &totalCanaries}), + ) - var count int64 - Expect(tx.Table("config_changes").Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(awsConfigChanges)) + DescribeTable("checks inherit canary RLS", + func(tc scopeCase) { + setRLS(tx, tc.payload()) + + var count int64 + Expect(tx.Model(&models.Check{}).Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(*tc.expectedCount)) + }, + Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), + Entry("logistics scope", scopeCase{payload: payloadWithScopes(&logisticsScopeID), expectedCount: &logisticsChecks}), + Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopeCanary), expectedCount: &totalChecks}), + ) + }) + } }) - It("should deny access for unknown scope", func() { - payload := rls.Payload{Scopes: []uuid.UUID{uuid.New()}} - Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) + var _ = Describe("views and panels", func() { + var ( + tx *gorm.DB + totalViews int64 + totalPanels int64 + podScopeID uuid.UUID + devScopeID uuid.UUID + podViewPanels int64 + devViewPanels int64 + combinedViews int64 + combinedPanels int64 + ) + + BeforeAll(func() { + tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) + + Expect(DefaultContext.DB().Model(&models.View{}).Where("deleted_at IS NULL").Count(&totalViews).Error).To(BeNil()) + Expect(DefaultContext.DB().Model(&models.ViewPanel{}).Count(&totalPanels).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("view_id = ?", dummy.PodView.ID).Model(&models.ViewPanel{}).Count(&podViewPanels).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("view_id = ?", dummy.ViewDev.ID).Model(&models.ViewPanel{}).Count(&devViewPanels).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("id IN ?", []uuid.UUID{dummy.PodView.ID, dummy.ViewDev.ID}).Model(&models.View{}).Count(&combinedViews).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("view_id IN ?", []uuid.UUID{dummy.PodView.ID, dummy.ViewDev.ID}).Model(&models.ViewPanel{}).Count(&combinedPanels).Error).To(BeNil()) + + resetScopes(DefaultContext.DB(), "views") + podScopeID = uuid.New() + devScopeID = uuid.New() + assignScope(DefaultContext.DB(), "views", podScopeID, "id = ?", dummy.PodView.ID) + assignScope(DefaultContext.DB(), "views", devScopeID, "id = ?", dummy.ViewDev.ID) + }) + + AfterAll(func() { + Expect(tx.Commit().Error).To(BeNil()) + }) + + for _, role := range []string{"postgrest_anon", "postgrest_api"} { + Context(role, Ordered, func() { + BeforeAll(func() { + Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + }) + + DescribeTable("views", + func(tc scopeCase) { + setRLS(tx, tc.payload()) - var count int64 - Expect(tx.Model(&models.ConfigItem{}).Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(int64(0))) + var count int64 + Expect(tx.Model(&models.View{}).Where("deleted_at IS NULL").Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(*tc.expectedCount)) + }, + Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), + Entry("pod view", scopeCase{payload: payloadWithScopes(&podScopeID), expectedCount: lo.ToPtr(int64(1))}), + Entry("combined", scopeCase{payload: payloadWithScopes(&podScopeID, &devScopeID), expectedCount: &combinedViews}), + Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopeView), expectedCount: &totalViews}), + ) + + DescribeTable("view panels", + func(tc scopeCase) { + setRLS(tx, tc.payload()) + + var count int64 + Expect(tx.Model(&models.ViewPanel{}).Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(*tc.expectedCount)) + }, + Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), + Entry("pod view", scopeCase{payload: payloadWithScopes(&podScopeID), expectedCount: &podViewPanels}), + Entry("dev view", scopeCase{payload: payloadWithScopes(&devScopeID), expectedCount: &devViewPanels}), + Entry("combined", scopeCase{payload: payloadWithScopes(&podScopeID, &devScopeID), expectedCount: &combinedPanels}), + Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopeView), expectedCount: &totalPanels}), + ) + }) + } }) }) From 05e3c9f7ad2247c7973507163d7633bba98d98a3 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 14 Jan 2026 21:41:04 +0545 Subject: [PATCH 07/10] fix: don't copy bench_test --- .github/workflows/benchmark.yml | 1 - 1 file changed, 1 deletion(-) 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 From 27df067dfebe11ca6632c62e1e810af845a512ef Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 15 Jan 2026 18:36:34 +0545 Subject: [PATCH 08/10] fix: remove wildcard optimization --- rls/payload.go | 48 ++++++-------------------------------- rls/payload_test.go | 6 +---- tests/rls_test.go | 13 ----------- types/resource_selector.go | 4 ---- views/035_rls_utils.sql | 21 ----------------- views/035_view_rls.sql | 4 ---- views/9998_rls_enable.sql | 15 ++++-------- 7 files changed, 13 insertions(+), 98 deletions(-) diff --git a/rls/payload.go b/rls/payload.go index b301a8eae..937ae9ec0 100644 --- a/rls/payload.go +++ b/rls/payload.go @@ -12,16 +12,6 @@ import ( "gorm.io/gorm" ) -type WildcardResourceScope string - -const ( - WildcardResourceScopeConfig WildcardResourceScope = "config" - WildcardResourceScopeComponent WildcardResourceScope = "component" - WildcardResourceScopeCanary WildcardResourceScope = "canary" - WildcardResourceScopePlaybook WildcardResourceScope = "playbook" - WildcardResourceScopeView WildcardResourceScope = "view" -) - // RLS Payload that's injected postgresl parameter `request.jwt.claims` type Payload struct { // cached fingerprint @@ -30,11 +20,6 @@ type Payload struct { // Scopes contains the list of scope UUIDs the user has access to. Scopes []uuid.UUID `json:"scopes,omitempty"` - // WildcardScopes contains resource types that grant access to all rows of that type. - // Wildcard scopes are not materialized directly into the table rows to avoid high writes/updates. - // Instead, if a user has wildcard scope to a resource type, then the RLS policy matches immediately. - WildcardScopes []WildcardResourceScope `json:"wildcard_scopes,omitempty"` - Disable bool `json:"disable_rls,omitempty"` } @@ -50,10 +35,6 @@ func (t Payload) JWTClaims() map[string]any { claims["scopes"] = t.Scopes } - if len(t.WildcardScopes) > 0 { - claims["wildcard_scopes"] = t.WildcardScopes - } - return claims } @@ -63,32 +44,17 @@ func (t *Payload) EvalFingerprint() { return } - parts := []string{} - if len(t.Scopes) > 0 { - 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, ",")) - } - - if len(t.WildcardScopes) > 0 { - wildcardsCopy := make([]string, 0, len(t.WildcardScopes)) - for _, wildcard := range t.WildcardScopes { - wildcardsCopy = append(wildcardsCopy, string(wildcard)) - } - slices.Sort(wildcardsCopy) - parts = append(parts, strings.Join(wildcardsCopy, ",")) - } - - if len(parts) == 0 { + if len(t.Scopes) == 0 { t.fingerprint = "empty" return } - slices.Sort(parts) - t.fingerprint = hash.Sha256Hex(strings.Join(parts, " | ")) + scopesCopy := make([]string, 0, len(t.Scopes)) + for _, scope := range t.Scopes { + scopesCopy = append(scopesCopy, scope.String()) + } + slices.Sort(scopesCopy) + t.fingerprint = hash.Sha256Hex(strings.Join(scopesCopy, ",")) } func (t *Payload) Fingerprint() string { diff --git a/rls/payload_test.go b/rls/payload_test.go index 87b671687..b03f25314 100644 --- a/rls/payload_test.go +++ b/rls/payload_test.go @@ -27,7 +27,6 @@ func TestPayload_EvalFingerprint(t *testing.T) { uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2"), uuid.MustParse("0a1ce1b2-5d90-4e74-8d30-2f4f0d30f8e4"), }, - WildcardScopes: []WildcardResourceScope{WildcardResourceScopePlaybook, WildcardResourceScopeConfig}, } payload.EvalFingerprint() @@ -54,7 +53,6 @@ func TestPayload_EvalFingerprint(t *testing.T) { uuid.MustParse("0a1ce1b2-5d90-4e74-8d30-2f4f0d30f8e4"), uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2"), }, - WildcardScopes: []WildcardResourceScope{WildcardResourceScopeView, WildcardResourceScopeConfig}, } payload1.EvalFingerprint() @@ -63,7 +61,6 @@ func TestPayload_EvalFingerprint(t *testing.T) { uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2"), uuid.MustParse("0a1ce1b2-5d90-4e74-8d30-2f4f0d30f8e4"), }, - WildcardScopes: []WildcardResourceScope{WildcardResourceScopeConfig, WildcardResourceScopeView}, } payload2.EvalFingerprint() @@ -75,8 +72,7 @@ func TestPayload_EvalFingerprint(t *testing.T) { g := gomega.NewWithT(t) payload := &Payload{ - Scopes: []uuid.UUID{uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2")}, - WildcardScopes: []WildcardResourceScope{WildcardResourceScopeCanary}, + Scopes: []uuid.UUID{uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2")}, } payload.EvalFingerprint() firstFingerprint := payload.Fingerprint() diff --git a/tests/rls_test.go b/tests/rls_test.go index d08986f28..45e090751 100644 --- a/tests/rls_test.go +++ b/tests/rls_test.go @@ -43,12 +43,6 @@ func payloadWithScopes(scopeIDs ...*uuid.UUID) func() rls.Payload { } } -func payloadWildcard(scope rls.WildcardResourceScope) func() rls.Payload { - return func() rls.Payload { - return rls.Payload{WildcardScopes: []rls.WildcardResourceScope{scope}} - } -} - func payloadDisabled() func() rls.Payload { return func() rls.Payload { return rls.Payload{Disable: true} @@ -118,7 +112,6 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), Entry("aws scope", scopeCase{payload: payloadWithScopes(&awsScopeID), expectedCount: &awsConfigs}), Entry("combined scopes", scopeCase{payload: payloadWithScopes(&awsScopeID, &demoScopeID), expectedCount: &awsOrDemo}), - Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopeConfig), expectedCount: &totalConfigs}), Entry("rls disabled", scopeCase{payload: payloadDisabled(), expectedCount: &totalConfigs}), ) }) @@ -173,7 +166,6 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { Entry("agent scope", scopeCase{payload: payloadWithScopes(&agentScopeID), expectedCount: &agentComponents}), Entry("name scope", scopeCase{payload: payloadWithScopes(&logisticsScopeID), expectedCount: &logisticsComponents}), Entry("combined scopes", scopeCase{payload: payloadWithScopes(&agentScopeID, &logisticsScopeID), expectedCount: &agentOrLogistics}), - Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopeComponent), expectedCount: &totalComponents}), ) }) } @@ -222,7 +214,6 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), Entry("echo scope", scopeCase{payload: payloadWithScopes(&echoScopeID), expectedCount: lo.ToPtr(int64(1))}), Entry("combined scopes", scopeCase{payload: payloadWithScopes(&echoScopeID, &restartScopeID), expectedCount: &combinedPlaybook}), - Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopePlaybook), expectedCount: &totalPlaybooks}), ) }) } @@ -269,7 +260,6 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { }, Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), Entry("logistics scope", scopeCase{payload: payloadWithScopes(&logisticsScopeID), expectedCount: lo.ToPtr(int64(1))}), - Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopeCanary), expectedCount: &totalCanaries}), ) DescribeTable("checks inherit canary RLS", @@ -282,7 +272,6 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { }, Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), Entry("logistics scope", scopeCase{payload: payloadWithScopes(&logisticsScopeID), expectedCount: &logisticsChecks}), - Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopeCanary), expectedCount: &totalChecks}), ) }) } @@ -339,7 +328,6 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), Entry("pod view", scopeCase{payload: payloadWithScopes(&podScopeID), expectedCount: lo.ToPtr(int64(1))}), Entry("combined", scopeCase{payload: payloadWithScopes(&podScopeID, &devScopeID), expectedCount: &combinedViews}), - Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopeView), expectedCount: &totalViews}), ) DescribeTable("view panels", @@ -354,7 +342,6 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { Entry("pod view", scopeCase{payload: payloadWithScopes(&podScopeID), expectedCount: &podViewPanels}), Entry("dev view", scopeCase{payload: payloadWithScopes(&devScopeID), expectedCount: &devViewPanels}), Entry("combined", scopeCase{payload: payloadWithScopes(&podScopeID, &devScopeID), expectedCount: &combinedPanels}), - Entry("wildcard", scopeCase{payload: payloadWildcard(rls.WildcardResourceScopeView), expectedCount: &totalPanels}), ) }) } 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 1a22ec5c3..c9ed9e7fc 100644 --- a/views/035_rls_utils.sql +++ b/views/035_rls_utils.sql @@ -28,24 +28,3 @@ BEGIN ); END; $$ LANGUAGE plpgsql STABLE SECURITY INVOKER; - --- rls_has_wildcard reports whether request.jwt.claims includes the given wildcard scope type. -CREATE -OR REPLACE FUNCTION rls_has_wildcard(scope_type TEXT) RETURNS BOOLEAN AS $$ -DECLARE - jwt_claims TEXT; -BEGIN - jwt_claims := current_setting('request.jwt.claims', TRUE); - IF jwt_claims IS NULL OR jwt_claims = '' THEN - RETURN FALSE; - END IF; - - RETURN EXISTS ( - SELECT 1 - FROM jsonb_array_elements_text( - COALESCE(jwt_claims::jsonb -> 'wildcard_scopes', '[]'::jsonb) - ) AS wildcard - WHERE wildcard = scope_type - ); -END; -$$ LANGUAGE plpgsql STABLE SECURITY INVOKER; diff --git a/views/035_view_rls.sql b/views/035_view_rls.sql index ed4723283..43b8524da 100644 --- a/views/035_view_rls.sql +++ b/views/035_view_rls.sql @@ -11,10 +11,6 @@ CREATE OR REPLACE FUNCTION check_view_grants(grants jsonb) RETURNS BOOLEAN AS $$ BEGIN - IF rls_has_wildcard('view') THEN - RETURN TRUE; - END IF; - IF grants IS NULL OR jsonb_array_length(grants) = 0 THEN RETURN FALSE; END IF; diff --git a/views/9998_rls_enable.sql b/views/9998_rls_enable.sql index 540654170..a56d8781f 100644 --- a/views/9998_rls_enable.sql +++ b/views/9998_rls_enable.sql @@ -59,8 +59,7 @@ CREATE POLICY config_items_auth ON config_items USING ( CASE WHEN (SELECT is_rls_disabled()) THEN TRUE ELSE - rls_has_wildcard('config') - OR (COALESCE(config_items.__scope, '{}'::uuid[]) && rls_scope_access()) + (config_items.__scope && rls_scope_access()) END ); @@ -135,8 +134,7 @@ CREATE POLICY components_auth ON components USING ( CASE WHEN (SELECT is_rls_disabled()) THEN TRUE ELSE - rls_has_wildcard('component') - OR (COALESCE(components.__scope, '{}'::uuid[]) && rls_scope_access()) + (components.__scope && rls_scope_access()) END ); @@ -148,8 +146,7 @@ CREATE POLICY canaries_auth ON canaries USING ( CASE WHEN (SELECT is_rls_disabled()) THEN TRUE ELSE - rls_has_wildcard('canary') - OR (COALESCE(canaries.__scope, '{}'::uuid[]) && rls_scope_access()) + (canaries.__scope && rls_scope_access()) END ); @@ -161,8 +158,7 @@ CREATE POLICY playbooks_auth ON playbooks USING ( CASE WHEN (SELECT is_rls_disabled()) THEN TRUE ELSE - rls_has_wildcard('playbook') - OR (COALESCE(playbooks.__scope, '{}'::uuid[]) && rls_scope_access()) + (playbooks.__scope && rls_scope_access()) END ); @@ -223,8 +219,7 @@ CREATE POLICY views_auth ON views USING ( CASE WHEN (SELECT is_rls_disabled()) THEN TRUE ELSE - rls_has_wildcard('view') - OR (COALESCE(views.__scope, '{}'::uuid[]) && rls_scope_access()) + (views.__scope && rls_scope_access()) END ); From 5dc949410dbc427a825bf05a8145f3e35fabe5bf Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Fri, 16 Jan 2026 00:08:37 +0545 Subject: [PATCH 09/10] remove wildcard handling from rls policies --- api/config.go | 36 ++++++++----- db.go | 13 +++-- migrate/migrate.go | 20 +++++-- rls/payload.go | 19 +++++-- tests/rls_test.go | 7 --- view/db.go | 4 +- views/035_rls_utils.sql | 13 ----- views/9998_rls_enable.sql | 106 +++++++++++++------------------------- 8 files changed, 101 insertions(+), 117 deletions(-) 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/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 937ae9ec0..6599068ac 100644 --- a/rls/payload.go +++ b/rls/payload.go @@ -67,15 +67,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) setPostgresSessionRLS(db *gorm.DB, local bool) error { +func (t Payload) SetPostgresSessionRLSWithRole(db *gorm.DB, role string) error { + return t.setPostgresSessionRLS(db, true, role) +} + +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) @@ -86,7 +97,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/tests/rls_test.go b/tests/rls_test.go index 45e090751..2455d36c4 100644 --- a/tests/rls_test.go +++ b/tests/rls_test.go @@ -43,12 +43,6 @@ func payloadWithScopes(scopeIDs ...*uuid.UUID) func() rls.Payload { } } -func payloadDisabled() func() rls.Payload { - return func() rls.Payload { - return rls.Payload{Disable: true} - } -} - func resetScopes(db *gorm.DB, tables ...string) { for _, table := range tables { Expect(db.Exec(fmt.Sprintf("UPDATE %s SET __scope = NULL", table)).Error).To(BeNil()) @@ -112,7 +106,6 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), Entry("aws scope", scopeCase{payload: payloadWithScopes(&awsScopeID), expectedCount: &awsConfigs}), Entry("combined scopes", scopeCase{payload: payloadWithScopes(&awsScopeID, &demoScopeID), expectedCount: &awsOrDemo}), - Entry("rls disabled", scopeCase{payload: payloadDisabled(), expectedCount: &totalConfigs}), ) }) } diff --git a/view/db.go b/view/db.go index 9569c4a6a..59ae4aa16 100644 --- a/view/db.go +++ b/view/db.go @@ -153,9 +153,7 @@ func ensureViewRLSPolicy(ctx context.Context, tableName string) error { CREATE POLICY view_grants_policy ON %s FOR ALL TO postgrest_api, postgrest_anon USING ( - CASE WHEN is_rls_disabled() THEN TRUE - ELSE check_view_grants(__grants) - END + check_view_grants(__grants) ) `, pq.QuoteIdentifier(tableName)) diff --git a/views/035_rls_utils.sql b/views/035_rls_utils.sql index c9ed9e7fc..9b9092b8f 100644 --- a/views/035_rls_utils.sql +++ b/views/035_rls_utils.sql @@ -1,16 +1,3 @@ --- isolated from 9998_rls_enable.sql because generated tables in the view use it. -CREATE -OR REPLACE FUNCTION is_rls_disabled() RETURNS BOOLEAN AS $$ -DECLARE - jwt_claims TEXT; -BEGIN - jwt_claims := current_setting('request.jwt.claims', TRUE); - RETURN (jwt_claims IS NULL - OR jwt_claims = '' - OR jwt_claims::jsonb ->> 'disable_rls' IS NOT NULL); -END; -$$ 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 $$ diff --git a/views/9998_rls_enable.sql b/views/9998_rls_enable.sql index a56d8781f..812b26888 100644 --- a/views/9998_rls_enable.sql +++ b/views/9998_rls_enable.sql @@ -57,10 +57,7 @@ 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 + (config_items.__scope && rls_scope_access()) ); -- Policy config_changes @@ -69,14 +66,12 @@ 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 ( + 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 @@ -85,14 +80,12 @@ 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 ( + 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 @@ -101,13 +94,9 @@ 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 + -- 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) ); -- Policy config_component_relationships @@ -116,14 +105,12 @@ DROP POLICY IF EXISTS config_component_relationships_auth ON config_component_re 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 ( + 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 @@ -132,10 +119,7 @@ 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 + (components.__scope && rls_scope_access()) ); -- Policy canaries @@ -144,10 +128,7 @@ 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 + (canaries.__scope && rls_scope_access()) ); -- Policy playbooks @@ -156,10 +137,7 @@ 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 + (playbooks.__scope && rls_scope_access()) ); -- Policy playbook_runs @@ -168,31 +146,27 @@ 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) + -- User must have access to the playbook + EXISTS ( + SELECT 1 + FROM playbooks + WHERE playbooks.id = playbook_runs.playbook_id ) - END + 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) ); -- Policy checks @@ -201,14 +175,12 @@ 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 ( + EXISTS ( -- just leverage the RLS on canaries SELECT 1 FROM canaries WHERE canaries.id = checks.canary_id ) - END ); -- Policy views @@ -217,10 +189,7 @@ 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 + (views.__scope && rls_scope_access()) ); -- Policy view_panels (inherits from parent views table) @@ -229,13 +198,10 @@ 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 + EXISTS ( + SELECT 1 FROM views + WHERE views.id = view_panels.view_id + ) ); ALTER VIEW analysis_by_config SET (security_invoker = true); From 56ec56d6da7eafe7c78c0be57d719dbb51051e1b Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 20 Jan 2026 12:21:18 +0545 Subject: [PATCH 10/10] bring back old RLS approach with match_scope --- AGENTS.md | 8 +- rls/payload.go | 77 +- rls/payload_test.go | 30 +- tests/rls_test.go | 2291 +++++++++++++++++++++++-- view/db.go | 4 +- views/035_rls_utils.sql | 13 + views/035_view_rls.sql | 10 +- views/9998_rls_enable.sql | 236 ++- views/9998_rls_enable_precomputed.sql | 262 +++ views/views.go | 14 +- 10 files changed, 2731 insertions(+), 214 deletions(-) create mode 100644 views/9998_rls_enable_precomputed.sql diff --git a/AGENTS.md b/AGENTS.md index 0a4ed2901..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 `__scope` array column and compare it against JWT claims. +**Direct Policies**: Tables with direct RLS default to `match_scope()` against JWT claims. - Examples: `config_items`, `canaries`, `components`, `playbooks`, `views` -- Policy checks scope overlap using `COALESCE(__scope, '{}'::uuid[]) && rls_scope_access()` and wildcard via `rls_has_wildcard('')`. +- 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 `__scope` overlap 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 diff --git a/rls/payload.go b/rls/payload.go index 6599068ac..71bad9770 100644 --- a/rls/payload.go +++ b/rls/payload.go @@ -6,17 +6,46 @@ import ( "slices" "strings" + "github.com/flanksource/commons/collections" "github.com/flanksource/commons/hash" "github.com/google/uuid" "github.com/lib/pq" "gorm.io/gorm" ) +type Scope struct { + Tags map[string]string `json:"tags,omitempty"` + Agents []string `json:"agents,omitempty"` + Names []string `json:"names,omitempty"` + ID string `json:"id,omitempty"` +} + +func (s Scope) IsEmpty() bool { + return len(s.Tags) == 0 && len(s.Agents) == 0 && len(s.Names) == 0 && strings.TrimSpace(s.ID) == "" +} + +func (s Scope) Fingerprint() string { + tagSelectors := collections.SortedMap(s.Tags) + agentsCopy := slices.Clone(s.Agents) + namesCopy := slices.Clone(s.Names) + slices.Sort(agentsCopy) + slices.Sort(namesCopy) + + data := fmt.Sprintf("agents:%s | tags:%s | names:%s | id:%s", strings.Join(agentsCopy, "--"), tagSelectors, strings.Join(namesCopy, "--"), strings.TrimSpace(s.ID)) + return fmt.Sprintf("scope::%s", hash.Sha256Hex(data)) +} + // RLS Payload that's injected postgresl parameter `request.jwt.claims` type Payload struct { // cached fingerprint fingerprint string + Config []Scope `json:"config,omitempty"` + Component []Scope `json:"component,omitempty"` + Playbook []Scope `json:"playbook,omitempty"` + Canary []Scope `json:"canary,omitempty"` + View []Scope `json:"view,omitempty"` + // Scopes contains the list of scope UUIDs the user has access to. Scopes []uuid.UUID `json:"scopes,omitempty"` @@ -31,6 +60,26 @@ func (t Payload) JWTClaims() map[string]any { return claims } + if len(t.Config) > 0 { + claims["config"] = t.Config + } + + if len(t.Component) > 0 { + claims["component"] = t.Component + } + + if len(t.Playbook) > 0 { + claims["playbook"] = t.Playbook + } + + if len(t.Canary) > 0 { + claims["canary"] = t.Canary + } + + if len(t.View) > 0 { + claims["view"] = t.View + } + if len(t.Scopes) > 0 { claims["scopes"] = t.Scopes } @@ -44,17 +93,31 @@ func (t *Payload) EvalFingerprint() { return } - if len(t.Scopes) == 0 { + parts := []string{} + for _, scopeArray := range [][]Scope{t.Config, t.Component, t.Playbook, t.Canary, t.View} { + for _, scope := range scopeArray { + if !scope.IsEmpty() { + parts = append(parts, scope.Fingerprint()) + } + } + } + + if len(t.Scopes) > 0 { + 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, ",")) + } + + if len(parts) == 0 { t.fingerprint = "empty" return } - scopesCopy := make([]string, 0, len(t.Scopes)) - for _, scope := range t.Scopes { - scopesCopy = append(scopesCopy, scope.String()) - } - slices.Sort(scopesCopy) - t.fingerprint = hash.Sha256Hex(strings.Join(scopesCopy, ",")) + slices.Sort(parts) + t.fingerprint = hash.Sha256Hex(strings.Join(parts, " | ")) } func (t *Payload) Fingerprint() string { diff --git a/rls/payload_test.go b/rls/payload_test.go index b03f25314..0eb25fdab 100644 --- a/rls/payload_test.go +++ b/rls/payload_test.go @@ -3,7 +3,6 @@ package rls import ( "testing" - "github.com/google/uuid" "github.com/onsi/gomega" ) @@ -23,9 +22,11 @@ func TestPayload_EvalFingerprint(t *testing.T) { g := gomega.NewWithT(t) payload := &Payload{ - Scopes: []uuid.UUID{ - uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2"), - uuid.MustParse("0a1ce1b2-5d90-4e74-8d30-2f4f0d30f8e4"), + Config: []Scope{ + { + Tags: map[string]string{"z": "value1", "a": "value2"}, + Agents: []string{"agent2", "agent1"}, + }, }, } payload.EvalFingerprint() @@ -49,17 +50,17 @@ func TestPayload_EvalFingerprint(t *testing.T) { g := gomega.NewWithT(t) payload1 := &Payload{ - Scopes: []uuid.UUID{ - uuid.MustParse("0a1ce1b2-5d90-4e74-8d30-2f4f0d30f8e4"), - uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2"), + Config: []Scope{ + {Tags: map[string]string{"a": "value1"}}, + {Tags: map[string]string{"b": "value2"}}, }, } payload1.EvalFingerprint() payload2 := &Payload{ - Scopes: []uuid.UUID{ - uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2"), - uuid.MustParse("0a1ce1b2-5d90-4e74-8d30-2f4f0d30f8e4"), + Config: []Scope{ + {Tags: map[string]string{"b": "value2"}}, + {Tags: map[string]string{"a": "value1"}}, }, } payload2.EvalFingerprint() @@ -72,13 +73,18 @@ func TestPayload_EvalFingerprint(t *testing.T) { g := gomega.NewWithT(t) payload := &Payload{ - Scopes: []uuid.UUID{uuid.MustParse("b6e3e8b2-8cda-4b70-bde7-3fb48c36d3f2")}, + Config: []Scope{ + { + Tags: map[string]string{"x": "value4"}, + Agents: []string{"agentX"}, + }, + }, } payload.EvalFingerprint() firstFingerprint := payload.Fingerprint() // Modify the underlying data to see if the cached fingerprint remains unchanged - payload.Scopes[0] = uuid.MustParse("f4a1fcb2-4cf7-48f2-9e68-6457e8c4e9e6") + payload.Config[0].Tags["x"] = "modified_value" g.Expect(payload.Fingerprint()).To(gomega.Equal(firstFingerprint)) }) } diff --git a/tests/rls_test.go b/tests/rls_test.go index 2455d36c4..ee99bbc1f 100644 --- a/tests/rls_test.go +++ b/tests/rls_test.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "os" + "strings" "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" @@ -11,78 +12,132 @@ import ( "github.com/samber/lo" "gorm.io/gorm" + "github.com/flanksource/duty/api" + "github.com/flanksource/duty/job" + "github.com/flanksource/duty/migrate" "github.com/flanksource/duty/models" "github.com/flanksource/duty/rls" "github.com/flanksource/duty/tests/fixtures/dummy" + "github.com/flanksource/duty/types" ) -type scopeCase struct { - payload func() rls.Payload +type testCase struct { + rlsPayload rls.Payload expectedCount *int64 } -func setRLS(tx *gorm.DB, payload rls.Payload) { - Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) -} - -func payloadNoScopes() func() rls.Payload { - return func() rls.Payload { - return rls.Payload{} - } -} - -func payloadWithScopes(scopeIDs ...*uuid.UUID) func() rls.Payload { - return func() rls.Payload { - ids := make([]uuid.UUID, 0, len(scopeIDs)) - for _, id := range scopeIDs { - if id != nil { - ids = append(ids, *id) - } - } - return rls.Payload{Scopes: ids} - } -} +func verifyConfigCount(tx *gorm.DB, rlsPayload rls.Payload, expectedCount int64) { + Expect(rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) -func resetScopes(db *gorm.DB, tables ...string) { - for _, table := range tables { - Expect(db.Exec(fmt.Sprintf("UPDATE %s SET __scope = NULL", table)).Error).To(BeNil()) - } + var count int64 + Expect(tx.Model(&models.ConfigItem{}).Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(expectedCount)) } -func assignScope(db *gorm.DB, table string, scopeID uuid.UUID, where string, args ...any) { - query := fmt.Sprintf("UPDATE %s SET __scope = array_append(COALESCE(__scope, '{}'::uuid[]), ?) WHERE %s", table, where) - Expect(db.Exec(query, append([]any{scopeID}, args...)...).Error).To(BeNil()) -} - -var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { +var _ = Describe("RLS test", Ordered, ContinueOnFailure, func() { BeforeAll(func() { if os.Getenv("DUTY_DB_DISABLE_RLS") == "true" { Skip("RLS tests are disabled because DUTY_DB_DISABLE_RLS is set to true") } }) - var _ = Describe("config_items", func() { + var _ = Describe("views query", func() { var ( tx *gorm.DB totalConfigs int64 awsConfigs int64 - demoConfigs int64 - awsOrDemo int64 - awsScopeID = uuid.New() - demoScopeID = uuid.New() + ) + + BeforeAll(func() { + Expect(DefaultContext.DB().Model(&models.ConfigItem{}).Count(&totalConfigs).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws'").Model(&models.ConfigItem{}).Count(&awsConfigs).Error).To(BeNil()) + + Expect(totalConfigs).To(Not(Equal(awsConfigs))) + + sqldb, err := DefaultContext.DB().DB() + Expect(err).To(BeNil()) + + // The migration_dependency_test can mess with the migration_logs so we clean and run migrations again + Expect(DefaultContext.DB().Exec("DELETE FROM migration_logs").Error).To(BeNil()) + + connString := DefaultContext.Value("db_url").(string) + err = migrate.RunMigrations(sqldb, api.Config{ConnectionString: connString, EnableRLS: true}) + Expect(err).To(BeNil()) + + tx = DefaultContext.DB().Begin() + + Expect(tx.Exec("SET LOCAL ROLE 'postgrest_api'").Error).To(BeNil()) + + payload := rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster": "aws"}}, + }, + } + Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) + + err = job.RefreshConfigItemSummary7d(DefaultContext) + Expect(err).To(BeNil()) + }) + + AfterAll(func() { + payload := rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster": "aws"}}, + }, + } + Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) + Expect(tx.Commit().Error).To(BeNil()) + }) + + It("should call configs", func() { + var count int64 + err := tx.Raw("SELECT COUNT(*) FROM configs").Scan(&count).Error + Expect(err).To(BeNil()) + + Expect(count).To(Equal(awsConfigs)) + }) + + It("should call config_detail", func() { + var count int64 + err := tx.Raw("SELECT COUNT(*) FROM config_detail").Scan(&count).Error + Expect(err).To(BeNil()) + + Expect(count).To(Equal(awsConfigs)) + }) + + It("should call config_item_summary_7d", func() { + var count int64 + err := tx.Raw("SELECT COUNT(*) FROM config_item_summary_7d").Scan(&count).Error + Expect(err).To(BeNil()) + + Expect(count).To(Equal(totalConfigs)) + }) + }) + + var _ = Describe("config_items query", func() { + var ( + tx *gorm.DB + totalConfigs int64 + numConfigsWithAgent int64 + numConfigsWithFlanksourceTag int64 + awsConfigs int64 + awsAndDemoCluster int64 + awsTagAndNilAgent int64 + awsTagAndEKSName int64 + awsAndFlanksourceTags int64 ) BeforeAll(func() { tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) Expect(DefaultContext.DB().Model(&models.ConfigItem{}).Count(&totalConfigs).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("tags->>'account' = 'flanksource'").Model(&models.ConfigItem{}).Count(&numConfigsWithFlanksourceTag).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("agent_id = ?", uuid.Nil).Model(&models.ConfigItem{}).Count(&numConfigsWithAgent).Error).To(BeNil()) Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws'").Model(&models.ConfigItem{}).Count(&awsConfigs).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("tags->>'cluster' = 'demo'").Model(&models.ConfigItem{}).Count(&demoConfigs).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("tags->>'cluster' IN ('aws', 'demo')").Model(&models.ConfigItem{}).Count(&awsOrDemo).Error).To(BeNil()) - - resetScopes(DefaultContext.DB(), "config_items") - assignScope(DefaultContext.DB(), "config_items", awsScopeID, "tags->>'cluster' = ?", "aws") - assignScope(DefaultContext.DB(), "config_items", demoScopeID, "tags->>'cluster' = ?", "demo") + Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws' OR tags->>'cluster' = 'demo'").Model(&models.ConfigItem{}).Count(&awsAndDemoCluster).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws' AND agent_id = ?", uuid.Nil).Model(&models.ConfigItem{}).Count(&awsTagAndNilAgent).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws' AND name = ?", *dummy.EKSCluster.Name).Model(&models.ConfigItem{}).Count(&awsTagAndEKSName).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("tags->>'cluster' = 'aws' AND tags->>'account' = 'flanksource'").Model(&models.ConfigItem{}).Count(&awsAndFlanksourceTags).Error).To(BeNil()) }) AfterAll(func() { @@ -93,48 +148,451 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { Context(role, Ordered, func() { BeforeAll(func() { Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + + var currentRole string + Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) + Expect(currentRole).To(Equal(role)) }) - DescribeTable("RLS scope tests", - func(tc scopeCase) { - setRLS(tx, tc.payload()) + It("should allow access to all records when RLS is disabled", func() { + payload := rls.Payload{ + Disable: true, + } + verifyConfigCount(tx, payload, totalConfigs) + }) - var count int64 - Expect(tx.Model(&models.ConfigItem{}).Count(&count).Error).To(BeNil()) - Expect(count).To(Equal(*tc.expectedCount)) + DescribeTable("JWT claim tests", + func(tc testCase) { + verifyConfigCount(tx, tc.rlsPayload, *tc.expectedCount) }, - Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), - Entry("aws scope", scopeCase{payload: payloadWithScopes(&awsScopeID), expectedCount: &awsConfigs}), - Entry("combined scopes", scopeCase{payload: payloadWithScopes(&awsScopeID, &demoScopeID), expectedCount: &awsOrDemo}), + Entry("no permissions", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Tags: map[string]string{"cluster": "testing-cluster"}, + Agents: []string{"10000000-0000-0000-0000-000000000000"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("correct agent", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Agents: []string{"00000000-0000-0000-0000-000000000000"}, + }, + }, + }, + expectedCount: &numConfigsWithAgent, + }), + Entry("correct tag", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Tags: map[string]string{"account": "flanksource"}, + }, + }, + }, + expectedCount: &numConfigsWithFlanksourceTag, + }), + Entry("multiple tags (OR logic between scopes)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster": "aws"}}, + {Tags: map[string]string{"cluster": "demo"}}, + }, + }, + expectedCount: &awsAndDemoCluster, + }), + Entry("specific name", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Names: []string{*dummy.EKSCluster.Name}}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("wildcard name (match all)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Names: []string{"*"}}, + }, + }, + expectedCount: &totalConfigs, + }), + Entry("wildcard agent (match all)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Agents: []string{"*"}}, + }, + }, + expectedCount: &totalConfigs, + }), + Entry("tags AND agents (within scope)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Tags: map[string]string{"cluster": "aws"}, + Agents: []string{uuid.Nil.String()}, + }, + }, + }, + expectedCount: &awsTagAndNilAgent, + }), + Entry("tags AND names (within scope)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Tags: map[string]string{"cluster": "aws"}, + Names: []string{*dummy.EKSCluster.Name}, + }, + }, + }, + expectedCount: &awsTagAndEKSName, + }), + Entry("empty payload (no scopes)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{}, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple names (OR within names array)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Names: []string{*dummy.EKSCluster.Name, "non-existent-config"}}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("mixed scope criteria (OR logic between scopes)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster": "aws"}}, + {Agents: []string{uuid.Nil.String()}}, + {Names: []string{*dummy.EKSCluster.Name}}, + }, + }, + expectedCount: &numConfigsWithAgent, // Should be union of all three scopes + }), + Entry("invalid agent UUID (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Agents: []string{"not-a-valid-uuid"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("empty string in agents array (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Agents: []string{""}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("empty string in names array (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Names: []string{""}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("empty tag value (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster": ""}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("case sensitivity - uppercase name (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Names: []string{strings.ToUpper(*dummy.EKSCluster.Name)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("case sensitivity - uppercase tag value (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster": "AWS"}}, // uppercase + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("duplicate scopes (should work same as single)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster": "aws"}}, + {Tags: map[string]string{"cluster": "aws"}}, // duplicate + }, + }, + expectedCount: &awsConfigs, // Should be same as single scope + }), + Entry("conflicting criteria within scope (agent matches but name doesn't)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Agents: []string{uuid.Nil.String()}, // matches many + Names: []string{"non-existent-config-name"}, // matches none + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), // AND logic means both must match + }), + Entry("special characters in name (unicode)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Names: []string{"config-名前-🚀"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple agents in single scope (OR within agents array)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Agents: []string{ + uuid.Nil.String(), + "10000000-0000-0000-0000-000000000000", + }, + }, + }, + }, + expectedCount: &numConfigsWithAgent, + }), + Entry("multiple tags in single scope (AND logic)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Tags: map[string]string{ + "cluster": "aws", + "account": "flanksource", + }, + }, + }, + }, + expectedCount: &awsAndFlanksourceTags, + }), + Entry("mixed valid and invalid agents", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Agents: []string{ + "not-a-uuid", + uuid.Nil.String(), + "also-invalid", + }, + }, + }, + }, + expectedCount: &numConfigsWithAgent, + }), + Entry("very long agent list (stress test)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Agents: append( + []string{uuid.Nil.String()}, + func() []string { + agents := make([]string, 99) + for i := range agents { + agents[i] = uuid.New().String() + } + return agents + }()..., + ), + }, + }, + }, + expectedCount: &numConfigsWithAgent, + }), + Entry("very long names list (stress test)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Names: append( + []string{*dummy.EKSCluster.Name}, + func() []string { + names := make([]string, 99) + for i := range names { + names[i] = fmt.Sprintf("non-existent-config-%d", i) + } + return names + }()..., + ), + }, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("very many scopes (stress test)", testCase{ + rlsPayload: rls.Payload{ + Config: append( + []rls.Scope{{Tags: map[string]string{"cluster": "aws"}}}, + func() []rls.Scope { + scopes := make([]rls.Scope, 49) + for i := range scopes { + scopes[i] = rls.Scope{ + Tags: map[string]string{"cluster": fmt.Sprintf("non-existent-%d", i)}, + } + } + return scopes + }()..., + ), + }, + expectedCount: &awsConfigs, + }), + Entry("tag with special characters in key", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster-name-with-dashes": "value"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("tag key exists but value doesn't match", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster": "non-existent-value"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple tags where only one matches", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Tags: map[string]string{ + "cluster": "aws", + "nonexistent": "should-fail", + }, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("empty tag map in scope", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Tags: map[string]string{}, + Agents: []string{uuid.Nil.String()}, + }, + }, + }, + expectedCount: &numConfigsWithAgent, + }), + Entry("whitespace-only values", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Names: []string{" "}, + Tags: map[string]string{"cluster": " "}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("extremely long name string", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Names: []string{strings.Repeat("a", 1000)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("extremely long tag value", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster": strings.Repeat("x", 1000)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("name with wildcard in middle", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Names: []string{"Production*EKS"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("name with wildcard prefix", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Names: []string{"*EKS"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple scopes with overlapping results", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster": "aws"}}, + {Names: []string{*dummy.EKSCluster.Name}}, + }, + }, + expectedCount: &awsConfigs, + }), + Entry("agent UUID with uppercase", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Agents: []string{strings.ToUpper(uuid.Nil.String())}}, + }, + }, + expectedCount: &numConfigsWithAgent, + }), + Entry("newline in tag value", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster": "aws\nmalicious"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("empty scope object", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + {}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("valid tag + valid agent + invalid name (AND within scope)", testCase{ + rlsPayload: rls.Payload{ + Config: []rls.Scope{ + { + Tags: map[string]string{"cluster": "aws"}, + Agents: []string{uuid.Nil.String()}, + Names: []string{"non-existent"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), ) }) } }) - var _ = Describe("components", func() { + var _ = Describe("components query", func() { var ( - tx *gorm.DB - totalComponents int64 - agentComponents int64 - logisticsComponents int64 - agentOrLogistics int64 - agentScopeID uuid.UUID - logisticsScopeID uuid.UUID + tx *gorm.DB + totalComponents int64 + numComponentsWithAgent int64 + agentAndLogisticsName int64 ) BeforeAll(func() { tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) Expect(DefaultContext.DB().Model(&models.Component{}).Count(&totalComponents).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("agent_id = ?", uuid.Nil).Model(&models.Component{}).Count(&agentComponents).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("name = ?", dummy.Logistics.Name).Model(&models.Component{}).Count(&logisticsComponents).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("agent_id = ? OR name = ?", uuid.Nil, dummy.Logistics.Name).Model(&models.Component{}).Count(&agentOrLogistics).Error).To(BeNil()) - - resetScopes(DefaultContext.DB(), "components") - agentScopeID = uuid.New() - logisticsScopeID = uuid.New() - assignScope(DefaultContext.DB(), "components", agentScopeID, "agent_id = ?", uuid.Nil) - assignScope(DefaultContext.DB(), "components", logisticsScopeID, "name = ?", dummy.Logistics.Name) + Expect(DefaultContext.DB().Where("agent_id = ?", uuid.Nil).Model(&models.Component{}).Count(&numComponentsWithAgent).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("agent_id = ? AND name = ?", uuid.Nil, dummy.Logistics.Name).Model(&models.Component{}).Count(&agentAndLogisticsName).Error).To(BeNil()) }) AfterAll(func() { @@ -145,45 +603,272 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { Context(role, Ordered, func() { BeforeAll(func() { Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + + var currentRole string + Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) + Expect(currentRole).To(Equal(role)) }) - DescribeTable("RLS scope tests", - func(tc scopeCase) { - setRLS(tx, tc.payload()) + DescribeTable("JWT claim tests", + func(tc testCase) { + Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) var count int64 Expect(tx.Model(&models.Component{}).Count(&count).Error).To(BeNil()) Expect(count).To(Equal(*tc.expectedCount)) }, - Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), - Entry("agent scope", scopeCase{payload: payloadWithScopes(&agentScopeID), expectedCount: &agentComponents}), - Entry("name scope", scopeCase{payload: payloadWithScopes(&logisticsScopeID), expectedCount: &logisticsComponents}), - Entry("combined scopes", scopeCase{payload: payloadWithScopes(&agentScopeID, &logisticsScopeID), expectedCount: &agentOrLogistics}), + Entry("no permissions", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + { + Agents: []string{"10000000-0000-0000-0000-000000000000"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("correct agent", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + { + Agents: []string{uuid.Nil.String()}, + }, + }, + }, + expectedCount: &numComponentsWithAgent, + }), + Entry("specific name", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Names: []string{dummy.Logistics.Name}}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("wildcard name (match all)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Names: []string{"*"}}, + }, + }, + expectedCount: &totalComponents, + }), + Entry("agents AND names (within scope)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + { + Agents: []string{uuid.Nil.String()}, + Names: []string{dummy.Logistics.Name}, + }, + }, + }, + expectedCount: &agentAndLogisticsName, + }), + Entry("empty payload (no scopes)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{}, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple names (OR within names array)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Names: []string{dummy.Logistics.Name, "non-existent-component"}}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("mixed scope criteria (OR logic between scopes)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Agents: []string{uuid.Nil.String()}}, + {Names: []string{dummy.Logistics.Name}}, + }, + }, + expectedCount: &numComponentsWithAgent, // Should be union of both scopes + }), + Entry("invalid agent UUID (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Agents: []string{"invalid-uuid"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("empty string in agents array (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Agents: []string{""}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("empty string in names array (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Names: []string{""}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("case sensitivity - uppercase name (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Names: []string{strings.ToUpper(dummy.Logistics.Name)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("conflicting criteria within scope (agent matches but name doesn't)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + { + Agents: []string{uuid.Nil.String()}, + Names: []string{"non-existent-component"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), // AND logic means both must match + }), + Entry("multiple agents in single scope", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + { + Agents: []string{ + uuid.Nil.String(), + "10000000-0000-0000-0000-000000000000", + }, + }, + }, + }, + expectedCount: &numComponentsWithAgent, + }), + Entry("mixed valid and invalid agents", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + { + Agents: []string{ + "not-a-uuid", + uuid.Nil.String(), + }, + }, + }, + }, + expectedCount: &numComponentsWithAgent, + }), + Entry("very long agent list (stress test)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + { + Agents: append( + []string{uuid.Nil.String()}, + func() []string { + agents := make([]string, 99) + for i := range agents { + agents[i] = uuid.New().String() + } + return agents + }()..., + ), + }, + }, + }, + expectedCount: &numComponentsWithAgent, + }), + Entry("very long names list (stress test)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + { + Names: append( + []string{dummy.Logistics.Name}, + func() []string { + names := make([]string, 99) + for i := range names { + names[i] = fmt.Sprintf("non-existent-component-%d", i) + } + return names + }()..., + ), + }, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("whitespace-only name", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Names: []string{" "}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("extremely long name string", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Names: []string{strings.Repeat("a", 1000)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("name with wildcard in middle", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Names: []string{"Log*tics"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple scopes with overlapping results", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Agents: []string{uuid.Nil.String()}}, + {Names: []string{dummy.Logistics.Name}}, + }, + }, + expectedCount: &numComponentsWithAgent, + }), + Entry("agent UUID with uppercase", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {Agents: []string{strings.ToUpper(uuid.Nil.String())}}, + }, + }, + expectedCount: &numComponentsWithAgent, + }), + Entry("empty scope object", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + {}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("valid agent + invalid name (AND within scope)", testCase{ + rlsPayload: rls.Payload{ + Component: []rls.Scope{ + { + Agents: []string{uuid.Nil.String()}, + Names: []string{"non-existent"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), ) }) } }) - var _ = Describe("playbooks", func() { + var _ = Describe("playbooks query", func() { var ( - tx *gorm.DB - totalPlaybooks int64 - combinedPlaybook int64 - echoScopeID uuid.UUID - restartScopeID uuid.UUID + tx *gorm.DB + totalPlaybooks int64 ) BeforeAll(func() { tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) Expect(DefaultContext.DB().Model(&models.Playbook{}).Count(&totalPlaybooks).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("id IN ?", []uuid.UUID{dummy.EchoConfig.ID, dummy.RestartPod.ID}).Model(&models.Playbook{}).Count(&combinedPlaybook).Error).To(BeNil()) - - resetScopes(DefaultContext.DB(), "playbooks") - echoScopeID = uuid.New() - restartScopeID = uuid.New() - assignScope(DefaultContext.DB(), "playbooks", echoScopeID, "id = ?", dummy.EchoConfig.ID) - assignScope(DefaultContext.DB(), "playbooks", restartScopeID, "id = ?", dummy.RestartPod.ID) }) AfterAll(func() { @@ -194,43 +879,251 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { Context(role, Ordered, func() { BeforeAll(func() { Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + + var currentRole string + Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) + Expect(currentRole).To(Equal(role)) }) - DescribeTable("RLS scope tests", - func(tc scopeCase) { - setRLS(tx, tc.payload()) + DescribeTable("JWT claim tests", + func(tc testCase) { + Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) var count int64 Expect(tx.Model(&models.Playbook{}).Count(&count).Error).To(BeNil()) Expect(count).To(Equal(*tc.expectedCount)) }, - Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), - Entry("echo scope", scopeCase{payload: payloadWithScopes(&echoScopeID), expectedCount: lo.ToPtr(int64(1))}), - Entry("combined scopes", scopeCase{payload: payloadWithScopes(&echoScopeID, &restartScopeID), expectedCount: &combinedPlaybook}), + Entry("no permissions", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + { + Names: []string{"non-existent-playbook"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("specific name", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{dummy.EchoConfig.Name}}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("wildcard name (match all)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{"*"}}, + }, + }, + expectedCount: &totalPlaybooks, + }), + Entry("empty payload (no scopes)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{}, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple names (OR within names array)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{dummy.EchoConfig.Name, "non-existent-playbook"}}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("empty string in names array (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{""}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("case sensitivity - uppercase name (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{strings.ToUpper(dummy.EchoConfig.Name)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("duplicate scopes (should work same as single)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{dummy.EchoConfig.Name}}, + {Names: []string{dummy.EchoConfig.Name}}, // duplicate + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("very long names list (stress test)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + { + Names: append( + []string{dummy.EchoConfig.Name}, + func() []string { + names := make([]string, 99) + for i := range names { + names[i] = fmt.Sprintf("non-existent-playbook-%d", i) + } + return names + }()..., + ), + }, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("whitespace-only name", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{" "}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("extremely long name string", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{strings.Repeat("a", 1000)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("name with wildcard in middle", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{"Echo*Config"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("name with wildcard prefix", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{"*Config"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple scopes with overlapping results", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{dummy.EchoConfig.Name}}, + {Names: []string{dummy.EchoConfig.Name}}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("empty scope object", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("agents defined in scope (should be ignored for playbooks)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + { + Agents: []string{"10000000-0000-0000-0000-000000000000"}, + Names: []string{dummy.EchoConfig.Name}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(1)), // Should match because agents should be ignored + }), + Entry("tags only in scope (should deny access - no applicable fields)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + { + Tags: map[string]string{"cluster": "homelab", "namespace": "default"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), // Should deny because playbooks don't support tags + }), + Entry("tags and agents only in scope (should deny access - no applicable fields)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + { + Tags: map[string]string{"cluster": "aws"}, + Agents: []string{"10000000-0000-0000-0000-000000000000"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), // Should deny because playbooks support neither tags nor agents + }), + Entry("specific ID (should grant access)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {ID: dummy.EchoConfig.ID.String()}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("wrong ID (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {ID: "00000000-0000-0000-0000-000000000000"}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("ID + matching name (AND logic - should grant access)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + { + ID: dummy.EchoConfig.ID.String(), + Names: []string{dummy.EchoConfig.Name}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("ID + non-matching name (AND logic - should deny access)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + { + ID: dummy.EchoConfig.ID.String(), + Names: []string{"wrong-name"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple scopes with different IDs (OR logic)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {ID: dummy.EchoConfig.ID.String()}, + {ID: dummy.RestartPod.ID.String()}, + }, + }, + expectedCount: lo.ToPtr(int64(2)), + }), ) }) } }) - var _ = Describe("canaries and checks", func() { + var _ = Describe("canaries query", func() { var ( - tx *gorm.DB - totalCanaries int64 - logisticsScopeID uuid.UUID - totalChecks int64 - logisticsChecks int64 + tx *gorm.DB + totalCanaries int64 + numCanariesWithAgent int64 + agentAndCanaryName int64 ) BeforeAll(func() { tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) Expect(DefaultContext.DB().Model(&models.Canary{}).Count(&totalCanaries).Error).To(BeNil()) - Expect(DefaultContext.DB().Model(&models.Check{}).Count(&totalChecks).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("canary_id = ?", dummy.LogisticsAPICanary.ID).Model(&models.Check{}).Count(&logisticsChecks).Error).To(BeNil()) - - resetScopes(DefaultContext.DB(), "canaries") - logisticsScopeID = uuid.New() - assignScope(DefaultContext.DB(), "canaries", logisticsScopeID, "id = ?", dummy.LogisticsAPICanary.ID) + Expect(DefaultContext.DB().Where("agent_id = ?", uuid.Nil).Model(&models.Canary{}).Count(&numCanariesWithAgent).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("agent_id = ? AND name = ?", uuid.Nil, dummy.LogisticsAPICanary.Name).Model(&models.Canary{}).Count(&agentAndCanaryName).Error).To(BeNil()) }) AfterAll(func() { @@ -241,63 +1134,754 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { Context(role, Ordered, func() { BeforeAll(func() { Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + + var currentRole string + Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) + Expect(currentRole).To(Equal(role)) }) - DescribeTable("canary scopes", - func(tc scopeCase) { - setRLS(tx, tc.payload()) + DescribeTable("JWT claim tests", + func(tc testCase) { + Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) var count int64 Expect(tx.Model(&models.Canary{}).Count(&count).Error).To(BeNil()) Expect(count).To(Equal(*tc.expectedCount)) }, - Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), - Entry("logistics scope", scopeCase{payload: payloadWithScopes(&logisticsScopeID), expectedCount: lo.ToPtr(int64(1))}), + Entry("no permissions", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Agents: []string{"10000000-0000-0000-0000-000000000000"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("correct agent", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Agents: []string{uuid.Nil.String()}, + }, + }, + }, + expectedCount: &numCanariesWithAgent, + }), + Entry("specific name", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{dummy.LogisticsAPICanary.Name}}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("wildcard name (match all)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{"*"}}, + }, + }, + expectedCount: &totalCanaries, + }), + Entry("agents AND names (within scope)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Agents: []string{uuid.Nil.String()}, + Names: []string{dummy.LogisticsAPICanary.Name}, + }, + }, + }, + expectedCount: &agentAndCanaryName, + }), + Entry("empty payload (no scopes)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{}, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple names (OR within names array)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{dummy.LogisticsAPICanary.Name, "non-existent-canary"}}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("mixed scope criteria (OR logic between scopes)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Agents: []string{uuid.Nil.String()}}, + {Names: []string{dummy.LogisticsAPICanary.Name}}, + }, + }, + expectedCount: &numCanariesWithAgent, // Should be union of both scopes + }), + Entry("invalid agent UUID (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Agents: []string{"not-valid-uuid"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("empty string in agents array (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Agents: []string{""}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("empty string in names array (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{""}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("case sensitivity - uppercase name (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{strings.ToUpper(dummy.LogisticsAPICanary.Name)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("conflicting criteria within scope (agent matches but name doesn't)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Agents: []string{uuid.Nil.String()}, + Names: []string{"non-existent-canary"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), // AND logic means both must match + }), + Entry("multiple agents in single scope", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Agents: []string{ + uuid.Nil.String(), + "10000000-0000-0000-0000-000000000000", + }, + }, + }, + }, + expectedCount: &numCanariesWithAgent, + }), + Entry("mixed valid and invalid agents", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Agents: []string{ + "not-a-uuid", + uuid.Nil.String(), + }, + }, + }, + }, + expectedCount: &numCanariesWithAgent, + }), + Entry("very long agent list (stress test)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Agents: append( + []string{uuid.Nil.String()}, + func() []string { + agents := make([]string, 99) + for i := range agents { + agents[i] = uuid.New().String() + } + return agents + }()..., + ), + }, + }, + }, + expectedCount: &numCanariesWithAgent, + }), + Entry("very long names list (stress test)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Names: append( + []string{dummy.LogisticsAPICanary.Name}, + func() []string { + names := make([]string, 99) + for i := range names { + names[i] = fmt.Sprintf("non-existent-canary-%d", i) + } + return names + }()..., + ), + }, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("whitespace-only name", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{" "}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("extremely long name string", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{strings.Repeat("a", 1000)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("name with wildcard in middle", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{"Logistics*Canary"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("name with wildcard prefix", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{"*Canary"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple scopes with overlapping results", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Agents: []string{uuid.Nil.String()}}, + {Names: []string{dummy.LogisticsAPICanary.Name}}, + }, + }, + expectedCount: &numCanariesWithAgent, + }), + Entry("agent UUID with uppercase", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Agents: []string{strings.ToUpper(uuid.Nil.String())}}, + }, + }, + expectedCount: &numCanariesWithAgent, + }), + Entry("empty scope object", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("valid agent + invalid name (AND within scope)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Agents: []string{uuid.Nil.String()}, + Names: []string{"non-existent"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + ) + }) + } + }) + + var _ = Describe("playbook_runs query", func() { + var ( + tx *gorm.DB + totalPlaybookRuns int64 + echoConfigRunsCount int64 + restartPodRunsCount int64 + ) + + BeforeAll(func() { + tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) + + Expect(DefaultContext.DB().Model(&models.PlaybookRun{}).Count(&totalPlaybookRuns).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("playbook_id = ?", dummy.EchoConfig.ID).Model(&models.PlaybookRun{}).Count(&echoConfigRunsCount).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("playbook_id = ?", dummy.RestartPod.ID).Model(&models.PlaybookRun{}).Count(&restartPodRunsCount).Error).To(BeNil()) + + Expect(totalPlaybookRuns).To(BeNumerically(">", 0), "No playbook runs found in test data") + Expect(echoConfigRunsCount).To(BeNumerically(">", 0), "No playbook runs found for EchoConfig playbook") + Expect(restartPodRunsCount).To(BeNumerically(">", 0), "No playbook runs found for RestartPod playbook") + Expect(totalPlaybookRuns).To(Equal(echoConfigRunsCount + restartPodRunsCount)) + }) + + AfterAll(func() { + Expect(tx.Commit().Error).To(BeNil()) + }) + + for _, role := range []string{"postgrest_anon", "postgrest_api"} { + Context(role, Ordered, func() { + BeforeAll(func() { + Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + + var currentRole string + Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) + Expect(currentRole).To(Equal(role)) + }) + + DescribeTable("JWT claim tests", + func(tc testCase) { + Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) + + var count int64 + Expect(tx.Model(&models.PlaybookRun{}).Count(&count).Error).To(BeNil()) + Expect(count).To(Equal(*tc.expectedCount)) + }, + Entry("no permissions (empty scopes array)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{}, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("no permissions (non-existent playbook)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + { + Names: []string{"non-existent-playbook"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("access only echo-config playbook runs", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{dummy.EchoConfig.Name}}, + }, + Config: []rls.Scope{ + {Names: []string{"*"}}, + }, + }, + expectedCount: &echoConfigRunsCount, + }), + Entry("access echo-config playbook runs but no access to the config", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{dummy.EchoConfig.Name}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("can access echo-config playbook but only 1 config", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{dummy.EchoConfig.Name}}, + }, + Config: []rls.Scope{ + {ID: dummy.KubernetesNodeA.ID.String()}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("access echo-config playbook runs but no access to the config", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{dummy.EchoConfig.Name}}, + }, + Config: []rls.Scope{ + {ID: dummy.EC2InstanceA.ID.String()}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("access only restart-pod playbook runs", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{dummy.RestartPod.Name}}, + }, + Config: []rls.Scope{ + {Names: []string{"*"}}, + }, + }, + expectedCount: &restartPodRunsCount, + }), + Entry("access both playbooks (OR logic)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{dummy.EchoConfig.Name, dummy.RestartPod.Name}}, + }, + Config: []rls.Scope{ + {Names: []string{"*"}}, + }, + }, + expectedCount: &totalPlaybookRuns, + }), + Entry("wildcard playbook name (match all runs)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{"*"}}, + }, + Config: []rls.Scope{ + {Names: []string{"*"}}, + }, + }, + expectedCount: &totalPlaybookRuns, + }), + Entry("empty string in names array (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{""}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("case sensitivity - uppercase playbook name (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{strings.ToUpper(dummy.EchoConfig.Name)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("empty scope object", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("whitespace-only name", testCase{ + rlsPayload: rls.Payload{ + Playbook: []rls.Scope{ + {Names: []string{" "}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), ) + }) + } + }) + + var _ = Describe("INSERT QUERY", func() { + var tx *gorm.DB + + // Verify that the implicit WITH CHECK clause works correctly for INSERT operations. + // PostgreSQL RLS policies without an explicit WITH CHECK clause will use the USING clause + // for both SELECT (read) and INSERT/UPDATE (write) operations. + BeforeAll(func() { + tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin() + Expect(tx.Exec("SET LOCAL ROLE 'postgrest_api'").Error).To(BeNil()) + }) + + AfterAll(func() { + Expect(tx.Rollback().Error).To(BeNil()) + }) + + It("should allow INSERT when user has access to the config tags", func() { + payload := rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"test-cluster": "test-value"}}, + }, + } + Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) + + newConfig := models.ConfigItem{ + ID: uuid.New(), + ConfigClass: "TestClass", + Type: lo.ToPtr("Test::Type"), + Name: lo.ToPtr("test-config-insert-allowed"), + Tags: types.JSONStringMap{ + "test-cluster": "test-value", + }, + } + + err := tx.Create(&newConfig).Error + Expect(err).To(BeNil(), "Should allow INSERT when user has access to the tags") + }) + + It("should deny INSERT when user doesn't have access to the config tags", func() { + payload := rls.Payload{ + Config: []rls.Scope{ + {Tags: map[string]string{"cluster": "aws"}}, + }, + } + Expect(payload.SetPostgresSessionRLS(tx)).To(BeNil()) + + newConfig := models.ConfigItem{ + ID: uuid.New(), + ConfigClass: "TestClass", + Type: lo.ToPtr("Test::Type"), + Name: lo.ToPtr("test-config-insert-denied"), + Tags: types.JSONStringMap{ + "cluster": "unauthorized-cluster", + }, + } + + err := tx.Create(&newConfig).Error + Expect(err).ToNot(BeNil(), "Should deny INSERT when user doesn't have access to the tags") + Expect(err.Error()).To(ContainSubstring("new row violates row-level security policy")) + }) + }) + + var _ = Describe("checks query", func() { + var ( + tx *gorm.DB + totalChecks int64 + logisticsAPICanaryChecksCount int64 + logisticsDBCanaryChecksCount int64 + cartAPICanaryAgentChecksCount int64 + logisticsAPIAndDBCanaryChecks int64 + ) + + BeforeAll(func() { + tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) + + Expect(DefaultContext.DB().Model(&models.Check{}).Count(&totalChecks).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("canary_id = ?", dummy.LogisticsAPICanary.ID).Model(&models.Check{}).Count(&logisticsAPICanaryChecksCount).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("canary_id = ?", dummy.LogisticsDBCanary.ID).Model(&models.Check{}).Count(&logisticsDBCanaryChecksCount).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("canary_id = ?", dummy.CartAPICanaryAgent.ID).Model(&models.Check{}).Count(&cartAPICanaryAgentChecksCount).Error).To(BeNil()) + logisticsAPIAndDBCanaryChecks = logisticsAPICanaryChecksCount + logisticsDBCanaryChecksCount + + Expect(totalChecks).To(BeNumerically(">", 0), "No checks found in test data") + Expect(logisticsAPICanaryChecksCount).To(BeNumerically(">", 0), "No checks found for LogisticsAPICanary") + Expect(logisticsDBCanaryChecksCount).To(BeNumerically(">", 0), "No checks found for LogisticsDBCanary") + Expect(cartAPICanaryAgentChecksCount).To(BeNumerically(">", 0), "No checks found for CartAPICanaryAgent") + }) - DescribeTable("checks inherit canary RLS", - func(tc scopeCase) { - setRLS(tx, tc.payload()) + AfterAll(func() { + Expect(tx.Commit().Error).To(BeNil()) + }) + + for _, role := range []string{"postgrest_anon", "postgrest_api"} { + Context(role, Ordered, func() { + BeforeAll(func() { + Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + + var currentRole string + Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) + Expect(currentRole).To(Equal(role)) + }) + + DescribeTable("JWT claim tests", + func(tc testCase) { + Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) var count int64 Expect(tx.Model(&models.Check{}).Count(&count).Error).To(BeNil()) Expect(count).To(Equal(*tc.expectedCount)) }, - Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), - Entry("logistics scope", scopeCase{payload: payloadWithScopes(&logisticsScopeID), expectedCount: &logisticsChecks}), + Entry("no permissions (empty scopes array)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{}, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("no permissions (non-existent canary)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Names: []string{"non-existent-canary"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("access checks via canary name (LogisticsAPICanary)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{dummy.LogisticsAPICanary.Name}}, + }, + }, + expectedCount: &logisticsAPICanaryChecksCount, + }), + Entry("access checks via canary name (LogisticsDBCanary)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{dummy.LogisticsDBCanary.Name}}, + }, + }, + expectedCount: &logisticsDBCanaryChecksCount, + }), + Entry("access checks via canary agent (CartAPICanaryAgent)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Agents: []string{dummy.GCPAgent.ID.String()}}, + }, + }, + expectedCount: &cartAPICanaryAgentChecksCount, + }), + Entry("access checks from multiple canaries (OR logic)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{dummy.LogisticsAPICanary.Name, dummy.LogisticsDBCanary.Name}}, + }, + }, + expectedCount: &logisticsAPIAndDBCanaryChecks, + }), + Entry("wildcard canary name (match all checks)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{"*"}}, + }, + }, + expectedCount: &totalChecks, + }), + Entry("empty string in canary names array (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{""}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("case sensitivity - uppercase canary name (should deny access)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{strings.ToUpper(dummy.LogisticsAPICanary.Name)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("empty scope object", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("whitespace-only canary name", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{" "}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("conflicting criteria within scope (agent matches but name doesn't)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Agents: []string{dummy.GCPAgent.ID.String()}, + Names: []string{"non-existent-canary"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), // AND logic means both must match + }), + Entry("valid canary agent + valid canary name (AND within scope)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Agents: []string{dummy.GCPAgent.ID.String()}, + Names: []string{dummy.CartAPICanaryAgent.Name}, + }, + }, + }, + expectedCount: &cartAPICanaryAgentChecksCount, + }), + Entry("multiple scopes with different canaries (OR logic)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{dummy.LogisticsAPICanary.Name}}, + {Names: []string{dummy.LogisticsDBCanary.Name}}, + }, + }, + expectedCount: &logisticsAPIAndDBCanaryChecks, + }), + Entry("tags only in scope (should deny access - canaries don't support tags)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Tags: map[string]string{"cluster": "test"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), // Should deny because canaries don't support tags + }), + Entry("mixed valid canary name and irrelevant tags", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Names: []string{dummy.LogisticsAPICanary.Name}, + Tags: map[string]string{"cluster": "test"}, + }, + }, + }, + expectedCount: &logisticsAPICanaryChecksCount, // Tags should be ignored for canaries + }), + Entry("very long canary names list (stress test)", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Names: append( + []string{dummy.LogisticsAPICanary.Name}, + func() []string { + names := make([]string, 99) + for i := range names { + names[i] = fmt.Sprintf("non-existent-canary-%d", i) + } + return names + }()..., + ), + }, + }, + }, + expectedCount: &logisticsAPICanaryChecksCount, + }), + Entry("multiple canary agents in single scope", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + { + Agents: []string{ + dummy.GCPAgent.ID.String(), + uuid.New().String(), + }, + }, + }, + }, + expectedCount: &cartAPICanaryAgentChecksCount, + }), + Entry("multiple scopes with overlapping results", testCase{ + rlsPayload: rls.Payload{ + Canary: []rls.Scope{ + {Names: []string{dummy.LogisticsAPICanary.Name}}, + {Agents: []string{uuid.Nil.String()}}, + }, + }, + expectedCount: &logisticsAPIAndDBCanaryChecks, // Union of both scopes + }), ) }) } }) - var _ = Describe("views and panels", func() { + var _ = Describe("views query", func() { var ( - tx *gorm.DB - totalViews int64 - totalPanels int64 - podScopeID uuid.UUID - devScopeID uuid.UUID - podViewPanels int64 - devViewPanels int64 - combinedViews int64 - combinedPanels int64 + tx *gorm.DB + totalViews int64 + podsViewCount int64 + devDashboardCount int64 + podsAndDevDashboard int64 ) BeforeAll(func() { tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) Expect(DefaultContext.DB().Model(&models.View{}).Where("deleted_at IS NULL").Count(&totalViews).Error).To(BeNil()) - Expect(DefaultContext.DB().Model(&models.ViewPanel{}).Count(&totalPanels).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("view_id = ?", dummy.PodView.ID).Model(&models.ViewPanel{}).Count(&podViewPanels).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("view_id = ?", dummy.ViewDev.ID).Model(&models.ViewPanel{}).Count(&devViewPanels).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("id IN ?", []uuid.UUID{dummy.PodView.ID, dummy.ViewDev.ID}).Model(&models.View{}).Count(&combinedViews).Error).To(BeNil()) - Expect(DefaultContext.DB().Where("view_id IN ?", []uuid.UUID{dummy.PodView.ID, dummy.ViewDev.ID}).Model(&models.ViewPanel{}).Count(&combinedPanels).Error).To(BeNil()) - - resetScopes(DefaultContext.DB(), "views") - podScopeID = uuid.New() - devScopeID = uuid.New() - assignScope(DefaultContext.DB(), "views", podScopeID, "id = ?", dummy.PodView.ID) - assignScope(DefaultContext.DB(), "views", devScopeID, "id = ?", dummy.ViewDev.ID) + Expect(DefaultContext.DB().Where("name = ? AND deleted_at IS NULL", dummy.PodView.Name).Model(&models.View{}).Count(&podsViewCount).Error).To(BeNil()) + Expect(DefaultContext.DB().Where("name = ? AND deleted_at IS NULL", dummy.ViewDev.Name).Model(&models.View{}).Count(&devDashboardCount).Error).To(BeNil()) + podsAndDevDashboard = podsViewCount + devDashboardCount + + Expect(totalViews).To(BeNumerically(">", 0), "No views found in test data") + Expect(podsViewCount).To(BeNumerically(">", 0), "No pods view found") + Expect(devDashboardCount).To(BeNumerically(">", 0), "No dev dashboard view found") }) AfterAll(func() { @@ -308,33 +1892,436 @@ var _ = Describe("RLS scopes", Ordered, ContinueOnFailure, func() { Context(role, Ordered, func() { BeforeAll(func() { Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + + var currentRole string + Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) + Expect(currentRole).To(Equal(role)) }) - DescribeTable("views", - func(tc scopeCase) { - setRLS(tx, tc.payload()) + DescribeTable("JWT claim tests", + func(tc testCase) { + Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) var count int64 Expect(tx.Model(&models.View{}).Where("deleted_at IS NULL").Count(&count).Error).To(BeNil()) Expect(count).To(Equal(*tc.expectedCount)) }, - Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), - Entry("pod view", scopeCase{payload: payloadWithScopes(&podScopeID), expectedCount: lo.ToPtr(int64(1))}), - Entry("combined", scopeCase{payload: payloadWithScopes(&podScopeID, &devScopeID), expectedCount: &combinedViews}), + Entry("no permissions (empty scopes array)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{}, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("no permissions (non-existent view)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + { + Names: []string{"non-existent-view"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("access specific view by name (pods)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{dummy.PodView.Name}}, + }, + }, + expectedCount: &podsViewCount, + }), + Entry("access specific view by name (Dev Dashboard)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{dummy.ViewDev.Name}}, + }, + }, + expectedCount: &devDashboardCount, + }), + Entry("access specific view by ID", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {ID: dummy.PodView.ID.String()}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("wildcard name (match all views)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{"*"}}, + }, + }, + expectedCount: &totalViews, + }), + Entry("wildcard ID (match all views)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {ID: "*"}, + }, + }, + expectedCount: &totalViews, + }), + Entry("multiple view names (OR within names array)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{dummy.PodView.Name, dummy.ViewDev.Name}}, + }, + }, + expectedCount: &podsAndDevDashboard, + }), + Entry("mixed scope criteria (OR logic between scopes)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{dummy.PodView.Name}}, + {Names: []string{dummy.ViewDev.Name}}, + }, + }, + expectedCount: &podsAndDevDashboard, + }), + Entry("ID + matching name (AND logic - should grant access)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + { + ID: dummy.PodView.ID.String(), + Names: []string{dummy.PodView.Name}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("ID + non-matching name (AND logic - should deny access)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + { + ID: dummy.PodView.ID.String(), + Names: []string{"wrong-name"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple scopes with different IDs (OR logic)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {ID: dummy.PodView.ID.String()}, + {ID: dummy.ViewDev.ID.String()}, + }, + }, + expectedCount: lo.ToPtr(int64(2)), + }), + Entry("empty string in names array (should deny access)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{""}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("case sensitivity - uppercase name (should deny access)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{strings.ToUpper(dummy.PodView.Name)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("duplicate scopes (should work same as single)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{dummy.PodView.Name}}, + {Names: []string{dummy.PodView.Name}}, // duplicate + }, + }, + expectedCount: &podsViewCount, + }), + Entry("very long names list (stress test)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + { + Names: append( + []string{dummy.PodView.Name}, + func() []string { + names := make([]string, 99) + for i := range names { + names[i] = fmt.Sprintf("non-existent-view-%d", i) + } + return names + }()..., + ), + }, + }, + }, + expectedCount: &podsViewCount, + }), + Entry("whitespace-only name", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{" "}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("extremely long name string", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{strings.Repeat("a", 1000)}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("name with wildcard in middle", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{"pod*view"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("name with wildcard prefix", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{"*view"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("multiple scopes with overlapping results", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{dummy.PodView.Name}}, + {ID: dummy.PodView.ID.String()}, + }, + }, + expectedCount: &podsViewCount, + }), + Entry("empty scope object", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("wrong ID (should deny access)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {ID: "00000000-0000-0000-0000-000000000000"}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("tags only in scope (should deny access - views don't support tags)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + { + Tags: map[string]string{"environment": "production"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), // Should deny because views don't support tags + }), + Entry("agents only in scope (should deny access - views don't support agents)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + { + Agents: []string{"00000000-0000-0000-0000-000000000000"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), // Should deny because views don't support agents + }), + Entry("tags and agents only in scope (should deny access - no applicable fields)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + { + Tags: map[string]string{"environment": "production"}, + Agents: []string{"00000000-0000-0000-0000-000000000000"}, + }, + }, + }, + expectedCount: lo.ToPtr(int64(0)), // Should deny because views support neither tags nor agents + }), + Entry("valid name + irrelevant tags (tags should be ignored)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + { + Names: []string{dummy.PodView.Name}, + Tags: map[string]string{"environment": "production"}, + }, + }, + }, + expectedCount: &podsViewCount, // Tags should be ignored for views + }), + Entry("valid name + irrelevant agents (agents should be ignored)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + { + Names: []string{dummy.PodView.Name}, + Agents: []string{"00000000-0000-0000-0000-000000000000"}, + }, + }, + }, + expectedCount: &podsViewCount, // Agents should be ignored for views + }), + Entry("mixed valid and invalid names", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + { + Names: []string{ + dummy.PodView.Name, + "non-existent-1", + dummy.ViewDev.Name, + "non-existent-2", + }, + }, + }, + }, + expectedCount: &podsAndDevDashboard, + }), + Entry("newline in name", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{"pods\nmalicious"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("special characters in name (unicode)", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{"view-名前-🚀"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("very many scopes (stress test)", testCase{ + rlsPayload: rls.Payload{ + View: append( + []rls.Scope{{Names: []string{dummy.PodView.Name}}}, + func() []rls.Scope { + scopes := make([]rls.Scope, 49) + for i := range scopes { + scopes[i] = rls.Scope{ + Names: []string{fmt.Sprintf("non-existent-%d", i)}, + } + } + return scopes + }()..., + ), + }, + expectedCount: &podsViewCount, + }), ) + }) + } + }) + + var _ = Describe("view_panels query", func() { + var ( + tx *gorm.DB + totalViewPanels int64 + podViewPanelCount int64 + devViewPanelCount int64 + ) + + BeforeAll(func() { + tx = DefaultContext.DB().Session(&gorm.Session{NewDB: true}).Begin(&sql.TxOptions{ReadOnly: true}) + + Expect(DefaultContext.DB().Model(&models.ViewPanel{}).Count(&totalViewPanels).Error).To(BeNil()) + Expect(totalViewPanels).To(Equal(int64(2)), "Expected exactly 2 view panels in test data") + + // Count panels for PodView specifically + Expect(DefaultContext.DB().Where("view_id = ?", dummy.PodView.ID).Model(&models.ViewPanel{}).Count(&podViewPanelCount).Error).To(BeNil()) + Expect(podViewPanelCount).To(Equal(int64(1)), "Expected exactly 1 panel for PodView") + + // Count panels for DevView specifically + Expect(DefaultContext.DB().Where("view_id = ?", dummy.ViewDev.ID).Model(&models.ViewPanel{}).Count(&devViewPanelCount).Error).To(BeNil()) + Expect(devViewPanelCount).To(Equal(int64(1)), "Expected exactly 1 panel for DevView") + }) + + AfterAll(func() { + Expect(tx.Commit().Error).To(BeNil()) + }) + + for _, role := range []string{"postgrest_anon", "postgrest_api"} { + Context(role, Ordered, func() { + BeforeAll(func() { + Expect(tx.Exec(fmt.Sprintf("SET LOCAL ROLE '%s'", role)).Error).To(BeNil()) + + var currentRole string + Expect(tx.Raw("SELECT CURRENT_USER").Scan(¤tRole).Error).To(BeNil()) + Expect(currentRole).To(Equal(role)) + }) - DescribeTable("view panels", - func(tc scopeCase) { - setRLS(tx, tc.payload()) + DescribeTable("JWT claim tests", + func(tc testCase) { + Expect(tc.rlsPayload.SetPostgresSessionRLS(tx)).To(BeNil()) var count int64 Expect(tx.Model(&models.ViewPanel{}).Count(&count).Error).To(BeNil()) Expect(count).To(Equal(*tc.expectedCount)) }, - Entry("no scopes", scopeCase{payload: payloadNoScopes(), expectedCount: lo.ToPtr(int64(0))}), - Entry("pod view", scopeCase{payload: payloadWithScopes(&podScopeID), expectedCount: &podViewPanels}), - Entry("dev view", scopeCase{payload: payloadWithScopes(&devScopeID), expectedCount: &devViewPanels}), - Entry("combined", scopeCase{payload: payloadWithScopes(&podScopeID, &devScopeID), expectedCount: &combinedPanels}), + Entry("user has permission to PodView - should see 1 view panel", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{dummy.PodView.Name}}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("user has no view permissions - should see 0 view panels", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{}, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("user has permission to non-existent view - should see 0 view panels", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{"non-existent-view"}}, + }, + }, + expectedCount: lo.ToPtr(int64(0)), + }), + Entry("user has permission to ViewDev - should see 1 view panel", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{dummy.ViewDev.Name}}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("user has permission to both views - should see 2 view panels", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{dummy.PodView.Name, dummy.ViewDev.Name}}, + }, + }, + expectedCount: lo.ToPtr(int64(2)), + }), + Entry("user has wildcard permission - should see all panels", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {Names: []string{"*"}}, + }, + }, + expectedCount: &totalViewPanels, + }), + Entry("user has permission by view ID - should see panel", testCase{ + rlsPayload: rls.Payload{ + View: []rls.Scope{ + {ID: dummy.PodView.ID.String()}, + }, + }, + expectedCount: lo.ToPtr(int64(1)), + }), + Entry("RLS disabled - should see all panels", testCase{ + rlsPayload: rls.Payload{ + Disable: true, + }, + expectedCount: &totalViewPanels, + }), ) }) } diff --git a/view/db.go b/view/db.go index 59ae4aa16..9569c4a6a 100644 --- a/view/db.go +++ b/view/db.go @@ -153,7 +153,9 @@ func ensureViewRLSPolicy(ctx context.Context, tableName string) error { CREATE POLICY view_grants_policy ON %s FOR ALL TO postgrest_api, postgrest_anon USING ( - check_view_grants(__grants) + CASE WHEN is_rls_disabled() THEN TRUE + ELSE check_view_grants(__grants) + END ) `, pq.QuoteIdentifier(tableName)) diff --git a/views/035_rls_utils.sql b/views/035_rls_utils.sql index 9b9092b8f..9edc82f8f 100644 --- a/views/035_rls_utils.sql +++ b/views/035_rls_utils.sql @@ -1,3 +1,16 @@ +-- is_rls_disabled checks JWT claims for disable flag. +CREATE +OR REPLACE FUNCTION is_rls_disabled() RETURNS BOOLEAN AS $$ +DECLARE + jwt_claims TEXT; +BEGIN + jwt_claims := current_setting('request.jwt.claims', TRUE); + RETURN (jwt_claims IS NULL + OR jwt_claims = '' + OR jwt_claims::jsonb ->> 'disable_rls' IS NOT NULL); +END; +$$ 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 $$ diff --git a/views/035_view_rls.sql b/views/035_view_rls.sql index 43b8524da..fb741cc93 100644 --- a/views/035_view_rls.sql +++ b/views/035_view_rls.sql @@ -17,7 +17,13 @@ BEGIN RETURN EXISTS ( SELECT 1 FROM jsonb_array_elements_text(grants) AS grant_uuid - WHERE grant_uuid::uuid = ANY(rls_scope_access()) + WHERE grant_uuid = ANY( + COALESCE( + ARRAY(SELECT jsonb_array_elements_text( + COALESCE(current_setting('request.jwt.claims', TRUE)::jsonb -> 'scopes', '[]'::jsonb) + )), '{}'::text[] + ) + ) ); END; -$$ LANGUAGE plpgsql VOLATILE; +$$ LANGUAGE plpgsql VOLATILE; \ No newline at end of file diff --git a/views/9998_rls_enable.sql b/views/9998_rls_enable.sql index 812b26888..a8fa794f0 100644 --- a/views/9998_rls_enable.sql +++ b/views/9998_rls_enable.sql @@ -1,3 +1,103 @@ +-- Generic function to match a row against an array of scopes +-- Returns TRUE if the row matches ANY scope in the array (OR logic between scopes) +-- Within a scope, ALL non-empty fields must match (AND logic within scope) +CREATE OR REPLACE FUNCTION match_scope( + scopes jsonb, -- Array of scope objects from JWT claims + row_tags jsonb, -- The row's tags (can be NULL) + row_agent uuid, -- The row's agent_id (can be NULL) + row_name text, -- The row's name (can be NULL) + row_id uuid -- The row's ID (can be NULL) +) RETURNS BOOLEAN AS $$ +DECLARE + scope jsonb; + scope_tags jsonb; + scope_agents jsonb; + scope_names jsonb; + scope_id text; + tags_match boolean; + agents_match boolean; + names_match boolean; + id_match boolean; +BEGIN + -- If scopes is NULL or not an array or empty, deny access + IF scopes IS NULL + OR jsonb_typeof(scopes) != 'array' + OR jsonb_array_length(scopes) = 0 THEN + RETURN FALSE; + END IF; + + -- Iterate through each scope (OR logic between scopes) + FOR scope IN SELECT * FROM jsonb_array_elements(scopes) + LOOP + -- Extract fields from scope + scope_tags := scope->'tags'; + scope_agents := scope->'agents'; + scope_names := scope->'names'; + scope_id := NULLIF(btrim(scope->>'id'), ''); + + -- Check if scope has any fields applicable to this resource type + -- A field is applicable if: scope defines it AND resource supports it (row param not NULL) + -- If no applicable fields, scope is effectively empty for this resource type + IF ((scope_tags IS NULL OR scope_tags = '{}'::jsonb) OR row_tags IS NULL) + AND (COALESCE(jsonb_array_length(scope_agents), 0) = 0 OR row_agent IS NULL) + AND (COALESCE(jsonb_array_length(scope_names), 0) = 0 OR row_name IS NULL) + AND (scope_id IS NULL OR row_id IS NULL) THEN + CONTINUE; + END IF; + + -- Check tags match (row must contain all scope tags) + IF scope_tags IS NULL OR jsonb_typeof(scope_tags) = 'null' OR scope_tags = '{}'::jsonb THEN + tags_match := TRUE; + ELSIF row_tags IS NULL THEN + tags_match := TRUE; -- Resource doesn't have tags, ignore this check + ELSE + tags_match := row_tags @> scope_tags; + END IF; + + -- Check agents match (row agent must be in list or wildcard) + IF scope_agents IS NULL OR jsonb_typeof(scope_agents) = 'null' OR jsonb_array_length(scope_agents) = 0 THEN + agents_match := TRUE; + ELSIF row_agent IS NULL THEN + agents_match := TRUE; -- Resource doesn't have agents, ignore this check + ELSIF scope_agents = '["*"]'::jsonb THEN + agents_match := row_agent IS NOT NULL; + ELSE + agents_match := scope_agents @> to_jsonb(row_agent::text); + END IF; + + -- Check names match (row name must be in list or wildcard) + IF scope_names IS NULL OR jsonb_typeof(scope_names) = 'null' OR jsonb_array_length(scope_names) = 0 THEN + names_match := TRUE; + ELSIF scope_names = '["*"]'::jsonb THEN + names_match := row_name IS NOT NULL; + ELSIF row_name IS NULL THEN + names_match := FALSE; + ELSE + names_match := scope_names @> to_jsonb(row_name); + END IF; + + -- Check ID match (row ID must match if provided) + IF scope_id IS NULL THEN + id_match := TRUE; + ELSIF row_id IS NULL THEN + id_match := FALSE; + ELSIF scope_id = '*' THEN + id_match := row_id IS NOT NULL; + ELSE + id_match := lower(scope_id) = row_id::text; + END IF; + + -- If ALL conditions match (AND logic within scope), return TRUE + IF tags_match AND agents_match AND names_match AND id_match THEN + RETURN TRUE; + END IF; + END LOOP; + + -- No scope matched + RETURN FALSE; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + -- Enable RLS for tables DO $$ BEGIN @@ -57,7 +157,16 @@ 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 ( - (config_items.__scope && rls_scope_access()) + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE + match_scope( + current_setting('request.jwt.claims', TRUE)::jsonb -> 'config', + config_items.tags, + config_items.agent_id, + config_items.name, + config_items.id + ) + END ); -- Policy config_changes @@ -66,12 +175,14 @@ 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 ( - EXISTS ( + 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 @@ -80,12 +191,14 @@ 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 ( - EXISTS ( + 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 @@ -94,9 +207,13 @@ 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 ( - -- 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) + 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 @@ -105,12 +222,14 @@ DROP POLICY IF EXISTS config_component_relationships_auth ON config_component_re CREATE POLICY config_component_relationships_auth ON config_component_relationships FOR ALL TO postgrest_api, postgrest_anon USING ( - EXISTS ( + 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 @@ -119,7 +238,16 @@ DROP POLICY IF EXISTS components_auth ON components; CREATE POLICY components_auth ON components FOR ALL TO postgrest_api, postgrest_anon USING ( - (components.__scope && rls_scope_access()) + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE + match_scope( + current_setting('request.jwt.claims', TRUE)::jsonb -> 'component', + NULL, + components.agent_id, + components.name, + components.id + ) + END ); -- Policy canaries @@ -128,7 +256,16 @@ DROP POLICY IF EXISTS canaries_auth ON canaries; CREATE POLICY canaries_auth ON canaries FOR ALL TO postgrest_api, postgrest_anon USING ( - (canaries.__scope && rls_scope_access()) + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE + match_scope( + current_setting('request.jwt.claims', TRUE)::jsonb -> 'canary', + NULL, + canaries.agent_id, + canaries.name, + canaries.id + ) + END ); -- Policy playbooks @@ -137,7 +274,16 @@ DROP POLICY IF EXISTS playbooks_auth ON playbooks; CREATE POLICY playbooks_auth ON playbooks FOR ALL TO postgrest_api, postgrest_anon USING ( - (playbooks.__scope && rls_scope_access()) + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE + match_scope( + current_setting('request.jwt.claims', TRUE)::jsonb -> 'playbook', + NULL, + NULL, + playbooks.name, + playbooks.id + ) + END ); -- Policy playbook_runs @@ -146,27 +292,31 @@ 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 ( - -- User must have access to the playbook - EXISTS ( - SELECT 1 - FROM playbooks - WHERE playbooks.id = playbook_runs.playbook_id + 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) ) - 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 @@ -175,12 +325,14 @@ DROP POLICY IF EXISTS checks_auth ON checks; CREATE POLICY checks_auth ON checks FOR ALL TO postgrest_api, postgrest_anon USING ( - EXISTS ( + 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 @@ -189,7 +341,16 @@ DROP POLICY IF EXISTS views_auth ON views; CREATE POLICY views_auth ON views FOR ALL TO postgrest_api, postgrest_anon USING ( - (views.__scope && rls_scope_access()) + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE + match_scope( + current_setting('request.jwt.claims', TRUE)::jsonb -> 'view', + NULL, + NULL, + views.name, + views.id + ) + END ); -- Policy view_panels (inherits from parent views table) @@ -198,10 +359,13 @@ 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 ( - EXISTS ( - SELECT 1 FROM views - WHERE views.id = view_panels.view_id - ) + 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); 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 }