Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ const (
EventMSPlannerResponderAdded = "incident.responder.msplanner.added"

EventMSPlannerCommentAdded = "incident.comment.msplanner.added"

EventScopeMaterialize = "scope.materialize"
EventPermissionMaterialize = "permission.materialize"
)

var (
Expand Down
49 changes: 2 additions & 47 deletions api/v1/permission_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down
170 changes: 51 additions & 119 deletions auth/rls.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
"encoding/json"
"errors"
"fmt"
"sort"
"strings"

"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"
Expand Down Expand Up @@ -72,11 +76,26 @@
}

if rlsPayload.Disable {
return fn(ctx)
return ctx.Transaction(func(txCtx context.Context, _ trace.Span) error {
role := dutyAPI.DefaultConfig.Postgrest.DBRoleBypass

Check failure on line 80 in auth/rls.go

View workflow job for this annotation

GitHub Actions / lint

dutyAPI.DefaultConfig.Postgrest.DBRoleBypass undefined (type "github.com/flanksource/duty/api".PostgrestConfig has no field or method 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)
})
Comment on lines +79 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Build failure: DBRoleBypass is undefined on PostgrestConfig.

The static analysis and pipeline failures confirm that dutyAPI.DefaultConfig.Postgrest.DBRoleBypass does not exist in the current duty version. This field must be added to the duty package before this code will compile.

The logic itself is sound: when RLS is disabled, set a local role for the transaction to ensure proper database access. However, the implementation depends on API that doesn't exist yet in the pinned duty version.

🧰 Tools
🪛 GitHub Actions: CodeQL

[error] 80-80: Go compile error: undefined field DBRoleBypass in PostgrestConfig (duty API). The field DBRoleBypass does not exist on type 'github.com/flanksource/duty/api'.PostgrestConfig.

🪛 GitHub Actions: Lint

[error] 80-80: auth/rls.go:80:44: dutyAPI.DefaultConfig.Postgrest.DBRoleBypass undefined (type "github.com/flanksource/duty/api'.PostgrestConfig has no field or method DBRoleBypass). GolangCI-Lint: run command failed: golangci-lint run --verbose --max-same-issues=0 --max-issues-per-linter=0

🪛 GitHub Actions: Test

[error] 80-80: dutyAPI.DefaultConfig.Postgrest.DBRoleBypass undefined (type "github.com/flanksource/duty/api".PostgrestConfig has no field or method DBRoleBypass)

🪛 GitHub Check: lint

[failure] 80-80:
dutyAPI.DefaultConfig.Postgrest.DBRoleBypass undefined (type "github.com/flanksource/duty/api".PostgrestConfig has no field or method DBRoleBypass)

🤖 Prompt for AI Agents
In `@auth/rls.go` around lines 79 - 94, Add the missing DBRoleBypass field to the
PostgrestConfig in the duty package so the reference
dutyAPI.DefaultConfig.Postgrest.DBRoleBypass compiles: add a DBRoleBypass string
field to the PostgrestConfig struct, ensure DefaultConfig initializes it (and
any config parsing/loading populates it, e.g., from env/YAML), and update any
relevant tests or config docs; this will allow the rls.go code that reads
dutyAPI.DefaultConfig.Postgrest.DBRoleBypass and falls back to DBRole to build
successfully.

}

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 {

Check failure on line 98 in auth/rls.go

View workflow job for this annotation

GitHub Actions / lint

rlsPayload.SetPostgresSessionRLSWithRole undefined (type *"github.com/flanksource/duty/rls".Payload has no field or method SetPostgresSessionRLSWithRole)
return err
}

Expand Down Expand Up @@ -106,23 +125,17 @@
return nil, fmt.Errorf("failed to query permissions: %w", err)
}

payload := &rls.Payload{}
scopeIDs := map[uuid.UUID]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.CanaryID != nil {
payload.Canary = append(payload.Canary, rls.Scope{ID: perm.CanaryID.String()})
if perm.ConfigID != nil || perm.ComponentID != nil || perm.PlaybookID != nil || perm.CanaryID != nil {
scopeIDs[permScopeID] = struct{}{}
}

if len(perm.ObjectSelector) == 0 {
Expand All @@ -137,35 +150,26 @@

// 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); 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))
}
scopeIDs[permScopeID] = struct{}{}
}

if len(selectors.Components) > 0 {
for _, selector := range selectors.Components {
payload.Component = append(payload.Component, convertResourceSelectorToRLSScope(selector))
}
scopeIDs[permScopeID] = struct{}{}
}

if len(selectors.Playbooks) > 0 {
for _, selector := range selectors.Playbooks {
payload.Playbook = append(payload.Playbook, convertResourceSelectorToRLSScope(selector))
}
scopeIDs[permScopeID] = struct{}{}
}

if len(selectors.Views) > 0 {
for _, viewRef := range selectors.Views {
payload.View = append(payload.View, convertViewScopeRefToRLSScope(viewRef))
}
scopeIDs[permScopeID] = struct{}{}
}

// TODO: No RLS support for connections yet!
Expand All @@ -176,11 +180,15 @@
// }
}

payload := &rls.Payload{
Scopes: setToSortedUUIDSlice(scopeIDs),
}

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
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().
Expand All @@ -194,103 +202,27 @@
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())

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
}
// Always include scope UUID for view row-level grants
scopeIDs[scope.ID] = struct{}{}

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)
}
}
}

return nil
}

func convertToRLSScope(selector *v1.ScopeResourceSelector) rls.Scope {
rlsScope := rls.Scope{}

if selector.Agent != "" {
rlsScope.Agents = []string{selector.Agent}
}

if selector.Name != "" {
rlsScope.Names = []string{selector.Name}
}

if selector.TagSelector != "" {
rlsScope.Tags = collections.SelectorToMap(selector.TagSelector)
}

return rlsScope
}

// 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 setToSortedUUIDSlice(set map[uuid.UUID]struct{}) []uuid.UUID {
if len(set) == 0 {
return nil
}

if selector.Name != "" {
rlsScope.Names = []string{selector.Name}
out := make([]uuid.UUID, 0, len(set))
for val := range set {
out = append(out, val)
}

if selector.TagSelector != "" {
rlsScope.Tags = collections.SelectorToMap(selector.TagSelector)
}

return rlsScope
}

// 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{}

if viewRef.Name != "" {
rlsScope.Names = []string{viewRef.Name}
}

if viewRef.ID != "" {
rlsScope.ID = viewRef.ID
}

// 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 out[i].String() < out[j].String()
})

return rlsScope
return out
}
16 changes: 12 additions & 4 deletions auth/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,24 @@
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 != "" {

Check failure on line 80 in auth/tokens.go

View workflow job for this annotation

GitHub Actions / lint

config.Postgrest.DBRoleBypass undefined (type "github.com/flanksource/duty/api".PostgrestConfig has no field or method DBRoleBypass)
role = config.Postgrest.DBRoleBypass

Check failure on line 81 in auth/tokens.go

View workflow job for this annotation

GitHub Actions / lint

config.Postgrest.DBRoleBypass undefined (type "github.com/flanksource/duty/api".PostgrestConfig has no field or method DBRoleBypass) (typecheck)
}
Comment on lines +74 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Build failure: DBRoleBypass is undefined on PostgrestConfig.

The pipeline failures confirm that config.Postgrest.DBRoleBypass does not exist in the current duty API.

The refactored logic is correct:

  1. Fetch RLS payload upfront to determine the appropriate role
  2. Use the bypass role when RLS is disabled and a bypass role is configured
  3. Fall back to the standard DBRole otherwise

This aligns with the changes in auth/rls.go. Once the duty dependency is updated with the DBRoleBypass field, this will work correctly.

🧰 Tools
🪛 GitHub Actions: Build

[error] 80-81: Build failed: config.Postgrest.DBRoleBypass is undefined in tokens.go (lines 80-81). Go code references a field that no longer exists in the duty API.

🪛 GitHub Check: lint

[failure] 81-81:
config.Postgrest.DBRoleBypass undefined (type "github.com/flanksource/duty/api".PostgrestConfig has no field or method DBRoleBypass) (typecheck)


[failure] 80-80:
config.Postgrest.DBRoleBypass undefined (type "github.com/flanksource/duty/api".PostgrestConfig has no field or method DBRoleBypass)

🤖 Prompt for AI Agents
In `@auth/tokens.go` around lines 74 - 82, The code references the non-existent
field config.Postgrest.DBRoleBypass causing build failures; update the role
selection in the block around GetRLSPayload and the local variable role so it
does not reference DBRoleBypass — keep role := config.Postgrest.DBRole and, for
now, remove the rlsPayload.Disable && config.Postgrest.DBRoleBypass != "" branch
(or replace it with a no-op/fallback that leaves role as DBRole), and add a TODO
noting to reintroduce DBRoleBypass once the duty dependency adds that field; use
the symbols GetRLSPayload, rlsPayload, and the local role to locate the change.


// 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)
}

Expand Down
1 change: 1 addition & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 4 additions & 9 deletions db/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading