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
1 change: 0 additions & 1 deletion .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ jobs:
run: |
mkdir -p .bench
git worktree add .bench/base "${{ github.event.pull_request.base.sha }}"
cp bench/bench_test.go .bench/base/bench/bench_test.go
- name: Build benchmark (base)
run: |
cd .bench/base
Expand Down
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ RLS policies filter database rows based on JWT claims passed via PostgREST, ensu

### Policy Patterns

**Direct Policies**: Tables with direct RLS use the `match_scope()` function to evaluate JWT claims against row attributes (tags, agents, names, id).
**Direct Policies**: Tables with direct RLS default to `match_scope()` against JWT claims.

- Examples: `config_items`, `canaries`, `components`, `playbooks`
- Policy checks row attributes directly using `match_scope(jwt_claims, row.tags, row.agent_id, row.name, row.id)`
- Examples: `config_items`, `canaries`, `components`, `playbooks`, `views`
- Default policy uses `match_scope(claims, tags, agent_id, name, id)` with wildcard support.
- When `rls.precomputed_scope` is enabled at migration time, policies switch to `__scope && rls_scope_access()` instead.

**Inherited Policies**: Child tables inherit access control from their parent using `EXISTS` clauses.

Expand All @@ -29,7 +30,8 @@ RLS policies filter database rows based on JWT claims passed via PostgREST, ensu

1. Add RLS enable logic to `@views/9998_rls_enable.sql`
- Enable RLS on the table
- Create the policy (either direct with `match_scope()` or inherited with `EXISTS`)
- Create the default match_scope policy (or inherited with `EXISTS`)
- If needed, mirror the change in `@views/9998_rls_enable_precomputed.sql` for precomputed mode
2. Add counterpart disable logic to `@views/9999_rls_disable.sql`
- Disable RLS on the table
- Drop the policy
Expand All @@ -42,7 +44,7 @@ RLS policies filter database rows based on JWT claims passed via PostgREST, ensu

The RLS policies work by injecting JWT claims into PostgreSQL session variables via `request.jwt.claims`. The flow is:

- Go code builds an RLS Payload (scopes for config, component, playbook, canary, view) in `@rls/payload.go`
- Go code builds an RLS Payload (scope UUIDs + wildcard scopes) in `@rls/payload.go`
- `SetPostgresSessionRLS()` serializes the Payload to JSON and executes: `SET request.jwt.claims TO <json>`
- PostgreSQL RLS policies read `(current_setting('request.jwt.claims')::jsonb)` to enforce access control

Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ ginkgo:
go install github.com/onsi/ginkgo/v2/ginkgo

test: ginkgo
# cleanup git directories that were downloaded from previous test run
# cuz we don't want to run their unit tests
rm -rf tests/e2e/exec-checkout

ginkgo -r -v --skip-package=tests/e2e

.PHONY: test-e2e
Expand Down
36 changes: 22 additions & 14 deletions api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import (

var DefaultConfig = Config{
Postgrest: PostgrestConfig{
Version: "v10.0.0",
DBRole: "postgrest_api",
AnonDBRole: "",
Port: 3000,
AdminPort: 3001,
MaxRows: 2000,
Version: "v10.0.0",
DBRole: "postgrest_api",
DBRoleBypass: "rls_bypasser",
AnonDBRole: "",
Port: 3000,
AdminPort: 3001,
MaxRows: 2000,
},
}

Expand Down Expand Up @@ -123,15 +124,22 @@ func (c Config) GetUsername() string {
}

type PostgrestConfig struct {
Port int
Disable bool
LogLevel string
URL string
Version string
JWTSecret string
DBRole string
Port int
Disable bool
LogLevel string
URL string
Version string
JWTSecret string
AdminPort int

// DBRole is the PostgREST role used for authenticated requests.
DBRole string

// DBRoleBypass is the PostgREST role used to bypass RLS for admin requests.
DBRoleBypass string

// AnonDBRole is the PostgREST role used for unauthenticated requests.
AnonDBRole string
AdminPort int

// A hard limit to the number of rows PostgREST will fetch from a view, table, or stored procedure.
// Limits payload size for accidental or malicious requests.
Expand Down
5 changes: 3 additions & 2 deletions bench/bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"

"github.com/flanksource/commons/logger"
"github.com/google/uuid"

"github.com/flanksource/duty/context"
pkgRLS "github.com/flanksource/duty/rls"
Expand Down Expand Up @@ -165,9 +166,9 @@ func runBenchmark(b *testing.B, config DistinctBenchConfig) {
var payload pkgRLS.Payload
if rls {
b.StopTimer()
payload = pkgRLS.Payload{Config: []pkgRLS.Scope{{Tags: sampleTags[i%len(sampleTags)]}}}
payload = pkgRLS.Payload{Scopes: []uuid.UUID{benchScopeIDs[i%len(benchScopeIDs)]}}
if err := payload.SetGlobalPostgresSessionRLS(testCtx.DB()); err != nil {
b.Fatalf("failed to setup rls payload with tag(%v): %v", payload, err)
b.Fatalf("failed to setup rls payload with scope(%v): %v", payload, err)
}

if err := verifyRLSPayload(testCtx); err != nil {
Expand Down
22 changes: 22 additions & 0 deletions bench/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bench_test

import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"testing"
Expand All @@ -28,6 +29,8 @@ var sampleTags = []map[string]string{
{"region": "us-east-2"},
}

var benchScopeIDs []uuid.UUID

func generateConfigItems(ctx context.Context, count int) error {
var iter int
for {
Expand Down Expand Up @@ -118,6 +121,25 @@ func setupConfigsForSize(ctx context.Context, size int) ([]uuid.UUID, error) {
return nil, fmt.Errorf("failed to generate configs: %w", err)
}

benchScopeIDs = make([]uuid.UUID, len(sampleTags))
for i, tag := range sampleTags {
scopeID := uuid.New()
benchScopeIDs[i] = scopeID
tagJSON, err := json.Marshal(tag)
if err != nil {
return nil, fmt.Errorf("failed to serialize bench scope tag: %w", err)
}

if err := ctx.DB().Exec(`
UPDATE config_items
SET __scope = array_append(COALESCE(__scope, '{}'::uuid[]), ?)
WHERE tags @> ?::jsonb
AND NOT (COALESCE(__scope, '{}'::uuid[]) @> ARRAY[?]::uuid[])
`, scopeID, string(tagJSON), scopeID).Error; err != nil {
return nil, fmt.Errorf("failed to materialize bench scope: %w", err)
}
}

var configIDs []uuid.UUID
if err := ctx.DB().Select("id").Model(&models.ConfigItem{}).Find(&configIDs).Error; err != nil {
return nil, err
Expand Down
13 changes: 10 additions & 3 deletions db.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/flanksource/commons/logger"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/lib/pq"
gormpostgres "gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/clause"
Expand Down Expand Up @@ -255,19 +256,25 @@ func verifyKratosMigration(db *gorm.DB) error {
func setStatementTimeouts(ctx dutyContext.Context, config api.Config) {
postgrestTimeout := ctx.Properties().Duration("db.postgrest.timeout", 1*time.Minute)

if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, config.Postgrest.DBRole, postgrestTimeout.Seconds())).Error; err != nil {
if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, pq.QuoteIdentifier(config.Postgrest.DBRole), postgrestTimeout.Seconds())).Error; err != nil {
logger.Errorf(err.Error())
}

if config.Postgrest.DBRoleBypass != "" {
if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, pq.QuoteIdentifier(config.Postgrest.DBRoleBypass), postgrestTimeout.Seconds())).Error; err != nil {
logger.Errorf(err.Error())
}
}

if config.Postgrest.AnonDBRole != "" {
if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, config.Postgrest.AnonDBRole, postgrestTimeout.Seconds())).Error; err != nil {
if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, pq.QuoteIdentifier(config.Postgrest.AnonDBRole), postgrestTimeout.Seconds())).Error; err != nil {
logger.Errorf(err.Error())
}
}

statementTimeout := ctx.Properties().Duration("db.connection.timeout", 1*time.Hour)
if username := config.GetUsername(); username != "" {
if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, username, statementTimeout.Seconds())).Error; err != nil {
if err := ctx.DB().Raw(fmt.Sprintf(`ALTER ROLE %s SET statement_timeout = '%0fs'`, pq.QuoteIdentifier(username), statementTimeout.Seconds())).Error; err != nil {
logger.Errorf(err.Error())
}
}
Expand Down
20 changes: 17 additions & 3 deletions migrate/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/flanksource/commons/logger"
"github.com/flanksource/commons/properties"
"github.com/lib/pq"
"github.com/samber/lo"
"github.com/samber/oops"

Expand Down Expand Up @@ -232,7 +233,7 @@ func createRole(db *sql.DB, roleName string, config api.Config, grants ...string
if err := db.QueryRow("SELECT count(*) FROM pg_catalog.pg_roles WHERE rolname = $1 LIMIT 1", roleName).Scan(&count); err != nil {
return err
} else if count == 0 {
if _, err := db.Exec(fmt.Sprintf("CREATE ROLE %s", roleName)); err != nil {
if _, err := db.Exec(fmt.Sprintf("CREATE ROLE %s", pq.QuoteIdentifier(roleName))); err != nil {
return err
} else {
log.Infof("Created role %s", roleName)
Expand All @@ -245,7 +246,7 @@ func createRole(db *sql.DB, roleName string, config api.Config, grants ...string
if granted, err := checkIfRoleIsGranted(db, roleName, user); err != nil {
return err
} else if !granted {
if _, err := db.Exec(fmt.Sprintf(`GRANT %s TO "%s"`, roleName, user)); err != nil {
if _, err := db.Exec(fmt.Sprintf(`GRANT %s TO "%s"`, pq.QuoteIdentifier(roleName), user)); err != nil {
log.Errorf("Failed to grant role %s to %s", roleName, user)
} else {
log.Infof("Granted %s to %s", roleName, user)
Expand All @@ -254,7 +255,7 @@ func createRole(db *sql.DB, roleName string, config api.Config, grants ...string
}

for _, grant := range grants {
if _, err := db.Exec(fmt.Sprintf(grant, roleName)); err != nil {
if _, err := db.Exec(fmt.Sprintf(grant, pq.QuoteIdentifier(roleName))); err != nil {
log.Errorf("Failed to apply grant[%s] for %s: %+v", grant, roleName, err)
}
}
Expand All @@ -270,6 +271,19 @@ func grantPostgrestRolesToCurrentUser(pool *sql.DB, config api.Config) error {
"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO %s"); err != nil {
return err
}

if config.Postgrest.DBRoleBypass != "" {
if err := createRole(pool, config.Postgrest.DBRoleBypass, config,
"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO %s",
"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO %s",
"GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO %s",
"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO %s"); err != nil {
return err
}
if _, err := pool.Exec(fmt.Sprintf("ALTER ROLE %s BYPASSRLS", pq.QuoteIdentifier(config.Postgrest.DBRoleBypass))); err != nil {
logger.GetLogger("migrate").Errorf("Failed to set BYPASSRLS for role %s: %v", config.Postgrest.DBRoleBypass, err)
}
}
if err := createRole(pool, config.Postgrest.AnonDBRole, config,
"GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s",
"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s"); err != nil {
Expand Down
29 changes: 21 additions & 8 deletions rls/payload.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/flanksource/commons/collections"
"github.com/flanksource/commons/hash"
"github.com/google/uuid"
"github.com/lib/pq"
"gorm.io/gorm"
)
Expand Down Expand Up @@ -46,8 +47,7 @@ type Payload struct {
View []Scope `json:"view,omitempty"`

// Scopes contains the list of scope UUIDs the user has access to.
// This is used for generated view tables only (for now).
Scopes []string `json:"scopes,omitempty"`
Scopes []uuid.UUID `json:"scopes,omitempty"`

Disable bool `json:"disable_rls,omitempty"`
}
Expand Down Expand Up @@ -102,9 +102,11 @@ func (t *Payload) EvalFingerprint() {
}
}

// Include scope UUIDs in fingerprint
if len(t.Scopes) > 0 {
scopesCopy := slices.Clone(t.Scopes)
scopesCopy := make([]string, 0, len(t.Scopes))
for _, scope := range t.Scopes {
scopesCopy = append(scopesCopy, scope.String())
}
slices.Sort(scopesCopy)
parts = append(parts, strings.Join(scopesCopy, ","))
}
Expand All @@ -128,15 +130,26 @@ func (t *Payload) Fingerprint() string {

// Injects the payload as local parameter
func (t Payload) SetPostgresSessionRLS(db *gorm.DB) error {
return t.setPostgresSessionRLS(db, true)
return t.setPostgresSessionRLS(db, true, "postgrest_api")
}

// Injects the payload as sessions parameter
func (t Payload) SetGlobalPostgresSessionRLS(db *gorm.DB) error {
return t.setPostgresSessionRLS(db, false)
return t.setPostgresSessionRLS(db, false, "postgrest_api")
}

func (t Payload) SetPostgresSessionRLSWithRole(db *gorm.DB, role string) error {
return t.setPostgresSessionRLS(db, true, role)
}

func (t Payload) setPostgresSessionRLS(db *gorm.DB, local bool) error {
func (t Payload) SetGlobalPostgresSessionRLSWithRole(db *gorm.DB, role string) error {
return t.setPostgresSessionRLS(db, false, role)
}

func (t Payload) setPostgresSessionRLS(db *gorm.DB, local bool, role string) error {
if role == "" {
return fmt.Errorf("role is required")
}
rlsJSON, err := json.Marshal(t)
if err != nil {
return fmt.Errorf("failed to marshall to json: %w", err)
Expand All @@ -147,7 +160,7 @@ func (t Payload) setPostgresSessionRLS(db *gorm.DB, local bool) error {
scope = "LOCAL"
}

if err := db.Exec(fmt.Sprintf("SET %s ROLE postgrest_api", scope)).Error; err != nil {
if err := db.Exec(fmt.Sprintf("SET %s ROLE %s", scope, pq.QuoteIdentifier(role))).Error; err != nil {
return fmt.Errorf("failed to set role: %w", err)
}

Expand Down
8 changes: 8 additions & 0 deletions schema/checks.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ table "canaries" {
null = true
type = jsonb
}
column "__scope" {
null = true
type = sql("uuid[]")
}
column "annotations" {
null = true
type = jsonb
Expand Down Expand Up @@ -84,6 +88,10 @@ table "canaries" {
index "canaries_source_idx" {
columns = [column.source]
}
index "canaries__scope_gin_idx" {
columns = [column.__scope]
type = GIN
}
}

table "check_statuses" {
Expand Down
8 changes: 8 additions & 0 deletions schema/components.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ table "components" {
null = true
type = jsonb
}
column "__scope" {
null = true
type = sql("uuid[]")
}
column "hidden" {
null = false
type = boolean
Expand Down Expand Up @@ -364,6 +368,10 @@ table "components" {
columns = [column.properties]
type = GIN
}
index "components__scope_gin_idx" {
columns = [column.__scope]
type = GIN
}
index "components_topology_id_type_name_parent_id_key" {
unique = true
columns = [column.topology_id, column.type, column.name, column.parent_id]
Expand Down
8 changes: 8 additions & 0 deletions schema/config.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ table "config_items" {
type = jsonb
comment = "contains a list of tags"
}
column "__scope" {
null = true
type = sql("uuid[]")
}
column "tags_values" {
null = true
type = jsonb
Expand Down Expand Up @@ -408,6 +412,10 @@ table "config_items" {
columns = [column.tags]
type = GIN
}
index "config_items__scope_gin_idx" {
columns = [column.__scope]
type = GIN
}
index "idx_config_items_tags_values" {
columns = [column.tags_values]
type = GIN
Expand Down
Loading