From 3f7623f53b343daab36f38696cc2e953d4bcf0c0 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 5 May 2026 17:27:54 +0545 Subject: [PATCH 1/3] fix(views): drop stale external group summary view The config access migration recreates config_access_summary, but some databases can still have the old external_group_summary view depending on it. Drop the stale dependent view before dropping config_access_summary so the migration can proceed without using CASCADE. --- views/038_config_access.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/views/038_config_access.sql b/views/038_config_access.sql index e9a5fcce..9ebd94f7 100644 --- a/views/038_config_access.sql +++ b/views/038_config_access.sql @@ -75,6 +75,7 @@ SELECT WHERE external_group_id IS NULL; -- config_access_summary +DROP VIEW IF EXISTS external_group_summary; DROP VIEW IF EXISTS config_access_summary_by_user; DROP VIEW IF EXISTS config_access_summary_by_config; DROP VIEW IF EXISTS config_access_summary; From 4b3ab13fa843c9352e2afad1e5e3041c78e51205 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 5 May 2026 21:29:44 +0545 Subject: [PATCH 2/3] feat(config): add typed config properties Add a normalized config_properties table for properties created by scrapers or users, with foreign keys, creator validation, and uniqueness per creator/name. Expose typed properties through JSON compatibility views and merge them into configs and config_detail while keeping existing scraper-written config_items.properties intact. --- models/config.go | 95 ++++++++++++++++++++++ rbac/objects.go | 3 + schema/config.hcl | 161 +++++++++++++++++++++++++++++++++++++ views/006_config_views.sql | 99 ++++++++++++++++++++++- views/9998_rls_enable.sql | 19 +++++ views/9999_rls_disable.sql | 6 ++ 6 files changed, 381 insertions(+), 2 deletions(-) diff --git a/models/config.go b/models/config.go index af0fcbbc..a33609e2 100644 --- a/models/config.go +++ b/models/config.go @@ -168,6 +168,101 @@ type ConfigItemLastScrapedTime struct { LastScrapedTime *time.Time `json:"last_scraped_time,omitempty"` } +// ConfigProperty represents typed properties for a config item. +type ConfigProperty struct { + ID uuid.UUID `json:"id" gorm:"default:generate_ulid()"` + ConfigID uuid.UUID `json:"config_id"` + ScraperID *uuid.UUID `json:"scraper_id,omitempty"` + CreatedBy *uuid.UUID `json:"created_by,omitempty"` + Name string `json:"name"` + Label string `json:"label,omitempty"` + Tooltip string `json:"tooltip,omitempty"` + Icon string `json:"icon,omitempty"` + PropertyType string `json:"type,omitempty" gorm:"column:property_type"` + Color string `json:"color,omitempty"` + DisplayOrder int `json:"order,omitempty" gorm:"column:display_order"` + Headline bool `json:"headline,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Text string `json:"text,omitempty"` + Value *int64 `json:"value,omitempty"` + Unit string `json:"unit,omitempty"` + Max *int64 `json:"max,omitempty"` + Min *int64 `json:"min,omitempty"` + Status string `json:"status,omitempty"` + LinkURL string `json:"link_url,omitempty"` + LinkLabel string `json:"link_label,omitempty"` + LinkIcon string `json:"link_icon,omitempty"` + CreatedAt time.Time `json:"created_at" gorm:"<-:create"` + UpdatedAt *time.Time `json:"updated_at,omitempty" gorm:"autoUpdateTime:false"` +} + +func (ConfigProperty) TableName() string { + return "config_properties" +} + +func NewConfigProperty(configID uuid.UUID, scraperID, createdBy *uuid.UUID, property types.Property) ConfigProperty { + var link types.Link + if len(property.Links) > 0 { + link = property.Links[0] + } + + return ConfigProperty{ + ConfigID: configID, + ScraperID: scraperID, + CreatedBy: createdBy, + Name: property.Name, + Label: property.Label, + Tooltip: property.Tooltip, + Icon: property.Icon, + PropertyType: property.Type, + Color: property.Color, + DisplayOrder: property.Order, + Headline: property.Headline, + Hidden: property.Hidden, + Text: property.Text, + Value: property.Value, + Unit: property.Unit, + Max: property.Max, + Min: property.Min, + Status: property.Status, + LinkURL: link.URL, + LinkLabel: link.Text.Label, + LinkIcon: link.Text.Icon, + } +} + +func (p ConfigProperty) AsProperty() types.Property { + var links []types.Link + if p.LinkURL != "" { + links = []types.Link{{ + URL: p.LinkURL, + Text: types.Text{ + Label: p.LinkLabel, + Icon: p.LinkIcon, + }, + }} + } + + return types.Property{ + Name: p.Name, + Label: p.Label, + Tooltip: p.Tooltip, + Icon: p.Icon, + Type: p.PropertyType, + Color: p.Color, + Order: p.DisplayOrder, + Headline: p.Headline, + Hidden: p.Hidden, + Text: p.Text, + Value: p.Value, + Unit: p.Unit, + Max: p.Max, + Min: p.Min, + Status: p.Status, + Links: links, + } +} + func (ConfigItemLastScrapedTime) TableName() string { return "config_items_last_scraped_time" } diff --git a/rbac/objects.go b/rbac/objects.go index 620b982e..bf846290 100644 --- a/rbac/objects.go +++ b/rbac/objects.go @@ -75,6 +75,9 @@ var dbResourceObjMap = map[string]string{ "config_items_aws": policy.ObjectCatalog, "config_items": policy.ObjectCatalog, "config_items_last_scraped_time": policy.ObjectCatalog, + "config_properties": policy.ObjectCatalog, + "config_properties_json": policy.ObjectCatalog, + "config_items_with_properties": policy.ObjectCatalog, "config_labels": policy.ObjectDatabasePublic, "config_names": policy.ObjectDatabasePublic, "config_relationships": policy.ObjectCatalog, diff --git a/schema/config.hcl b/schema/config.hcl index e8964f97..c37b3b3d 100644 --- a/schema/config.hcl +++ b/schema/config.hcl @@ -449,6 +449,167 @@ table "config_items" { } } +# Config properties that are owned independently from the scraper-written +# config_items.properties JSONB column. This table is the typed write path for +# user-created and future scraper-created properties; compatibility views merge +# these rows back into the legacy JSON array shape for existing readers. +table "config_properties" { + schema = schema.public + column "id" { + null = false + type = uuid + default = sql("generate_ulid()") + } + column "config_id" { + null = false + type = uuid + } + column "scraper_id" { + null = true + type = uuid + } + column "created_by" { + null = true + type = uuid + } + column "name" { + null = false + type = text + } + column "label" { + null = true + type = text + } + column "tooltip" { + null = true + type = text + } + column "icon" { + null = true + type = text + } + column "property_type" { + null = true + type = text + } + column "color" { + null = true + type = text + } + column "display_order" { + null = true + type = integer + } + column "headline" { + null = false + type = bool + default = false + } + column "hidden" { + null = false + type = bool + default = false + } + column "text" { + null = true + type = text + } + column "value" { + null = true + type = bigint + } + column "unit" { + null = true + type = text + } + column "max" { + null = true + type = bigint + } + column "min" { + null = true + type = bigint + } + column "status" { + null = true + type = text + } + column "link_url" { + null = true + type = text + } + column "link_label" { + null = true + type = text + } + column "link_icon" { + null = true + type = text + } + column "created_at" { + null = false + type = timestamptz + default = sql("now()") + } + column "updated_at" { + null = true + type = timestamptz + default = sql("now()") + } + primary_key { + columns = [column.id] + } + foreign_key "config_properties_config_id_fkey" { + columns = [column.config_id] + ref_columns = [table.config_items.column.id] + on_update = NO_ACTION + on_delete = CASCADE + } + foreign_key "config_properties_scraper_id_fkey" { + columns = [column.scraper_id] + ref_columns = [table.config_scrapers.column.id] + on_update = NO_ACTION + on_delete = NO_ACTION + } + foreign_key "config_properties_created_by_fkey" { + columns = [column.created_by] + ref_columns = [table.people.column.id] + on_update = NO_ACTION + on_delete = NO_ACTION + } + index "config_properties_config_id_idx" { + columns = [column.config_id] + } + index "config_properties_scraper_id_idx" { + columns = [column.scraper_id] + } + index "config_properties_created_by_idx" { + columns = [column.created_by] + } + index "config_properties_name_idx" { + columns = [column.name] + } + index "config_properties_config_name_text_idx" { + columns = [column.config_id, column.name, column.text] + } + index "config_properties_config_name_value_idx" { + columns = [column.config_id, column.name, column.value] + } + index "config_properties_scraper_unique_idx" { + unique = true + columns = [column.config_id, column.scraper_id, column.name] + where = "scraper_id IS NOT NULL" + } + index "config_properties_created_by_unique_idx" { + unique = true + columns = [column.config_id, column.created_by, column.name] + where = "created_by IS NOT NULL" + } + check "config_properties_creator_check" { + expr = "((scraper_id IS NOT NULL) <> (created_by IS NOT NULL))" + } +} + table "config_items_last_scraped_time" { schema = schema.public unlogged = true diff --git a/views/006_config_views.sql b/views/006_config_views.sql index f3211f68..77bb43e8 100644 --- a/views/006_config_views.sql +++ b/views/006_config_views.sql @@ -2,6 +2,8 @@ -- Add cascade drops first to make sure all functions and views are always recreated DROP VIEW IF EXISTS configs CASCADE; +DROP VIEW IF EXISTS config_items_with_properties CASCADE; +DROP VIEW IF EXISTS config_properties_json CASCADE; DROP FUNCTION IF EXISTS related_changes_recursive CASCADE; @@ -112,6 +114,99 @@ BEGIN END; $$ LANGUAGE plpgsql SECURITY DEFINER; +-- config_properties_json +-- Converts typed config_properties rows back into the legacy JSONB property-array +-- shape used by config_items.properties. This keeps existing readers compatible +-- while allowing new writes to target the normalized config_properties table. +CREATE OR REPLACE VIEW config_properties_json AS + SELECT + config_id, + jsonb_agg( + jsonb_strip_nulls( + jsonb_build_object( + 'name', name, + 'label', label, + 'tooltip', tooltip, + 'icon', icon, + 'type', property_type, + 'color', color, + 'order', display_order, + 'headline', NULLIF(headline, false), + 'hidden', NULLIF(hidden, false), + 'text', text, + 'value', value, + 'unit', unit, + 'max', max, + 'min', min, + 'status', status, + 'links', CASE WHEN link_url IS NOT NULL AND link_url <> '' THEN + jsonb_build_array( + jsonb_strip_nulls( + jsonb_build_object( + 'url', link_url, + 'label', link_label, + 'icon', link_icon + ) + ) + ) + ELSE NULL END + ) + ) + ORDER BY display_order NULLS LAST, created_at, id + ) AS properties + FROM config_properties + GROUP BY config_id; + +-- config_items_with_properties +-- Compatibility layer over config_items that exposes a merged properties array: +-- scraper-written config_items.properties first, followed by normalized +-- config_properties rows. Use this from views that should present all config +-- properties without forcing callers to read two sources. +CREATE OR REPLACE VIEW config_items_with_properties AS + SELECT + ci.id, + ci.agent_id, + ci.icon, + ci.scraper_id, + ci.config_class, + ci.status, + ci.health, + ci.ready, + ci.external_id, + ci.type, + ci.cost_per_minute, + ci.cost_total_1d, + ci.cost_total_7d, + ci.cost_total_30d, + ci.name, + ci.description, + ci.config, + ci.source, + ci.labels, + ci.tags, + ci.tags_values, + merged.properties, + CASE WHEN merged.properties IS NULL THEN NULL + ELSE jsonb_path_query_array(merged.properties, '$[*].text'::jsonpath) || + jsonb_path_query_array(merged.properties, '$[*].value'::jsonpath) + END AS properties_values, + ci.parent_id, + ci.path, + ci.is_pushed, + ci.created_by, + ci.created_at, + ci.updated_at, + ci.deleted_at, + ci.delete_reason, + ci.inserted_at + FROM config_items AS ci + LEFT JOIN config_properties_json cp ON cp.config_id = ci.id + CROSS JOIN LATERAL ( + SELECT CASE WHEN ci.properties IS NULL AND cp.properties IS NULL THEN NULL + ELSE COALESCE(ci.properties, '[]'::jsonb) || COALESCE(cp.properties, '[]'::jsonb) + END AS properties + ) AS merged; + CREATE or REPLACE VIEW configs AS SELECT ci.id, @@ -143,7 +238,7 @@ CREATE or REPLACE VIEW configs AS ci.path, config_item_summary_7d.config_changes_count AS changes, config_item_summary_7d.config_analysis_type_counts AS analysis - FROM config_items AS ci + FROM config_items_with_properties AS ci LEFT JOIN config_item_summary_7d ON config_item_summary_7d.config_id = ci.id; @@ -1010,7 +1105,7 @@ CREATE OR REPLACE VIEW config_detail AS 'id', config_scrapers.id, 'name', config_scrapers.name ) ELSE NULL END as scraper - FROM config_items as ci + FROM config_items_with_properties as ci LEFT JOIN agents ON agents.id = ci.agent_id LEFT JOIN config_items_last_scraped_time ON config_items_last_scraped_time.config_id = ci.id LEFT JOIN config_scrapers ON config_scrapers.id = ci.scraper_id diff --git a/views/9998_rls_enable.sql b/views/9998_rls_enable.sql index 6469b802..acc5e53c 100644 --- a/views/9998_rls_enable.sql +++ b/views/9998_rls_enable.sql @@ -114,6 +114,10 @@ BEGIN EXECUTE 'ALTER TABLE config_changes ENABLE ROW LEVEL SECURITY;'; END IF; + IF NOT (SELECT relrowsecurity FROM pg_class WHERE relname = 'config_properties') THEN + EXECUTE 'ALTER TABLE config_properties 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; @@ -174,6 +178,21 @@ CREATE POLICY config_items_auth ON config_items END ); +-- Policy config_properties +DROP POLICY IF EXISTS config_properties_auth ON config_properties; + +CREATE POLICY config_properties_auth ON config_properties + FOR ALL TO postgrest_api, postgrest_anon + USING ( + CASE WHEN (SELECT is_rls_disabled()) THEN TRUE + ELSE EXISTS ( + SELECT 1 + FROM config_items + WHERE config_items.id = config_properties.config_id + ) + END + ); + -- Policy config_changes DROP POLICY IF EXISTS config_changes_auth ON config_changes; diff --git a/views/9999_rls_disable.sql b/views/9999_rls_disable.sql index 16316d45..c9a09c9f 100644 --- a/views/9999_rls_disable.sql +++ b/views/9999_rls_disable.sql @@ -9,6 +9,10 @@ BEGIN EXECUTE 'ALTER TABLE config_changes DISABLE ROW LEVEL SECURITY;'; END IF; + IF (SELECT relrowsecurity FROM pg_class WHERE relname = 'config_properties') THEN + EXECUTE 'ALTER TABLE config_properties DISABLE ROW LEVEL SECURITY;'; + END IF; + IF (SELECT relrowsecurity FROM pg_class WHERE relname = 'config_analysis') THEN EXECUTE 'ALTER TABLE config_analysis DISABLE ROW LEVEL SECURITY;'; END IF; @@ -57,6 +61,8 @@ DROP POLICY IF EXISTS components_auth ON components; DROP POLICY IF EXISTS config_changes_auth ON config_changes; +DROP POLICY IF EXISTS config_properties_auth ON config_properties; + DROP POLICY IF EXISTS config_analysis_auth ON config_analysis; DROP POLICY IF EXISTS config_component_relationships_auth ON config_component_relationships; From 0c52d9349ec8065be23df000780b497275c53c90 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 5 May 2026 21:43:11 +0545 Subject: [PATCH 3/3] fix(rls): secure config property views Set security_invoker on the new config property compatibility views so configs and config_detail continue to enforce config_items RLS through nested view reads. --- views/9998_rls_enable.sql | 2 ++ 1 file changed, 2 insertions(+) diff --git a/views/9998_rls_enable.sql b/views/9998_rls_enable.sql index acc5e53c..db912b44 100644 --- a/views/9998_rls_enable.sql +++ b/views/9998_rls_enable.sql @@ -407,6 +407,8 @@ 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_items_with_properties SET (security_invoker = true); +ALTER VIEW config_properties_json 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);