Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ RLS policies filter database rows based on JWT claims passed via PostgREST, ensu

### Policy Patterns

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

- Examples: `config_items`, `canaries`, `components`, `playbooks`
- Policy checks row attributes directly using `match_scope(jwt_claims, row.tags, row.agent_id, row.name, row.id)`
- Examples: `config_items`, `canaries`, `components`, `playbooks`, `views`
- Policy checks scope overlap using `COALESCE(__scope, '{}'::uuid[]) && rls_scope_access()` and wildcard via `rls_has_wildcard('<type>')`.

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

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

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

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

- Go code builds an RLS Payload (scopes for config, component, playbook, canary, view) in `@rls/payload.go`
- Go code builds an RLS Payload (scope UUIDs + wildcard scopes) in `@rls/payload.go`
- `SetPostgresSessionRLS()` serializes the Payload to JSON and executes: `SET request.jwt.claims TO <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())
}
}
Comment thread
adityathebe marked this conversation as resolved.

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)
}
Comment thread
adityathebe marked this conversation as resolved.
}
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
Loading