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>
107 lines
3.2 KiB
Go
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
|
|
}
|