From 196251908b7a1f4106ec5421f529413eb55bf31e Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 14 Jan 2026 15:50:25 +0545 Subject: [PATCH 1/4] feat: process materialization events --- api/event.go | 3 ++ cmd/server.go | 1 + jobs/jobs.go | 10 ++++ jobs/rls_scope_jobs.go | 118 +++++++++++++++++++++++++++++++++++++++++ permission/events.go | 74 ++++++++++++++++++++++++++ 5 files changed, 206 insertions(+) create mode 100644 jobs/rls_scope_jobs.go create mode 100644 permission/events.go diff --git a/api/event.go b/api/event.go index 19f109ba2..caefbf37f 100644 --- a/api/event.go +++ b/api/event.go @@ -67,6 +67,9 @@ const ( EventMSPlannerResponderAdded = "incident.responder.msplanner.added" EventMSPlannerCommentAdded = "incident.comment.msplanner.added" + + EventScopeMaterialize = "scope.materialize" + EventPermissionMaterialize = "permission.materialize" ) var ( diff --git a/cmd/server.go b/cmd/server.go index 9826de859..f4e2a03bd 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -37,6 +37,7 @@ import ( _ "github.com/flanksource/incident-commander/artifacts" _ "github.com/flanksource/incident-commander/catalog" _ "github.com/flanksource/incident-commander/connection" + _ "github.com/flanksource/incident-commander/permission" _ "github.com/flanksource/incident-commander/playbook" _ "github.com/flanksource/incident-commander/shorturl" _ "github.com/flanksource/incident-commander/snapshot" diff --git a/jobs/jobs.go b/jobs/jobs.go index 1c7caac96..4ffbcf859 100644 --- a/jobs/jobs.go +++ b/jobs/jobs.go @@ -113,6 +113,16 @@ func Start(ctx context.Context, mcpServer *server.MCPServer) { logger.Errorf("Failed to schedule job for cleaning up short URLs: %v", err) } + materializeAllScopesJob.Context = ctx + if err := materializeAllScopesJob.AddToScheduler(FuncScheduler); err != nil { + logger.Errorf("Failed to schedule job for materializing scopes: %v", err) + } + + cleanupDeletedScopesJob.Context = ctx + if err := cleanupDeletedScopesJob.AddToScheduler(FuncScheduler); err != nil { + logger.Errorf("Failed to schedule job for cleaning up deleted scopes: %v", err) + } + for _, job := range query.Jobs { j := job j.Context = ctx diff --git a/jobs/rls_scope_jobs.go b/jobs/rls_scope_jobs.go new file mode 100644 index 000000000..fc1a8f2d7 --- /dev/null +++ b/jobs/rls_scope_jobs.go @@ -0,0 +1,118 @@ +package jobs + +import ( + "github.com/flanksource/duty/job" + "github.com/flanksource/duty/models" + + "github.com/flanksource/incident-commander/db" +) + +const ( + ScopeMaterializeAllSchedule = "@every 24h" + ScopeCleanupDeletedSchedule = "@every 24h" + + materializeAllScopesJobName = "MaterializeAllScopes" + cleanupDeletedScopesJobName = "CleanupDeletedScopes" +) + +var materializeAllScopesJob = &job.Job{ + Name: materializeAllScopesJobName, + Schedule: ScopeMaterializeAllSchedule, + Singleton: true, + JitterDisable: true, + JobHistory: true, + Retention: job.RetentionFew, + RunNow: true, // Must start immediately to materialize existing scopes/permissions. Only needed for initial migration. + Fn: func(ctx job.JobRuntime) error { + var scopes []models.Scope + if err := ctx.DB().Model(&models.Scope{}). + Where("deleted_at IS NULL"). + Find(&scopes).Error; err != nil { + return ctx.Oops().Wrapf(err, "failed to list scopes") + } + + var rebuildCount, errorCount int + var failed []string + for _, scope := range scopes { + jobRun, err := db.GetProcessScopeJob(ctx.Context, db.ScopeQueueSourceScope, scope.ID.String(), db.ScopeQueueActionRebuild) + if err != nil { + errorCount++ + failed = append(failed, scope.ID.String()) + continue + } + + jobRun.Run() + if jobRun.LastJob != nil { + if err := jobRun.LastJob.AsError(); err != nil { + errorCount++ + failed = append(failed, scope.ID.String()) + continue + } + } + + rebuildCount++ + } + + ctx.History.SuccessCount = rebuildCount + ctx.History.ErrorCount = errorCount + ctx.History.AddDetails("processed", len(scopes)) + ctx.History.AddDetails("rebuild_count", rebuildCount) + ctx.History.AddDetails("error_count", errorCount) + if len(failed) > 0 { + ctx.History.AddDetails("failed_scopes", failed) + } + + return nil + }, +} + +var cleanupDeletedScopesJob = &job.Job{ + Name: cleanupDeletedScopesJobName, + Schedule: ScopeCleanupDeletedSchedule, + Singleton: true, + JitterDisable: true, + JobHistory: true, + Retention: job.RetentionFew, + RunNow: false, + Fn: func(ctx job.JobRuntime) error { + var scopes []models.Scope + if err := ctx.DB().Model(&models.Scope{}). + Where("deleted_at IS NOT NULL"). + Find(&scopes).Error; err != nil { + return ctx.Oops().Wrapf(err, "failed to list deleted scopes") + } + + var removeCount, errorCount int + var failed []string + for _, scope := range scopes { + jobRun, err := db.GetProcessScopeJob(ctx.Context, db.ScopeQueueSourceScope, scope.ID.String(), db.ScopeQueueActionRemove) + if err != nil { + errorCount++ + failed = append(failed, scope.ID.String()) + continue + } + + jobRun.Run() + if jobRun.LastJob != nil { + if err := jobRun.LastJob.AsError(); err != nil { + errorCount++ + failed = append(failed, scope.ID.String()) + continue + } + } + + removeCount++ + } + + ctx.History.SuccessCount = removeCount + ctx.History.ErrorCount = errorCount + ctx.History.AddDetails("processed", len(scopes)) + ctx.History.AddDetails("remove_count", removeCount) + ctx.History.AddDetails("error_count", errorCount) + if len(failed) > 0 { + ctx.History.AddDetails("failed_scopes", failed) + } + + return nil + }, +} diff --git a/permission/events.go b/permission/events.go new file mode 100644 index 000000000..4ffe2a647 --- /dev/null +++ b/permission/events.go @@ -0,0 +1,74 @@ +package permission + +import ( + "strings" + + "github.com/flanksource/duty/context" + "github.com/flanksource/duty/models" + + "github.com/flanksource/incident-commander/api" + "github.com/flanksource/incident-commander/db" + "github.com/flanksource/incident-commander/events" +) + +func init() { + events.Register(registerPermissionEvents) +} + +func registerPermissionEvents(ctx context.Context) { + events.RegisterAsyncHandler(handleScopeMaterializationEvents, 1, 1, api.EventScopeMaterialize) + events.RegisterAsyncHandler(handlePermissionMaterializationEvents, 1, 1, api.EventPermissionMaterialize) +} + +func handleScopeMaterializationEvents(ctx context.Context, batch models.Events) models.Events { + return handleMaterializationEvents(ctx, batch, db.ScopeQueueSourceScope) +} + +func handlePermissionMaterializationEvents(ctx context.Context, batch models.Events) models.Events { + return handleMaterializationEvents(ctx, batch, db.ScopeQueueSourcePermission) +} + +func handleMaterializationEvents(ctx context.Context, batch models.Events, sourceType string) models.Events { + var failed models.Events + + for _, event := range batch { + action := strings.ToLower(strings.TrimSpace(event.Properties["action"])) + if action == "" { + action = db.ScopeQueueActionRebuild + } + + sourceID := strings.TrimSpace(event.Properties["id"]) + if sourceID == "" { + event.SetError("materialization event missing id") + failed = append(failed, event) + continue + } + + switch action { + case db.ScopeQueueActionApply, db.ScopeQueueActionRemove, db.ScopeQueueActionRebuild: + // ok + default: + event.SetError("invalid materialization action") + failed = append(failed, event) + continue + } + + jobRun, err := db.GetProcessScopeJob(ctx, sourceType, sourceID, action) + if err != nil { + event.SetError(err.Error()) + failed = append(failed, event) + continue + } + + jobRun.Run() + if jobRun.LastJob != nil { + if err := jobRun.LastJob.AsError(); err != nil { + event.SetError(err.Error()) + failed = append(failed, event) + continue + } + } + } + + return failed +} From 89e83702b9ed611b71324ab9fa3fc7110088a246 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 14 Jan 2026 19:08:15 +0545 Subject: [PATCH 2/4] update tests --- jobs/rls_scope_jobs.go | 6 +- permission/events.go | 11 +- permission/rls_scopes.go | 565 ++++++++++++++++++ tests/permissions/permissions_test.go | 217 +++---- .../testdata/scopes/multi-target-scope.yaml | 2 +- 5 files changed, 647 insertions(+), 154 deletions(-) create mode 100644 permission/rls_scopes.go diff --git a/jobs/rls_scope_jobs.go b/jobs/rls_scope_jobs.go index fc1a8f2d7..251df4f55 100644 --- a/jobs/rls_scope_jobs.go +++ b/jobs/rls_scope_jobs.go @@ -4,7 +4,7 @@ import ( "github.com/flanksource/duty/job" "github.com/flanksource/duty/models" - "github.com/flanksource/incident-commander/db" + "github.com/flanksource/incident-commander/permission" ) const ( @@ -34,7 +34,7 @@ var materializeAllScopesJob = &job.Job{ var rebuildCount, errorCount int var failed []string for _, scope := range scopes { - jobRun, err := db.GetProcessScopeJob(ctx.Context, db.ScopeQueueSourceScope, scope.ID.String(), db.ScopeQueueActionRebuild) + jobRun, err := permission.GetProcessScopeJob(ctx.Context, permission.ScopeQueueSourceScope, scope.ID.String(), permission.ScopeQueueActionRebuild) if err != nil { errorCount++ failed = append(failed, scope.ID.String()) @@ -85,7 +85,7 @@ var cleanupDeletedScopesJob = &job.Job{ var removeCount, errorCount int var failed []string for _, scope := range scopes { - jobRun, err := db.GetProcessScopeJob(ctx.Context, db.ScopeQueueSourceScope, scope.ID.String(), db.ScopeQueueActionRemove) + jobRun, err := permission.GetProcessScopeJob(ctx.Context, permission.ScopeQueueSourceScope, scope.ID.String(), permission.ScopeQueueActionRemove) if err != nil { errorCount++ failed = append(failed, scope.ID.String()) diff --git a/permission/events.go b/permission/events.go index 4ffe2a647..7a344feb9 100644 --- a/permission/events.go +++ b/permission/events.go @@ -7,7 +7,6 @@ import ( "github.com/flanksource/duty/models" "github.com/flanksource/incident-commander/api" - "github.com/flanksource/incident-commander/db" "github.com/flanksource/incident-commander/events" ) @@ -21,11 +20,11 @@ func registerPermissionEvents(ctx context.Context) { } func handleScopeMaterializationEvents(ctx context.Context, batch models.Events) models.Events { - return handleMaterializationEvents(ctx, batch, db.ScopeQueueSourceScope) + return handleMaterializationEvents(ctx, batch, ScopeQueueSourceScope) } func handlePermissionMaterializationEvents(ctx context.Context, batch models.Events) models.Events { - return handleMaterializationEvents(ctx, batch, db.ScopeQueueSourcePermission) + return handleMaterializationEvents(ctx, batch, ScopeQueueSourcePermission) } func handleMaterializationEvents(ctx context.Context, batch models.Events, sourceType string) models.Events { @@ -34,7 +33,7 @@ func handleMaterializationEvents(ctx context.Context, batch models.Events, sourc for _, event := range batch { action := strings.ToLower(strings.TrimSpace(event.Properties["action"])) if action == "" { - action = db.ScopeQueueActionRebuild + action = ScopeQueueActionRebuild } sourceID := strings.TrimSpace(event.Properties["id"]) @@ -45,7 +44,7 @@ func handleMaterializationEvents(ctx context.Context, batch models.Events, sourc } switch action { - case db.ScopeQueueActionApply, db.ScopeQueueActionRemove, db.ScopeQueueActionRebuild: + case ScopeQueueActionApply, ScopeQueueActionRemove, ScopeQueueActionRebuild: // ok default: event.SetError("invalid materialization action") @@ -53,7 +52,7 @@ func handleMaterializationEvents(ctx context.Context, batch models.Events, sourc continue } - jobRun, err := db.GetProcessScopeJob(ctx, sourceType, sourceID, action) + jobRun, err := GetProcessScopeJob(ctx, sourceType, sourceID, action) if err != nil { event.SetError(err.Error()) failed = append(failed, event) diff --git a/permission/rls_scopes.go b/permission/rls_scopes.go new file mode 100644 index 000000000..cdf2e0bc7 --- /dev/null +++ b/permission/rls_scopes.go @@ -0,0 +1,565 @@ +package permission + +import ( + "encoding/json" + "strings" + + "github.com/flanksource/commons/collections" + "github.com/flanksource/duty/context" + "github.com/flanksource/duty/job" + "github.com/flanksource/duty/models" + "github.com/flanksource/duty/query" + dutyRBAC "github.com/flanksource/duty/rbac" + "github.com/flanksource/duty/rbac/policy" + "github.com/flanksource/duty/types" + "github.com/google/uuid" + "gorm.io/gorm" + + v1 "github.com/flanksource/incident-commander/api/v1" +) + +const ( + ScopeQueueSourceScope = "scope" + ScopeQueueSourcePermission = "permission" + + ScopeQueueActionApply = "apply" + ScopeQueueActionRemove = "remove" + ScopeQueueActionRebuild = "rebuild" +) + +type ScopeMaterializationStats struct { + Tables map[string]int64 `json:"tables,omitempty"` + TotalRows int64 `json:"total_rows,omitempty"` + SelectorCount int `json:"selector_count,omitempty"` + IDCount int `json:"id_count,omitempty"` + WildcardsSkipped int `json:"wildcards_skipped,omitempty"` +} + +func (s *ScopeMaterializationStats) addTableCount(table string, count int64) { + if count == 0 { + return + } + if s.Tables == nil { + s.Tables = make(map[string]int64) + } + s.Tables[table] += count + s.TotalRows += count +} + +type ScopeProcessor struct { + ctx context.Context + sourceType string + sourceID uuid.UUID + action string + stats ScopeMaterializationStats +} + +func GetProcessScopeJob(ctx context.Context, sourceType, sourceID, action string) (*job.Job, error) { + if _, err := uuid.Parse(sourceID); err != nil { + return nil, ctx.Oops().Wrapf(err, "invalid scope id %s", sourceID) + } + + jobName := "ProcessScopeMaterialization" + j := job.NewJob(ctx, jobName, "", func(run job.JobRuntime) error { + processor, err := newScopeProcessor(run.Context, sourceType, sourceID, action) + if err != nil { + return err + } + + run.History.AddDetails("source_type", sourceType) + run.History.AddDetails("source_id", sourceID) + run.History.AddDetails("action", action) + + if err := processor.Run(); err != nil { + return err + } + + run.History.AddDetails("tables", processor.stats.Tables) + run.History.AddDetails("total_rows", processor.stats.TotalRows) + run.History.AddDetails("selector_count", processor.stats.SelectorCount) + run.History.AddDetails("id_count", processor.stats.IDCount) + run.History.AddDetails("wildcards_skipped", processor.stats.WildcardsSkipped) + + if processor.stats.TotalRows > 0 { + run.History.SuccessCount = int(processor.stats.TotalRows) + } + + return nil + }) + + j.ResourceType = sourceType + j.ResourceID = sourceID + j.ID = action + j.JobHistory = true + return j, nil +} + +func newScopeProcessor(ctx context.Context, sourceType, sourceID, action string) (*ScopeProcessor, error) { + id, err := uuid.Parse(sourceID) + if err != nil { + return nil, ctx.Oops().Wrapf(err, "invalid scope id %s", sourceID) + } + + return &ScopeProcessor{ + ctx: ctx, + sourceType: sourceType, + sourceID: id, + action: action, + stats: ScopeMaterializationStats{}, + }, nil +} + +func (p *ScopeProcessor) Run() error { + switch p.sourceType { + case ScopeQueueSourceScope: + return p.runScope() + case ScopeQueueSourcePermission: + return p.runPermission() + default: + return p.ctx.Oops().Errorf("unknown scope source_type=%s", p.sourceType) + } +} + +func (p *ScopeProcessor) runScope() error { + switch p.action { + case ScopeQueueActionRemove: + removed, err := removeScopeFromAllTables(p.ctx, p.sourceID.String()) + if err != nil { + return err + } + for table, count := range removed { + p.stats.addTableCount(table, count) + } + return nil + case ScopeQueueActionApply: + return applyScope(p.ctx, p.sourceID, &p.stats) + case ScopeQueueActionRebuild: + removed, err := removeScopeFromAllTables(p.ctx, p.sourceID.String()) + if err != nil { + return err + } + for table, count := range removed { + p.stats.addTableCount(table, count) + } + return applyScope(p.ctx, p.sourceID, &p.stats) + default: + return p.ctx.Oops().Errorf("unknown scope action=%s", p.action) + } +} + +func (p *ScopeProcessor) runPermission() error { + switch p.action { + case ScopeQueueActionRemove: + removed, err := removeScopeFromAllTables(p.ctx, p.sourceID.String()) + if err != nil { + return err + } + for table, count := range removed { + p.stats.addTableCount(table, count) + } + return nil + case ScopeQueueActionApply: + return applyPermissionScope(p.ctx, p.sourceID, &p.stats) + case ScopeQueueActionRebuild: + removed, err := removeScopeFromAllTables(p.ctx, p.sourceID.String()) + if err != nil { + return err + } + for table, count := range removed { + p.stats.addTableCount(table, count) + } + return applyPermissionScope(p.ctx, p.sourceID, &p.stats) + default: + return p.ctx.Oops().Errorf("unknown permission action=%s", p.action) + } +} + +func applyScope(ctx context.Context, scopeID uuid.UUID, stats *ScopeMaterializationStats) error { + var scope models.Scope + err := ctx.DB().Where("id = ? AND deleted_at IS NULL", scopeID).First(&scope).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return ctx.Oops().Wrap(err) + } + + var targets []v1.ScopeTarget + if err := json.Unmarshal([]byte(scope.Targets), &targets); err != nil { + return ctx.Oops().Wrapf(err, "failed to unmarshal scope targets") + } + + for _, target := range targets { + switch { + case target.Config != nil: + if isWildcardScopeSelector(target.Config) { + if stats != nil { + stats.WildcardsSkipped++ + } + continue + } + if stats != nil { + stats.SelectorCount++ + } + count, err := applyScopeSelector(ctx, "config_items", convertScopeResourceSelector(target.Config), scopeID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("config_items", count) + } + case target.Component != nil: + if isWildcardScopeSelector(target.Component) { + if stats != nil { + stats.WildcardsSkipped++ + } + continue + } + if stats != nil { + stats.SelectorCount++ + } + count, err := applyScopeSelector(ctx, "components", convertScopeResourceSelector(target.Component), scopeID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("components", count) + } + case target.Canary != nil: + if isWildcardScopeSelector(target.Canary) { + if stats != nil { + stats.WildcardsSkipped++ + } + continue + } + if stats != nil { + stats.SelectorCount++ + } + count, err := applyScopeSelector(ctx, "canaries", convertScopeResourceSelector(target.Canary), scopeID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("canaries", count) + } + case target.Playbook != nil: + if isWildcardScopeSelector(target.Playbook) { + if stats != nil { + stats.WildcardsSkipped++ + } + continue + } + if stats != nil { + stats.SelectorCount++ + } + count, err := applyScopeSelector(ctx, "playbooks", convertScopeResourceSelector(target.Playbook), scopeID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("playbooks", count) + } + case target.View != nil: + if isWildcardScopeSelector(target.View) { + if stats != nil { + stats.WildcardsSkipped++ + } + continue + } + if stats != nil { + stats.SelectorCount++ + } + count, err := applyScopeSelector(ctx, "views", convertScopeResourceSelector(target.View), scopeID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("views", count) + } + case target.Global != nil: + if isWildcardScopeSelector(target.Global) { + if stats != nil { + stats.WildcardsSkipped++ + } + continue + } + globalSelector := convertScopeResourceSelector(target.Global) + for _, table := range rlsScopeTables() { + if stats != nil { + stats.SelectorCount++ + } + count, err := applyScopeSelector(ctx, table, globalSelector, scopeID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount(table, count) + } + } + } + } + + return nil +} + +func applyPermissionScope(ctx context.Context, permissionID uuid.UUID, stats *ScopeMaterializationStats) error { + var permission models.Permission + if err := ctx.DB().Where("id = ? AND deleted_at IS NULL", permissionID).First(&permission).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return ctx.Oops().Wrap(err) + } + + if !collections.MatchItems(policy.ActionRead, strings.Split(permission.Action, ",")...) { + return nil + } + + if permission.ConfigID != nil { + if stats != nil { + stats.IDCount++ + } + count, err := applyScopeToIDs(ctx, "config_items", []uuid.UUID{*permission.ConfigID}, permission.ID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("config_items", count) + } + } + if permission.ComponentID != nil { + if stats != nil { + stats.IDCount++ + } + count, err := applyScopeToIDs(ctx, "components", []uuid.UUID{*permission.ComponentID}, permission.ID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("components", count) + } + } + if permission.CanaryID != nil { + if stats != nil { + stats.IDCount++ + } + count, err := applyScopeToIDs(ctx, "canaries", []uuid.UUID{*permission.CanaryID}, permission.ID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("canaries", count) + } + } + if permission.PlaybookID != nil { + if stats != nil { + stats.IDCount++ + } + count, err := applyScopeToIDs(ctx, "playbooks", []uuid.UUID{*permission.PlaybookID}, permission.ID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("playbooks", count) + } + } + + if len(permission.ObjectSelector) == 0 { + return nil + } + + var selectors v1.PermissionObject + if err := json.Unmarshal([]byte(permission.ObjectSelector), &selectors); err != nil { + return ctx.Oops().Wrapf(err, "failed to unmarshal permission object_selector") + } + + if len(selectors.Configs) > 0 && !hasWildcardSelector(selectors.Configs) { + for _, selector := range selectors.Configs { + if stats != nil { + stats.SelectorCount++ + } + count, err := applyScopeSelector(ctx, "config_items", selector, permission.ID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("config_items", count) + } + } + } else if len(selectors.Configs) > 0 && stats != nil { + stats.WildcardsSkipped++ + } + if len(selectors.Components) > 0 && !hasWildcardSelector(selectors.Components) { + for _, selector := range selectors.Components { + if stats != nil { + stats.SelectorCount++ + } + count, err := applyScopeSelector(ctx, "components", selector, permission.ID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("components", count) + } + } + } else if len(selectors.Components) > 0 && stats != nil { + stats.WildcardsSkipped++ + } + if len(selectors.Playbooks) > 0 && !hasWildcardSelector(selectors.Playbooks) { + for _, selector := range selectors.Playbooks { + if stats != nil { + stats.SelectorCount++ + } + count, err := applyScopeSelector(ctx, "playbooks", selector, permission.ID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("playbooks", count) + } + } + } else if len(selectors.Playbooks) > 0 && stats != nil { + stats.WildcardsSkipped++ + } + if len(selectors.Views) > 0 && !hasWildcardViewRef(selectors.Views) { + for _, selector := range selectors.Views { + viewSelector := types.ResourceSelector{ + Name: selector.Name, + Namespace: selector.Namespace, + } + if stats != nil { + stats.SelectorCount++ + } + count, err := applyScopeSelector(ctx, "views", viewSelector, permission.ID.String()) + if err != nil { + return err + } + if stats != nil { + stats.addTableCount("views", count) + } + } + } else if len(selectors.Views) > 0 && stats != nil { + stats.WildcardsSkipped++ + } + + return nil +} + +func applyScopeSelector(ctx context.Context, table string, selector types.ResourceSelector, scopeID string) (int64, error) { + if selector.IsEmpty() || selector.Wildcard() { + return 0, nil + } + + batchSize := ctx.Properties().Int("rls.scope.materialize.batch", 10000) + var total int64 + for { + q := ctx.DB().Table(table).Select("id") + q, err := query.SetResourceSelectorClause(ctx, selector, q, table) + if err != nil { + return 0, ctx.Oops().Wrapf(err, "failed to apply resource selector to %s", table) + } + + q = q.Where("NOT (COALESCE(__scope, '{}'::uuid[]) @> ARRAY[?]::uuid[])", scopeID) + if batchSize > 0 { + q = q.Limit(batchSize) + } + + var ids []uuid.UUID + if err := q.Find(&ids).Error; err != nil { + return 0, ctx.Oops().Wrapf(err, "failed to fetch scope ids for %s", table) + } + + if len(ids) == 0 { + break + } + + if err := ctx.DB().Table(table). + Where("id IN ?", ids). + UpdateColumn("__scope", gorm.Expr("array_append(COALESCE(__scope, '{}'::uuid[]), ?)", scopeID)).Error; err != nil { + return 0, ctx.Oops().Wrapf(err, "failed to update __scope for %s", table) + } + total += int64(len(ids)) + } + + return total, nil +} + +func applyScopeToIDs(ctx context.Context, table string, ids []uuid.UUID, scopeID string) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + + result := ctx.DB().Table(table). + Where("id IN ?", ids). + Where("NOT (COALESCE(__scope, '{}'::uuid[]) @> ARRAY[?]::uuid[])", scopeID). + UpdateColumn("__scope", gorm.Expr("array_append(COALESCE(__scope, '{}'::uuid[]), ?)", scopeID)) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +func removeScopeFromAllTables(ctx context.Context, scopeID string) (map[string]int64, error) { + removed := make(map[string]int64) + for _, table := range rlsScopeTables() { + count, err := removeScopeFromTable(ctx, table, scopeID) + if err != nil { + return nil, err + } + removed[table] = count + } + + return removed, nil +} + +func removeScopeFromTable(ctx context.Context, table, scopeID string) (int64, error) { + result := ctx.DB().Table(table). + Where("__scope @> ARRAY[?]::uuid[]", scopeID). + UpdateColumn("__scope", gorm.Expr("array_remove(__scope, ?)", scopeID)) + if result.Error != nil { + return 0, result.Error + } + return result.RowsAffected, nil +} + +func rlsScopeTables() []string { + return []string{"config_items", "components", "canaries", "playbooks", "views"} +} + +func isWildcardScopeSelector(selector *v1.ScopeResourceSelector) bool { + if selector == nil { + return false + } + + return selector.Name == "*" && + selector.Namespace == "" && + selector.Agent == "" && + selector.TagSelector == "" +} + +func hasWildcardSelector(selectors []types.ResourceSelector) bool { + for _, selector := range selectors { + if selector.Wildcard() { + return true + } + } + return false +} + +func hasWildcardViewRef(selectors []dutyRBAC.ViewRef) bool { + for _, selector := range selectors { + if selector.Name == "*" && selector.Namespace == "" && selector.ID == "" { + return true + } + } + return false +} + +func convertScopeResourceSelector(selector *v1.ScopeResourceSelector) types.ResourceSelector { + return types.ResourceSelector{ + Agent: selector.Agent, + Name: selector.Name, + Namespace: selector.Namespace, + TagSelector: selector.TagSelector, + } +} diff --git a/tests/permissions/permissions_test.go b/tests/permissions/permissions_test.go index 7a090044a..0f12328a5 100644 --- a/tests/permissions/permissions_test.go +++ b/tests/permissions/permissions_test.go @@ -23,6 +23,7 @@ import ( v1 "github.com/flanksource/incident-commander/api/v1" "github.com/flanksource/incident-commander/auth" "github.com/flanksource/incident-commander/db" + "github.com/flanksource/incident-commander/permission" "github.com/flanksource/incident-commander/rbac/adapter" ) @@ -65,6 +66,8 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { // That's why they are created here in the test setup. directPermissions = createDirectPermissions(guestUserDirectPerms.ID.String()) + materializeScopesAndPermissions() + var permissions []models.Permission err = DefaultContext.DB().Where("deleted_at IS NULL").Find(&permissions).Error Expect(err).ToNot(HaveOccurred()) @@ -103,29 +106,16 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(payload).ToNot(BeNil()) Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users with scopes") - Expect(payload.Config).To(HaveLen(3), "should have three config scopes") - Expect(payload.Config).To(ContainElements([]rls.Scope{ - {Tags: map[string]string{"namespace": "missioncontrol"}}, - {Tags: map[string]string{"namespace": "monitoring"}}, - {Tags: map[string]string{"namespace": "media"}}, - })) - - // Playbook scopes should include echo-config and restart-pod - Expect(payload.Playbook).To(HaveLen(2), "should have two playbook scopes") - Expect(payload.Playbook).To(ContainElements([]rls.Scope{ - {Names: []string{"echo-config"}}, - {Names: []string{"restart-pod"}}, + Expect(payload.Scopes).To(HaveLen(6), "should have six scopes (3 scope refs + 3 direct selectors)") + Expect(payload.Scopes).To(ContainElements([]uuid.UUID{ + uuid.MustParse("db80e7f5-b6af-4896-8431-8d6e5430c6f2"), // missioncontrol-configs scope + uuid.MustParse("eb5a2647-f377-4fd9-83fd-07020e761740"), // monitoring-configs scope + uuid.MustParse("f1e2d3c4-b5a6-4978-8abc-def012345678"), // restart-pod-playbook scope + uuid.MustParse("287937c8-c012-409d-834c-8a7a0357164e"), // guest-media-configs permission + uuid.MustParse("a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"), // guest-playbook-read permission + uuid.MustParse("38c3d4e5-f6a7-4b8c-9d0e-4f5a6b7c8d9e"), // guest-view-pods-read permission })) - - // View scopes should be included if user has view permissions - Expect(payload.View).To(HaveLen(1), "should have one view scope for pods view") - Expect(payload.View).To(ContainElement(rls.Scope{ - Names: []string{"pods"}, - })) - - // Other resource types should be empty - Expect(payload.Component).To(BeEmpty(), "component scope should be empty") - Expect(payload.Canary).To(BeEmpty(), "canary scope should be empty") + Expect(payload.WildcardScopes).To(BeEmpty(), "no wildcard scopes expected") }) It("should return RLS payload for guest user with multi-target scope", func() { @@ -135,24 +125,10 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(err).ToNot(HaveOccurred()) Expect(payload).ToNot(BeNil()) - // Verify exact match of entire payload - user should have access to ONLY these resources - expectedPayload := &rls.Payload{ - Disable: false, - Config: []rls.Scope{ - {Tags: map[string]string{"namespace": "database"}}, - }, - Playbook: []rls.Scope{ - {Names: []string{"echo-config"}}, - }, - View: []rls.Scope{ - {Names: []string{"metrics"}}, - }, - Scopes: []string{"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, - Component: nil, - Canary: nil, - } - - Expect(payload).To(Equal(expectedPayload), "RLS payload should match exactly - user should only see database configs, echo-config playbook, and metrics view") + Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users with scopes") + Expect(payload.Scopes).To(HaveLen(1)) + Expect(payload.Scopes).To(ContainElement(uuid.MustParse("6b7c8d9e-0f1a-4b2c-9d3e-4f5a6b7c8d9e"))) + Expect(payload.WildcardScopes).To(BeEmpty(), "no wildcard scopes expected") }) It("should return RLS payload for guest user with agent-based scope", func() { @@ -162,19 +138,10 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(err).ToNot(HaveOccurred()) Expect(payload).ToNot(BeNil()) - // Verify exact match of entire payload - user should have access to ONLY homelab agent configs - expectedPayload := &rls.Payload{ - Disable: false, - Config: []rls.Scope{ - {Agents: []string{dummy.HomelabAgent.ID.String()}}, - }, - Scopes: []string{"3c4e5f6a-7b8c-4d9e-0f1a-2b3c4d5e6f7a"}, - Playbook: nil, - Component: nil, - Canary: nil, - } - - Expect(payload).To(Equal(expectedPayload), "RLS payload should match exactly - user should only see homelab agent configs") + Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users with scopes") + Expect(payload.Scopes).To(HaveLen(1)) + Expect(payload.Scopes).To(ContainElement(uuid.MustParse("3c4e5f6a-7b8c-4d9e-0f1a-2b3c4d5e6f7a"))) + Expect(payload.WildcardScopes).To(BeEmpty(), "no wildcard scopes expected") }) It("should return RLS payload for wildcard manager with full wildcard scope", func() { @@ -184,19 +151,10 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(err).ToNot(HaveOccurred()) Expect(payload).ToNot(BeNil()) - // Verify exact match of entire payload - user should have access to ALL configs via "*" - expectedPayload := &rls.Payload{ - Disable: false, - Config: []rls.Scope{ - {Names: []string{"*"}}, - }, - Scopes: []string{"f1e2d3c4-b5a6-4c7d-8e9f-0a1b2c3d4e5f"}, - Playbook: nil, - Component: nil, - Canary: nil, - } - - Expect(payload).To(Equal(expectedPayload), "RLS payload should match exactly - user should see all configs via wildcard '*'") + Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users with scopes") + Expect(payload.Scopes).To(HaveLen(1)) + Expect(payload.Scopes).To(ContainElement(uuid.MustParse("f1e2d3c4-b5a6-4c7d-8e9f-0a1b2c3d4e5f"))) + Expect(payload.WildcardScopes).To(ConsistOf(rls.WildcardResourceScopeConfig)) }) It("should return RLS payload for homelab default manager with combined agent+tag scope", func() { @@ -206,22 +164,10 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(err).ToNot(HaveOccurred()) Expect(payload).ToNot(BeNil()) - // Verify exact match of entire payload - user should have access to homelab agent configs in default namespace - expectedPayload := &rls.Payload{ - Disable: false, - Config: []rls.Scope{ - { - Agents: []string{dummy.HomelabAgent.ID.String()}, - Tags: map[string]string{"namespace": "default"}, - }, - }, - Playbook: nil, - Component: nil, - Canary: nil, - Scopes: []string{"7a8b9c0d-1e2f-4a3b-5c6d-7e8f9a0b1c2d"}, - } - - Expect(payload).To(Equal(expectedPayload), "RLS payload should match exactly - user should see homelab agent configs in default namespace") + Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users with scopes") + Expect(payload.Scopes).To(HaveLen(1)) + Expect(payload.Scopes).To(ContainElement(uuid.MustParse("7a8b9c0d-1e2f-4a3b-5c6d-7e8f9a0b1c2d"))) + Expect(payload.WildcardScopes).To(BeEmpty(), "no wildcard scopes expected") }) It("should return RLS payload for multi-scope user with multiple scopes (OR behavior)", func() { @@ -233,20 +179,13 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { // Verify the payload contains all three scopes (not merged/AND'ed) Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users") - Expect(payload.Config).To(HaveLen(3), "should have three separate config scopes") - - // Verify all three scopes are present - Expect(payload.Config).To(ContainElements([]rls.Scope{ - {Tags: map[string]string{"namespace": "missioncontrol"}}, - {Tags: map[string]string{"namespace": "monitoring"}}, - {Agents: []string{dummy.HomelabAgent.ID.String()}}, - })) - - // Other resource types should be empty - Expect(payload.Playbook).To(BeEmpty(), "playbook scope should be empty") - Expect(payload.Component).To(BeEmpty(), "component scope should be empty") - Expect(payload.Canary).To(BeEmpty(), "canary scope should be empty") - Expect(payload.View).To(BeEmpty(), "view scope should be empty") + Expect(payload.Scopes).To(ContainElements( + uuid.MustParse("db80e7f5-b6af-4896-8431-8d6e5430c6f2"), + uuid.MustParse("eb5a2647-f377-4fd9-83fd-07020e761740"), + uuid.MustParse("3c4e5f6a-7b8c-4d9e-0f1a-2b3c4d5e6f7a"), + )) + Expect(payload.Scopes).To(HaveLen(3)) + Expect(payload.WildcardScopes).To(BeEmpty(), "no wildcard scopes expected") }) It("should disable RLS for non-guest users", func() { @@ -271,11 +210,8 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users even without permissions") // All resource scopes should be empty since user has no permissions - Expect(payload.Config).To(BeEmpty(), "config scope should be empty for guest user with no permissions") - Expect(payload.Component).To(BeEmpty(), "component scope should be empty for guest user with no permissions") - Expect(payload.Playbook).To(BeEmpty(), "playbook scope should be empty for guest user with no permissions") - Expect(payload.Canary).To(BeEmpty(), "canary scope should be empty for guest user with no permissions") - Expect(payload.View).To(BeEmpty(), "view scope should be empty for guest user with no permissions") + Expect(payload.Scopes).To(BeEmpty(), "scopes should be empty for guest user with no permissions") + Expect(payload.WildcardScopes).To(BeEmpty(), "wildcard scopes should be empty for guest user with no permissions") }) It("should include direct ID-based permissions in RLS payload", func() { @@ -286,36 +222,10 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(payload).ToNot(BeNil()) Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users") - - // Verify playbook scope includes the direct playbook ID - var hasPlaybookID bool - for _, scope := range payload.Playbook { - if scope.ID == dummy.EchoConfig.ID.String() { - hasPlaybookID = true - break - } - } - Expect(hasPlaybookID).To(BeTrue(), "playbook scope should include direct playbook ID") - - // Verify canary scope includes the direct canary ID - var hasCanaryID bool - for _, scope := range payload.Canary { - if scope.ID == dummy.LogisticsAPICanary.ID.String() { - hasCanaryID = true - break - } - } - Expect(hasCanaryID).To(BeTrue(), "canary scope should include direct canary ID") - - // Verify component scope includes the direct component ID - var hasComponentID bool - for _, scope := range payload.Component { - if scope.ID == dummy.Logistics.ID.String() { - hasComponentID = true - break - } - } - Expect(hasComponentID).To(BeTrue(), "component scope should include direct component ID") + expectedIDs := lo.Map(directPermissions, func(permission *models.Permission, _ int) uuid.UUID { + return permission.ID + }) + Expect(payload.Scopes).To(ContainElements(expectedIDs)) }) It("should return RLS payload for user with metrics view permission", func() { @@ -325,19 +235,10 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(err).ToNot(HaveOccurred()) Expect(payload).ToNot(BeNil()) - // Verify exact match of entire payload - user should have access to ONLY metrics view - expectedPayload := &rls.Payload{ - Disable: false, - Config: nil, - Playbook: nil, - Component: nil, - Canary: nil, - View: []rls.Scope{ - {Names: []string{"metrics"}}, - }, - } - - Expect(payload).To(Equal(expectedPayload), "RLS payload should match exactly - user should only see metrics view") + Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users with scopes") + Expect(payload.Scopes).To(HaveLen(1)) + Expect(payload.Scopes).To(ContainElement(uuid.MustParse("48d4e5f6-a7b8-4c9d-0e1f-5a6b7c8d9e0f"))) + Expect(payload.WildcardScopes).To(BeEmpty(), "no wildcard scopes expected") }) }) @@ -739,6 +640,8 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { ) BeforeAll(func() { + materializeScopesAndPermissions() + // Calculate expected counts from dummy data (without RLS) // Guest user: namespace in [missioncontrol, monitoring, media] DefaultContext.DB(). @@ -1217,6 +1120,32 @@ func loadPermissions() { } } +func materializeScopesAndPermissions() { + var scopes []models.Scope + Expect(DefaultContext.DB().Where("deleted_at IS NULL").Find(&scopes).Error).ToNot(HaveOccurred()) + for _, scope := range scopes { + jobRun, err := permission.GetProcessScopeJob(DefaultContext, permission.ScopeQueueSourceScope, scope.ID.String(), permission.ScopeQueueActionRebuild) + Expect(err).ToNot(HaveOccurred()) + + jobRun.Run() + if jobRun.LastJob != nil { + Expect(jobRun.LastJob.AsError()).ToNot(HaveOccurred()) + } + } + + var permissions []models.Permission + Expect(DefaultContext.DB().Where("deleted_at IS NULL").Find(&permissions).Error).ToNot(HaveOccurred()) + for _, perm := range permissions { + jobRun, err := permission.GetProcessScopeJob(DefaultContext, permission.ScopeQueueSourcePermission, perm.ID.String(), permission.ScopeQueueActionRebuild) + Expect(err).ToNot(HaveOccurred()) + + jobRun.Run() + if jobRun.LastJob != nil { + Expect(jobRun.LastJob.AsError()).ToNot(HaveOccurred()) + } + } +} + func createDirectPermissions(userID string) []*models.Permission { // Create direct ID-based permissions for guest user with direct permissions // These test permissions that use specific resource IDs instead of object_selector. diff --git a/tests/permissions/testdata/scopes/multi-target-scope.yaml b/tests/permissions/testdata/scopes/multi-target-scope.yaml index 893be684a..3de6b5b97 100644 --- a/tests/permissions/testdata/scopes/multi-target-scope.yaml +++ b/tests/permissions/testdata/scopes/multi-target-scope.yaml @@ -3,7 +3,7 @@ kind: Scope metadata: name: multi-target-scope namespace: default - uid: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d + uid: 6b7c8d9e-0f1a-4b2c-9d3e-4f5a6b7c8d9e spec: description: Scope with multiple targets - database configs, echo-config playbook, and metrics view targets: From e9c4aeab7198558c6cf4cbf893fdf5740c2e7bac Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Fri, 16 Jan 2026 16:02:37 +0545 Subject: [PATCH 3/4] ref: https://github.com/flanksource/duty/pull/1729 --- auth/rls.go | 205 ++++++++++++++++++++++++++++++---------------------- go.mod | 2 +- go.sum | 4 +- 3 files changed, 121 insertions(+), 90 deletions(-) diff --git a/auth/rls.go b/auth/rls.go index d42169fd6..15dceaffe 100644 --- a/auth/rls.go +++ b/auth/rls.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "fmt" + "sort" + "strings" "github.com/flanksource/commons/collections" "github.com/flanksource/commons/logger" @@ -13,6 +15,7 @@ import ( "github.com/flanksource/duty/rbac/policy" "github.com/flanksource/duty/rls" "github.com/flanksource/duty/types" + "github.com/google/uuid" "github.com/samber/lo" "go.opentelemetry.io/otel/trace" "gorm.io/gorm" @@ -106,23 +109,31 @@ func buildRLSPayloadFromScopes(ctx context.Context) (*rls.Payload, error) { return nil, fmt.Errorf("failed to query permissions: %w", err) } - payload := &rls.Payload{} + scopeIDs := map[uuid.UUID]struct{}{} + wildcards := map[rls.WildcardResourceScope]struct{}{} for _, perm := range permissions { - if perm.ConfigID != nil { - payload.Config = append(payload.Config, rls.Scope{ID: perm.ConfigID.String()}) + if !collections.MatchItems(policy.ActionRead, strings.Split(perm.Action, ",")...) { + continue } - if perm.ComponentID != nil { - payload.Component = append(payload.Component, rls.Scope{ID: perm.ComponentID.String()}) - } + permScopeID := perm.ID - if perm.PlaybookID != nil { - payload.Playbook = append(payload.Playbook, rls.Scope{ID: perm.PlaybookID.String()}) + if perm.ConfigID != nil || perm.ComponentID != nil || perm.PlaybookID != nil || perm.CanaryID != nil { + scopeIDs[permScopeID] = struct{}{} } - if perm.CanaryID != nil { - payload.Canary = append(payload.Canary, rls.Scope{ID: perm.CanaryID.String()}) + switch perm.Object { + case policy.ObjectCatalog: + wildcards[rls.WildcardResourceScopeConfig] = struct{}{} + case policy.ObjectTopology: + wildcards[rls.WildcardResourceScopeComponent] = struct{}{} + case policy.ObjectCanary: + wildcards[rls.WildcardResourceScopeCanary] = struct{}{} + case policy.ObjectPlaybooks: + wildcards[rls.WildcardResourceScopePlaybook] = struct{}{} + case policy.ObjectViews: + wildcards[rls.WildcardResourceScopeView] = struct{}{} } if len(perm.ObjectSelector) == 0 { @@ -137,34 +148,41 @@ func buildRLSPayloadFromScopes(ctx context.Context) (*rls.Payload, error) { // Process scope references (indirect permissions) if len(selectors.Scopes) > 0 { - if err := processScopeRefs(ctx, selectors.Scopes, payload); err != nil { + if err := processScopeRefs(ctx, selectors.Scopes, scopeIDs, wildcards); err != nil { return nil, err } } // Process direct resource selectors (configs, components, playbooks, etc.) - // Only use tags, name, and agent_id as per requirements if len(selectors.Configs) > 0 { - for _, selector := range selectors.Configs { - payload.Config = append(payload.Config, convertResourceSelectorToRLSScope(selector)) + if hasWildcardSelector(selectors.Configs) { + wildcards[rls.WildcardResourceScopeConfig] = struct{}{} + } else { + scopeIDs[permScopeID] = struct{}{} } } if len(selectors.Components) > 0 { - for _, selector := range selectors.Components { - payload.Component = append(payload.Component, convertResourceSelectorToRLSScope(selector)) + if hasWildcardSelector(selectors.Components) { + wildcards[rls.WildcardResourceScopeComponent] = struct{}{} + } else { + scopeIDs[permScopeID] = struct{}{} } } if len(selectors.Playbooks) > 0 { - for _, selector := range selectors.Playbooks { - payload.Playbook = append(payload.Playbook, convertResourceSelectorToRLSScope(selector)) + if hasWildcardSelector(selectors.Playbooks) { + wildcards[rls.WildcardResourceScopePlaybook] = struct{}{} + } else { + scopeIDs[permScopeID] = struct{}{} } } if len(selectors.Views) > 0 { - for _, viewRef := range selectors.Views { - payload.View = append(payload.View, convertViewScopeRefToRLSScope(viewRef)) + if hasWildcardViewRef(selectors.Views) { + wildcards[rls.WildcardResourceScopeView] = struct{}{} + } else { + scopeIDs[permScopeID] = struct{}{} } } @@ -176,11 +194,16 @@ func buildRLSPayloadFromScopes(ctx context.Context) (*rls.Payload, error) { // } } + payload := &rls.Payload{ + Scopes: setToSortedUUIDSlice(scopeIDs), + WildcardScopes: setToSortedWildcardSlice(wildcards), + } + return payload, nil } -// processScopeRefs fetches scopes from database and adds their targets to the payload -func processScopeRefs(ctx context.Context, scopeRefs []dutyRBAC.NamespacedNameIDSelector, payload *rls.Payload) error { +// processScopeRefs fetches scopes from database and adds their IDs and wildcard types +func processScopeRefs(ctx context.Context, scopeRefs []dutyRBAC.NamespacedNameIDSelector, scopeIDs map[uuid.UUID]struct{}, wildcards map[rls.WildcardResourceScope]struct{}) error { for _, ref := range scopeRefs { var scope models.Scope err := ctx.DB(). @@ -194,8 +217,8 @@ func processScopeRefs(ctx context.Context, scopeRefs []dutyRBAC.NamespacedNameID return fmt.Errorf("failed to query scope %s/%s: %w", ref.Namespace, ref.Name, err) } - // Add scope UUID for view row-level grants - payload.Scopes = append(payload.Scopes, scope.ID.String()) + // Always include scope UUID for view row-level grants + scopeIDs[scope.ID] = struct{}{} var targets []v1.ScopeTarget if err := json.Unmarshal([]byte(scope.Targets), &targets); err != nil { @@ -204,33 +227,35 @@ func processScopeRefs(ctx context.Context, scopeRefs []dutyRBAC.NamespacedNameID } for _, target := range targets { - if target.Config != nil { - rlsScope := convertToRLSScope(target.Config) - payload.Config = append(payload.Config, rlsScope) - } - if target.Component != nil { - rlsScope := convertToRLSScope(target.Component) - payload.Component = append(payload.Component, rlsScope) - } - if target.Playbook != nil { - rlsScope := convertToRLSScope(target.Playbook) - payload.Playbook = append(payload.Playbook, rlsScope) - } - if target.Canary != nil { - rlsScope := convertToRLSScope(target.Canary) - payload.Canary = append(payload.Canary, rlsScope) - } - if target.View != nil { - rlsScope := convertToRLSScope(target.View) - payload.View = append(payload.View, rlsScope) - } - if target.Global != nil { - rlsScope := convertToRLSScope(target.Global) - payload.Config = append(payload.Config, rlsScope) - payload.Component = append(payload.Component, rlsScope) - payload.Playbook = append(payload.Playbook, rlsScope) - payload.Canary = append(payload.Canary, rlsScope) - payload.View = append(payload.View, rlsScope) + switch { + case target.Config != nil: + if isWildcardScopeSelector(target.Config) { + wildcards[rls.WildcardResourceScopeConfig] = struct{}{} + } + case target.Component != nil: + if isWildcardScopeSelector(target.Component) { + wildcards[rls.WildcardResourceScopeComponent] = struct{}{} + } + case target.Playbook != nil: + if isWildcardScopeSelector(target.Playbook) { + wildcards[rls.WildcardResourceScopePlaybook] = struct{}{} + } + case target.Canary != nil: + if isWildcardScopeSelector(target.Canary) { + wildcards[rls.WildcardResourceScopeCanary] = struct{}{} + } + case target.View != nil: + if isWildcardScopeSelector(target.View) { + wildcards[rls.WildcardResourceScopeView] = struct{}{} + } + case target.Global != nil: + if isWildcardScopeSelector(target.Global) { + wildcards[rls.WildcardResourceScopeConfig] = struct{}{} + wildcards[rls.WildcardResourceScopeComponent] = struct{}{} + wildcards[rls.WildcardResourceScopePlaybook] = struct{}{} + wildcards[rls.WildcardResourceScopeCanary] = struct{}{} + wildcards[rls.WildcardResourceScopeView] = struct{}{} + } } } } @@ -238,59 +263,65 @@ func processScopeRefs(ctx context.Context, scopeRefs []dutyRBAC.NamespacedNameID return nil } -func convertToRLSScope(selector *v1.ScopeResourceSelector) rls.Scope { - rlsScope := rls.Scope{} - - if selector.Agent != "" { - rlsScope.Agents = []string{selector.Agent} +func isWildcardScopeSelector(selector *v1.ScopeResourceSelector) bool { + if selector == nil { + return false } - if selector.Name != "" { - rlsScope.Names = []string{selector.Name} - } + return selector.Name == "*" && + selector.Namespace == "" && + selector.Agent == "" && + selector.TagSelector == "" +} - if selector.TagSelector != "" { - rlsScope.Tags = collections.SelectorToMap(selector.TagSelector) +func hasWildcardSelector(selectors []types.ResourceSelector) bool { + for _, selector := range selectors { + if selector.Wildcard() { + return true + } } - - return rlsScope + return false } -// convertResourceSelectorToRLSScope converts a types.ResourceSelector to rls.Scope -// Only uses tags, name, and agent_id. -func convertResourceSelectorToRLSScope(selector types.ResourceSelector) rls.Scope { - rlsScope := rls.Scope{} - - if selector.Agent != "" { - rlsScope.Agents = []string{selector.Agent} +func hasWildcardViewRef(selectors []dutyRBAC.ViewRef) bool { + for _, selector := range selectors { + if selector.Name == "*" && selector.Namespace == "" && selector.ID == "" { + return true + } } + return false +} - if selector.Name != "" { - rlsScope.Names = []string{selector.Name} +func setToSortedUUIDSlice(set map[uuid.UUID]struct{}) []uuid.UUID { + if len(set) == 0 { + return nil } - if selector.TagSelector != "" { - rlsScope.Tags = collections.SelectorToMap(selector.TagSelector) + out := make([]uuid.UUID, 0, len(set)) + for val := range set { + out = append(out, val) } - return rlsScope -} + sort.Slice(out, func(i, j int) bool { + return out[i].String() < out[j].String() + }) -// convertViewScopeRefToRLSScope converts a view ViewRef (namespace/name) to rls.Scope -// Views only support id and name in match_scope (namespace is not supported) -func convertViewScopeRefToRLSScope(viewRef dutyRBAC.ViewRef) rls.Scope { - rlsScope := rls.Scope{} + return out +} - if viewRef.Name != "" { - rlsScope.Names = []string{viewRef.Name} +func setToSortedWildcardSlice(set map[rls.WildcardResourceScope]struct{}) []rls.WildcardResourceScope { + if len(set) == 0 { + return nil } - if viewRef.ID != "" { - rlsScope.ID = viewRef.ID + out := make([]rls.WildcardResourceScope, 0, len(set)) + for val := range set { + out = append(out, val) } - // Note: namespace is not supported by match_scope for views - // ID would be set if we have a direct ID reference, but ViewRef doesn't have ID field + sort.Slice(out, func(i, j int) bool { + return string(out[i]) < string(out[j]) + }) - return rlsScope + return out } diff --git a/go.mod b/go.mod index 182887c7a..fd6bac114 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/containrrr/shoutrrr v0.8.0 github.com/fergusstrange/embedded-postgres v1.32.0 // indirect github.com/flanksource/commons v1.44.0 - github.com/flanksource/duty v1.0.1159 + github.com/flanksource/duty v1.0.1157-0.20260114123018-b5680731a586 // temporary from https://github.com/flanksource/duty/pull/1729 github.com/flanksource/gomplate/v3 v3.24.66 github.com/flanksource/kopper v1.0.14 github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a diff --git a/go.sum b/go.sum index 7926b7171..c0f35f087 100644 --- a/go.sum +++ b/go.sum @@ -385,8 +385,8 @@ github.com/flanksource/commons v1.44.0 h1:u+4fARnBEpes/s9nM+Ll0vRl6grU7mjYY/1oYf github.com/flanksource/commons v1.44.0/go.mod h1:xCvBGp3f9N/Y4Iab9lgzjsN90zFiHvlaOgWtfH321Pg= github.com/flanksource/deps v1.0.20 h1:HHNSBxrtHgEuKuxXO5TPCI8KYlmIVjyCRzAMXxlPzAU= github.com/flanksource/deps v1.0.20/go.mod h1:74gGDlS75O9jAIAlKrKd+qSvY6sO5KBa5Y1VT9aYTPo= -github.com/flanksource/duty v1.0.1159 h1:cTOSe6uJw+e+ISE05QmvqZ8/vxitB3UxaNzV26Tdg8E= -github.com/flanksource/duty v1.0.1159/go.mod h1:OXLsjmkDrJaMjpPNYYbfAuoqVNZLoIeYn6JgclDGF1A= +github.com/flanksource/duty v1.0.1157-0.20260114123018-b5680731a586 h1:fv+iP36hS4g+9Wy6uAJvXU5kPO3opFpwEpjg8XLeq4E= +github.com/flanksource/duty v1.0.1157-0.20260114123018-b5680731a586/go.mod h1:pi6/7DTnpOWgsrNE+fYhOSTVFZJ789knV/H5XeJZkqA= github.com/flanksource/gomplate/v3 v3.24.66 h1:fTaN0s9t+YZCau+KlgcLn9pMcLTsSiMjBnZUbhGY/oY= github.com/flanksource/gomplate/v3 v3.24.66/go.mod h1:PiYJOAk971BpG/suhFP9YAZSjfz4KiRaqwYlQZZJp0Q= github.com/flanksource/is-healthy v1.0.82 h1:/hjq2hYWVph2Cr6F6qF4v/vTuEqOqgPVnZOh1kfDwvg= From 0f32be7e4de692adfc61a94159cd5eec8f3f3150 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Fri, 16 Jan 2026 00:09:03 +0545 Subject: [PATCH 4/4] materialize wildcard scopes and set rls bypass role --- api/v1/permission_types.go | 49 +-------- auth/rls.go | 153 +++++--------------------- auth/tokens.go | 16 ++- db/permissions.go | 13 +-- permission/rls_scopes.go | 94 ++-------------- tests/permissions/permissions_test.go | 9 -- 6 files changed, 54 insertions(+), 280 deletions(-) diff --git a/api/v1/permission_types.go b/api/v1/permission_types.go index cca51eb0a..2da8a9d97 100644 --- a/api/v1/permission_types.go +++ b/api/v1/permission_types.go @@ -8,8 +8,6 @@ import ( "github.com/flanksource/duty/context" "github.com/flanksource/duty/models" dutyRBAC "github.com/flanksource/duty/rbac" - "github.com/flanksource/duty/rbac/policy" - "github.com/flanksource/duty/types" "github.com/google/uuid" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -146,52 +144,9 @@ type PermissionObject struct { Scopes []dutyRBAC.NamespacedNameIDSelector `json:"scopes,omitempty"` } -// GlobalObject checks if the object selector semantically maps to a global object -// and returns the corresponding global object if applicable. -// For example: -// -// configs: -// - name: '*' -// -// is interpreted as the object: catalog. +// GlobalObject is deprecated and always returns false. func (t *PermissionObject) GlobalObject() (string, bool) { - switch { - case t.isWildcardOnly(t.Playbooks, t.Configs, t.Components, t.Connections) && len(t.Views) == 0: - return policy.ObjectPlaybooks, true - case t.isWildcardOnly(t.Configs, t.Playbooks, t.Components, t.Connections) && len(t.Views) == 0: - return policy.ObjectCatalog, true - case t.isWildcardOnly(t.Components, t.Playbooks, t.Configs, t.Connections) && len(t.Views) == 0: - return policy.ObjectTopology, true - case t.isWildcardOnly(t.Connections, t.Playbooks, t.Configs, t.Components) && len(t.Views) == 0: - return policy.ObjectConnection, true - case t.isViewWildcardOnly(): - return policy.ObjectViews, true - default: - return "", false - } -} - -func (t *PermissionObject) isWildcardOnly(primary []types.ResourceSelector, others ...[]types.ResourceSelector) bool { - for _, other := range others { - if len(other) != 0 { - return false - } - } - - return len(primary) == 1 && primary[0].Wildcard() -} - -// isViewWildcardOnly checks if the permission object has only a wildcard view selector -// and no other resource selectors -func (t *PermissionObject) isViewWildcardOnly() bool { - // Check that all other selectors are empty - if len(t.Configs) != 0 || len(t.Components) != 0 || - len(t.Playbooks) != 0 || len(t.Connections) != 0 { - return false - } - - // Check that we have exactly one view with wildcard name - return len(t.Views) == 1 && t.Views[0].Name == "*" + return "", false } // +kubebuilder:object:generate=true diff --git a/auth/rls.go b/auth/rls.go index 15dceaffe..6318d87f2 100644 --- a/auth/rls.go +++ b/auth/rls.go @@ -9,13 +9,14 @@ import ( "github.com/flanksource/commons/collections" "github.com/flanksource/commons/logger" + dutyAPI "github.com/flanksource/duty/api" "github.com/flanksource/duty/context" "github.com/flanksource/duty/models" dutyRBAC "github.com/flanksource/duty/rbac" "github.com/flanksource/duty/rbac/policy" "github.com/flanksource/duty/rls" - "github.com/flanksource/duty/types" "github.com/google/uuid" + "github.com/lib/pq" "github.com/samber/lo" "go.opentelemetry.io/otel/trace" "gorm.io/gorm" @@ -75,11 +76,26 @@ func WithRLS(ctx context.Context, fn func(context.Context) error) error { } if rlsPayload.Disable { - return fn(ctx) + return ctx.Transaction(func(txCtx context.Context, _ trace.Span) error { + role := dutyAPI.DefaultConfig.Postgrest.DBRoleBypass + if role == "" { + role = dutyAPI.DefaultConfig.Postgrest.DBRole + if role != "" { + txCtx.Logger.Warnf("RLS bypass role not configured, using role=%s", role) + } + } + if role == "" { + return fmt.Errorf("role is required") + } + if err := txCtx.DB().Exec(fmt.Sprintf("SET LOCAL ROLE %s", pq.QuoteIdentifier(role))).Error; err != nil { + return err + } + return fn(txCtx) + }) } return ctx.Transaction(func(txCtx context.Context, _ trace.Span) error { - if err := rlsPayload.SetPostgresSessionRLS(txCtx.DB()); err != nil { + if err := rlsPayload.SetPostgresSessionRLSWithRole(txCtx.DB(), dutyAPI.DefaultConfig.Postgrest.DBRole); err != nil { return err } @@ -110,7 +126,6 @@ func buildRLSPayloadFromScopes(ctx context.Context) (*rls.Payload, error) { } scopeIDs := map[uuid.UUID]struct{}{} - wildcards := map[rls.WildcardResourceScope]struct{}{} for _, perm := range permissions { if !collections.MatchItems(policy.ActionRead, strings.Split(perm.Action, ",")...) { @@ -123,19 +138,6 @@ func buildRLSPayloadFromScopes(ctx context.Context) (*rls.Payload, error) { scopeIDs[permScopeID] = struct{}{} } - switch perm.Object { - case policy.ObjectCatalog: - wildcards[rls.WildcardResourceScopeConfig] = struct{}{} - case policy.ObjectTopology: - wildcards[rls.WildcardResourceScopeComponent] = struct{}{} - case policy.ObjectCanary: - wildcards[rls.WildcardResourceScopeCanary] = struct{}{} - case policy.ObjectPlaybooks: - wildcards[rls.WildcardResourceScopePlaybook] = struct{}{} - case policy.ObjectViews: - wildcards[rls.WildcardResourceScopeView] = struct{}{} - } - if len(perm.ObjectSelector) == 0 { continue } @@ -148,42 +150,26 @@ func buildRLSPayloadFromScopes(ctx context.Context) (*rls.Payload, error) { // Process scope references (indirect permissions) if len(selectors.Scopes) > 0 { - if err := processScopeRefs(ctx, selectors.Scopes, scopeIDs, wildcards); err != nil { + if err := processScopeRefs(ctx, selectors.Scopes, scopeIDs); err != nil { return nil, err } } // Process direct resource selectors (configs, components, playbooks, etc.) if len(selectors.Configs) > 0 { - if hasWildcardSelector(selectors.Configs) { - wildcards[rls.WildcardResourceScopeConfig] = struct{}{} - } else { - scopeIDs[permScopeID] = struct{}{} - } + scopeIDs[permScopeID] = struct{}{} } if len(selectors.Components) > 0 { - if hasWildcardSelector(selectors.Components) { - wildcards[rls.WildcardResourceScopeComponent] = struct{}{} - } else { - scopeIDs[permScopeID] = struct{}{} - } + scopeIDs[permScopeID] = struct{}{} } if len(selectors.Playbooks) > 0 { - if hasWildcardSelector(selectors.Playbooks) { - wildcards[rls.WildcardResourceScopePlaybook] = struct{}{} - } else { - scopeIDs[permScopeID] = struct{}{} - } + scopeIDs[permScopeID] = struct{}{} } if len(selectors.Views) > 0 { - if hasWildcardViewRef(selectors.Views) { - wildcards[rls.WildcardResourceScopeView] = struct{}{} - } else { - scopeIDs[permScopeID] = struct{}{} - } + scopeIDs[permScopeID] = struct{}{} } // TODO: No RLS support for connections yet! @@ -195,15 +181,14 @@ func buildRLSPayloadFromScopes(ctx context.Context) (*rls.Payload, error) { } payload := &rls.Payload{ - Scopes: setToSortedUUIDSlice(scopeIDs), - WildcardScopes: setToSortedWildcardSlice(wildcards), + Scopes: setToSortedUUIDSlice(scopeIDs), } return payload, nil } -// processScopeRefs fetches scopes from database and adds their IDs and wildcard types -func processScopeRefs(ctx context.Context, scopeRefs []dutyRBAC.NamespacedNameIDSelector, scopeIDs map[uuid.UUID]struct{}, wildcards map[rls.WildcardResourceScope]struct{}) error { +// processScopeRefs fetches scopes from database and adds their IDs +func processScopeRefs(ctx context.Context, scopeRefs []dutyRBAC.NamespacedNameIDSelector, scopeIDs map[uuid.UUID]struct{}) error { for _, ref := range scopeRefs { var scope models.Scope err := ctx.DB(). @@ -220,78 +205,11 @@ func processScopeRefs(ctx context.Context, scopeRefs []dutyRBAC.NamespacedNameID // Always include scope UUID for view row-level grants scopeIDs[scope.ID] = struct{}{} - var targets []v1.ScopeTarget - if err := json.Unmarshal([]byte(scope.Targets), &targets); err != nil { - ctx.Warnf("failed to unmarshal targets for scope %s: %v", scope.ID, err) - continue - } - - for _, target := range targets { - switch { - case target.Config != nil: - if isWildcardScopeSelector(target.Config) { - wildcards[rls.WildcardResourceScopeConfig] = struct{}{} - } - case target.Component != nil: - if isWildcardScopeSelector(target.Component) { - wildcards[rls.WildcardResourceScopeComponent] = struct{}{} - } - case target.Playbook != nil: - if isWildcardScopeSelector(target.Playbook) { - wildcards[rls.WildcardResourceScopePlaybook] = struct{}{} - } - case target.Canary != nil: - if isWildcardScopeSelector(target.Canary) { - wildcards[rls.WildcardResourceScopeCanary] = struct{}{} - } - case target.View != nil: - if isWildcardScopeSelector(target.View) { - wildcards[rls.WildcardResourceScopeView] = struct{}{} - } - case target.Global != nil: - if isWildcardScopeSelector(target.Global) { - wildcards[rls.WildcardResourceScopeConfig] = struct{}{} - wildcards[rls.WildcardResourceScopeComponent] = struct{}{} - wildcards[rls.WildcardResourceScopePlaybook] = struct{}{} - wildcards[rls.WildcardResourceScopeCanary] = struct{}{} - wildcards[rls.WildcardResourceScopeView] = struct{}{} - } - } - } } return nil } -func isWildcardScopeSelector(selector *v1.ScopeResourceSelector) bool { - if selector == nil { - return false - } - - return selector.Name == "*" && - selector.Namespace == "" && - selector.Agent == "" && - selector.TagSelector == "" -} - -func hasWildcardSelector(selectors []types.ResourceSelector) bool { - for _, selector := range selectors { - if selector.Wildcard() { - return true - } - } - return false -} - -func hasWildcardViewRef(selectors []dutyRBAC.ViewRef) bool { - for _, selector := range selectors { - if selector.Name == "*" && selector.Namespace == "" && selector.ID == "" { - return true - } - } - return false -} - func setToSortedUUIDSlice(set map[uuid.UUID]struct{}) []uuid.UUID { if len(set) == 0 { return nil @@ -308,20 +226,3 @@ func setToSortedUUIDSlice(set map[uuid.UUID]struct{}) []uuid.UUID { return out } - -func setToSortedWildcardSlice(set map[rls.WildcardResourceScope]struct{}) []rls.WildcardResourceScope { - if len(set) == 0 { - return nil - } - - out := make([]rls.WildcardResourceScope, 0, len(set)) - for val := range set { - out = append(out, val) - } - - sort.Slice(out, func(i, j int) bool { - return string(out[i]) < string(out[j]) - }) - - return out -} diff --git a/auth/tokens.go b/auth/tokens.go index 103d16974..94f1da4a4 100644 --- a/auth/tokens.go +++ b/auth/tokens.go @@ -71,16 +71,24 @@ func GetOrCreateJWTToken(ctx context.Context, user *models.Person, sessionId str return token.(string), nil } + rlsPayload, err := GetRLSPayload(ctx.WithUser(user)) + if err != nil { + return "", ctx.Oops().Wrap(err) + } + + role := config.Postgrest.DBRole + if rlsPayload.Disable && config.Postgrest.DBRoleBypass != "" { + role = config.Postgrest.DBRoleBypass + } + // Postgrest makes this jwt available as a session parameter inside postgres. // We inject the rls payload here and then access it inside postgres using request.jwt.claims parameter. claims := jwt.MapClaims{ - "role": config.Postgrest.DBRole, + "role": role, "id": user.ID.String(), } - if rlsPayload, err := GetRLSPayload(ctx.WithUser(user)); err != nil { - return "", ctx.Oops().Wrap(err) - } else if jwtClaim := rlsPayload.JWTClaims(); jwtClaim != nil { + if jwtClaim := rlsPayload.JWTClaims(); jwtClaim != nil { claims = collections.MergeMap(claims, jwtClaim) } diff --git a/db/permissions.go b/db/permissions.go index e66ac066f..634dd5183 100644 --- a/db/permissions.go +++ b/db/permissions.go @@ -44,16 +44,11 @@ func PersistPermissionFromCRD(ctx context.Context, obj *v1.Permission) error { Deny: obj.Spec.Deny, } - // Check if the object selectors semantically match a global object. - if globalObject, ok := obj.Spec.Object.GlobalObject(); ok { - p.Object = globalObject - } else { - selectors, err := json.Marshal(obj.Spec.Object) - if err != nil { - return fmt.Errorf("failed to marshal object: %w", err) - } - p.ObjectSelector = selectors + selectors, err := json.Marshal(obj.Spec.Object) + if err != nil { + return fmt.Errorf("failed to marshal object: %w", err) } + p.ObjectSelector = selectors return ctx.DB().Save(&p).Error } diff --git a/permission/rls_scopes.go b/permission/rls_scopes.go index cdf2e0bc7..6b35b1c3d 100644 --- a/permission/rls_scopes.go +++ b/permission/rls_scopes.go @@ -9,7 +9,6 @@ import ( "github.com/flanksource/duty/job" "github.com/flanksource/duty/models" "github.com/flanksource/duty/query" - dutyRBAC "github.com/flanksource/duty/rbac" "github.com/flanksource/duty/rbac/policy" "github.com/flanksource/duty/types" "github.com/google/uuid" @@ -28,11 +27,10 @@ const ( ) type ScopeMaterializationStats struct { - Tables map[string]int64 `json:"tables,omitempty"` - TotalRows int64 `json:"total_rows,omitempty"` - SelectorCount int `json:"selector_count,omitempty"` - IDCount int `json:"id_count,omitempty"` - WildcardsSkipped int `json:"wildcards_skipped,omitempty"` + Tables map[string]int64 `json:"tables,omitempty"` + TotalRows int64 `json:"total_rows,omitempty"` + SelectorCount int `json:"selector_count,omitempty"` + IDCount int `json:"id_count,omitempty"` } func (s *ScopeMaterializationStats) addTableCount(table string, count int64) { @@ -78,7 +76,6 @@ func GetProcessScopeJob(ctx context.Context, sourceType, sourceID, action string run.History.AddDetails("total_rows", processor.stats.TotalRows) run.History.AddDetails("selector_count", processor.stats.SelectorCount) run.History.AddDetails("id_count", processor.stats.IDCount) - run.History.AddDetails("wildcards_skipped", processor.stats.WildcardsSkipped) if processor.stats.TotalRows > 0 { run.History.SuccessCount = int(processor.stats.TotalRows) @@ -192,12 +189,6 @@ func applyScope(ctx context.Context, scopeID uuid.UUID, stats *ScopeMaterializat for _, target := range targets { switch { case target.Config != nil: - if isWildcardScopeSelector(target.Config) { - if stats != nil { - stats.WildcardsSkipped++ - } - continue - } if stats != nil { stats.SelectorCount++ } @@ -209,12 +200,6 @@ func applyScope(ctx context.Context, scopeID uuid.UUID, stats *ScopeMaterializat stats.addTableCount("config_items", count) } case target.Component != nil: - if isWildcardScopeSelector(target.Component) { - if stats != nil { - stats.WildcardsSkipped++ - } - continue - } if stats != nil { stats.SelectorCount++ } @@ -226,12 +211,6 @@ func applyScope(ctx context.Context, scopeID uuid.UUID, stats *ScopeMaterializat stats.addTableCount("components", count) } case target.Canary != nil: - if isWildcardScopeSelector(target.Canary) { - if stats != nil { - stats.WildcardsSkipped++ - } - continue - } if stats != nil { stats.SelectorCount++ } @@ -243,12 +222,6 @@ func applyScope(ctx context.Context, scopeID uuid.UUID, stats *ScopeMaterializat stats.addTableCount("canaries", count) } case target.Playbook != nil: - if isWildcardScopeSelector(target.Playbook) { - if stats != nil { - stats.WildcardsSkipped++ - } - continue - } if stats != nil { stats.SelectorCount++ } @@ -260,12 +233,6 @@ func applyScope(ctx context.Context, scopeID uuid.UUID, stats *ScopeMaterializat stats.addTableCount("playbooks", count) } case target.View != nil: - if isWildcardScopeSelector(target.View) { - if stats != nil { - stats.WildcardsSkipped++ - } - continue - } if stats != nil { stats.SelectorCount++ } @@ -277,12 +244,6 @@ func applyScope(ctx context.Context, scopeID uuid.UUID, stats *ScopeMaterializat stats.addTableCount("views", count) } case target.Global != nil: - if isWildcardScopeSelector(target.Global) { - if stats != nil { - stats.WildcardsSkipped++ - } - continue - } globalSelector := convertScopeResourceSelector(target.Global) for _, table := range rlsScopeTables() { if stats != nil { @@ -373,7 +334,7 @@ func applyPermissionScope(ctx context.Context, permissionID uuid.UUID, stats *Sc return ctx.Oops().Wrapf(err, "failed to unmarshal permission object_selector") } - if len(selectors.Configs) > 0 && !hasWildcardSelector(selectors.Configs) { + if len(selectors.Configs) > 0 { for _, selector := range selectors.Configs { if stats != nil { stats.SelectorCount++ @@ -386,10 +347,8 @@ func applyPermissionScope(ctx context.Context, permissionID uuid.UUID, stats *Sc stats.addTableCount("config_items", count) } } - } else if len(selectors.Configs) > 0 && stats != nil { - stats.WildcardsSkipped++ } - if len(selectors.Components) > 0 && !hasWildcardSelector(selectors.Components) { + if len(selectors.Components) > 0 { for _, selector := range selectors.Components { if stats != nil { stats.SelectorCount++ @@ -402,10 +361,8 @@ func applyPermissionScope(ctx context.Context, permissionID uuid.UUID, stats *Sc stats.addTableCount("components", count) } } - } else if len(selectors.Components) > 0 && stats != nil { - stats.WildcardsSkipped++ } - if len(selectors.Playbooks) > 0 && !hasWildcardSelector(selectors.Playbooks) { + if len(selectors.Playbooks) > 0 { for _, selector := range selectors.Playbooks { if stats != nil { stats.SelectorCount++ @@ -418,10 +375,8 @@ func applyPermissionScope(ctx context.Context, permissionID uuid.UUID, stats *Sc stats.addTableCount("playbooks", count) } } - } else if len(selectors.Playbooks) > 0 && stats != nil { - stats.WildcardsSkipped++ } - if len(selectors.Views) > 0 && !hasWildcardViewRef(selectors.Views) { + if len(selectors.Views) > 0 { for _, selector := range selectors.Views { viewSelector := types.ResourceSelector{ Name: selector.Name, @@ -438,15 +393,13 @@ func applyPermissionScope(ctx context.Context, permissionID uuid.UUID, stats *Sc stats.addTableCount("views", count) } } - } else if len(selectors.Views) > 0 && stats != nil { - stats.WildcardsSkipped++ } return nil } func applyScopeSelector(ctx context.Context, table string, selector types.ResourceSelector, scopeID string) (int64, error) { - if selector.IsEmpty() || selector.Wildcard() { + if selector.IsEmpty() { return 0, nil } @@ -526,35 +479,6 @@ func rlsScopeTables() []string { return []string{"config_items", "components", "canaries", "playbooks", "views"} } -func isWildcardScopeSelector(selector *v1.ScopeResourceSelector) bool { - if selector == nil { - return false - } - - return selector.Name == "*" && - selector.Namespace == "" && - selector.Agent == "" && - selector.TagSelector == "" -} - -func hasWildcardSelector(selectors []types.ResourceSelector) bool { - for _, selector := range selectors { - if selector.Wildcard() { - return true - } - } - return false -} - -func hasWildcardViewRef(selectors []dutyRBAC.ViewRef) bool { - for _, selector := range selectors { - if selector.Name == "*" && selector.Namespace == "" && selector.ID == "" { - return true - } - } - return false -} - func convertScopeResourceSelector(selector *v1.ScopeResourceSelector) types.ResourceSelector { return types.ResourceSelector{ Agent: selector.Agent, diff --git a/tests/permissions/permissions_test.go b/tests/permissions/permissions_test.go index 0f12328a5..5295aef1c 100644 --- a/tests/permissions/permissions_test.go +++ b/tests/permissions/permissions_test.go @@ -8,7 +8,6 @@ import ( "github.com/flanksource/duty/models" "github.com/flanksource/duty/rbac" "github.com/flanksource/duty/rbac/policy" - "github.com/flanksource/duty/rls" "github.com/flanksource/duty/tests/fixtures/dummy" "github.com/flanksource/duty/tests/setup" "github.com/flanksource/duty/types" @@ -115,7 +114,6 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { uuid.MustParse("a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"), // guest-playbook-read permission uuid.MustParse("38c3d4e5-f6a7-4b8c-9d0e-4f5a6b7c8d9e"), // guest-view-pods-read permission })) - Expect(payload.WildcardScopes).To(BeEmpty(), "no wildcard scopes expected") }) It("should return RLS payload for guest user with multi-target scope", func() { @@ -128,7 +126,6 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users with scopes") Expect(payload.Scopes).To(HaveLen(1)) Expect(payload.Scopes).To(ContainElement(uuid.MustParse("6b7c8d9e-0f1a-4b2c-9d3e-4f5a6b7c8d9e"))) - Expect(payload.WildcardScopes).To(BeEmpty(), "no wildcard scopes expected") }) It("should return RLS payload for guest user with agent-based scope", func() { @@ -141,7 +138,6 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users with scopes") Expect(payload.Scopes).To(HaveLen(1)) Expect(payload.Scopes).To(ContainElement(uuid.MustParse("3c4e5f6a-7b8c-4d9e-0f1a-2b3c4d5e6f7a"))) - Expect(payload.WildcardScopes).To(BeEmpty(), "no wildcard scopes expected") }) It("should return RLS payload for wildcard manager with full wildcard scope", func() { @@ -154,7 +150,6 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users with scopes") Expect(payload.Scopes).To(HaveLen(1)) Expect(payload.Scopes).To(ContainElement(uuid.MustParse("f1e2d3c4-b5a6-4c7d-8e9f-0a1b2c3d4e5f"))) - Expect(payload.WildcardScopes).To(ConsistOf(rls.WildcardResourceScopeConfig)) }) It("should return RLS payload for homelab default manager with combined agent+tag scope", func() { @@ -167,7 +162,6 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users with scopes") Expect(payload.Scopes).To(HaveLen(1)) Expect(payload.Scopes).To(ContainElement(uuid.MustParse("7a8b9c0d-1e2f-4a3b-5c6d-7e8f9a0b1c2d"))) - Expect(payload.WildcardScopes).To(BeEmpty(), "no wildcard scopes expected") }) It("should return RLS payload for multi-scope user with multiple scopes (OR behavior)", func() { @@ -185,7 +179,6 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { uuid.MustParse("3c4e5f6a-7b8c-4d9e-0f1a-2b3c4d5e6f7a"), )) Expect(payload.Scopes).To(HaveLen(3)) - Expect(payload.WildcardScopes).To(BeEmpty(), "no wildcard scopes expected") }) It("should disable RLS for non-guest users", func() { @@ -211,7 +204,6 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { // All resource scopes should be empty since user has no permissions Expect(payload.Scopes).To(BeEmpty(), "scopes should be empty for guest user with no permissions") - Expect(payload.WildcardScopes).To(BeEmpty(), "wildcard scopes should be empty for guest user with no permissions") }) It("should include direct ID-based permissions in RLS payload", func() { @@ -238,7 +230,6 @@ var _ = Describe("Permissions", Ordered, ContinueOnFailure, func() { Expect(payload.Disable).To(BeFalse(), "RLS should be enabled for guest users with scopes") Expect(payload.Scopes).To(HaveLen(1)) Expect(payload.Scopes).To(ContainElement(uuid.MustParse("48d4e5f6-a7b8-4c9d-0e1f-5a6b7c8d9e0f"))) - Expect(payload.WildcardScopes).To(BeEmpty(), "no wildcard scopes expected") }) })