Files
anchorage/test/rls_integration_test.go
William Gill fa0df451d5
Some checks failed
Test / Build & Unit Tests (push) Successful in 4m58s
Test / Lint (push) Successful in 27s
Test / Integration Tests (push) Failing after 2m19s
Security / Vulnerability Check (push) Failing after 1m37s
fix: coerce nil Origins to empty slice at pg store boundary
Two bugs exposed by the integration suite:

1. (production) pins.origins is NOT NULL DEFAULT '{}', but pgx
   serialises a Go nil []string as SQL NULL — so every Create/Replace
   whose caller omitted origins (an optional field per the IPFS
   Pinning Service spec) was 500ing on the NOT NULL constraint. The
   openapi/pin-service paths pass origins through verbatim, so any
   client POST without "origins" hit this. Normalise nil -> []string{}
   at the store boundary in both pinStore.Create and pinStore.Replace.

2. (test I introduced last commit) SET LOCAL does not accept bound
   parameters; the RLS integration test was getting a 42601 syntax
   error. Switch to SELECT set_config('anchorage.org_id', $1, true),
   which is parameterisable and keeps the value out of the SQL string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 09:45:37 -05:00

107 lines
3.2 KiB
Go

//go:build integration
package test
import (
"context"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"anchorage/internal/pkg/ids"
)
// TestRLSTenantIsolation is the belt-and-suspenders check behind
// anchorage's RLS policies. It connects directly via pgxpool (bypassing
// the Store's GUC-setting Tx), inserts a pin with anchorage.org_id set
// to org A, then reads back under three GUC states:
//
// 1. No GUC set → RLS denies (zero rows).
// 2. GUC set to a different org → RLS denies (zero rows).
// 3. GUC set to the inserting org → RLS allows (one row).
//
// If RLS is ever disabled by accident (e.g., a migration drops a
// policy), this test flips red immediately.
func TestRLSTenantIsolation(t *testing.T) {
s, pool, cleanup := startPostgresWithPool(t)
defer cleanup()
ctx := context.Background()
// Seed two orgs via the regular Store. Orgs is not an RLS-gated
// table; pins is.
orgA := ids.MustNewOrg()
orgB := ids.MustNewOrg()
if _, err := s.Orgs().Create(ctx, orgA, "a", "A"); err != nil {
t.Fatalf("create org A: %v", err)
}
if _, err := s.Orgs().Create(ctx, orgB, "b", "B"); err != nil {
t.Fatalf("create org B: %v", err)
}
// Insert a pin for org A inside a tx with anchorage.org_id set to A.
pinA := ids.MustNewPin()
if err := txWithOrg(ctx, pool, orgA.String(), func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`INSERT INTO pins (request_id, org_id, cid, status) VALUES ($1, $2, 'bafy-rls', 'pinned')`,
pinA, orgA,
)
return err
}); err != nil {
t.Fatalf("insert: %v", err)
}
// Case 1: no GUC. current_setting('anchorage.org_id', true) returns
// NULL and the RLS policy `org_id = <null>` is false for every row.
count := mustCountPins(t, ctx, pool, "")
if count != 0 {
t.Errorf("no-GUC count = %d, want 0 (RLS should hide)", count)
}
// Case 2: GUC set to org B.
count = mustCountPins(t, ctx, pool, orgB.String())
if count != 0 {
t.Errorf("org-B-GUC count = %d, want 0 (RLS should hide org A)", count)
}
// Case 3: GUC set to org A → row visible.
count = mustCountPins(t, ctx, pool, orgA.String())
if count != 1 {
t.Errorf("org-A-GUC count = %d, want 1", count)
}
}
// txWithOrg runs fn inside a transaction with anchorage.org_id bound
// (or unbound if org==""). Commits on nil error, rolls back otherwise.
func txWithOrg(ctx context.Context, pool *pgxpool.Pool, org string, fn func(context.Context, pgx.Tx) error) error {
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if org != "" {
// SET LOCAL does not accept bound parameters; use set_config,
// which does, so the org id is never string-concatenated into
// SQL.
if _, err := tx.Exec(ctx, "SELECT set_config('anchorage.org_id', $1, true)", org); err != nil {
return err
}
}
if err := fn(ctx, tx); err != nil {
return err
}
return tx.Commit(ctx)
}
func mustCountPins(t *testing.T, ctx context.Context, pool *pgxpool.Pool, org string) int {
t.Helper()
var n int
err := txWithOrg(ctx, pool, org, func(ctx context.Context, tx pgx.Tx) error {
return tx.QueryRow(ctx, "SELECT COUNT(*) FROM pins").Scan(&n)
})
if err != nil {
t.Fatalf("count (org=%q): %v", org, err)
}
return n
}