Phase 4: SSO scaffolding, RBAC enforcement, tenant-scoped dashboards, audit logging, K8s deployment

RBAC (api/internal/authz) is live on /query and /dashboards, backed by a
new enterprise/ module (session issuance, audit logging, RBAC storage,
OIDC/SAML protocol wiring) that core never imports -- only calls over
HTTP. Found and fixed a real cross-tenant vulnerability in dashboards
(no tenant_id filtering at all) while writing the threat model doc.

Two things are explicitly NOT done, documented rather than hidden:
tenant isolation for log data itself (/query still shares one ClickHouse
connection and Tantivy index across every tenant -- RBAC controls who
can query, not what a query can see), and human SSO login (protocol
wiring exists, no HTTP handler calls it yet). See
docs/security/threat-model.md and docs/phase-4-runbook.md.

Also adds deploy/ (Go Operator + Helm chart, validated offline only --
no cluster was reachable in this environment).
This commit is contained in:
2026-08-13 22:16:59 -07:00
parent 9435115ab7
commit 3eb0f4c589
116 changed files with 8589 additions and 126 deletions
+185
View File
@@ -0,0 +1,185 @@
// Package audit implements the append-only, hash-chained query audit
// log described in /docs/phase-4-isolation-design.md's audit-logging
// section. Two independent defenses back the "no update/delete path
// from the application layer" requirement -- both verified against a
// live Postgres, not just written: audit_writer (this package's own
// Postgres role, via its own connection pool, never the shared `sentry`
// role every other store uses) has only INSERT+SELECT grants, and a
// BEFORE UPDATE OR DELETE trigger (metadata/migrations/0015-0016)
// rejects the operation for *any* role, including the table owner --
// confirmed live: even `sentry` cannot UPDATE a row without first
// disabling the trigger, a privileged operation distinct from ordinary
// application access.
//
// The hash chain (prev_hash/row_hash) proves internal consistency --
// detects tampering with existing rows -- but does not by itself prove
// truth against a privileged attacker who can rewrite the whole table
// and regenerate a self-consistent chain from row 1. See checkpoint.go
// for the external-anchoring half of that guarantee.
package audit
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Source string
const (
SourceAPI Source = "api"
SourceWeb Source = "web"
SourceCLI Source = "cli"
SourceAlerting Source = "alerting"
)
type EventType string
const (
EventQuery EventType = "query"
EventRoleChange EventType = "role_change"
EventGrantChange EventType = "grant_change"
EventSSOConfigChange EventType = "sso_config_change"
EventSecretReveal EventType = "secret_reveal"
)
type Status string
const (
StatusSuccess Status = "success"
StatusError Status = "error"
)
// Entry is what a caller supplies. UserID is nil for system/alerting-
// sourced entries (see /docs/phase-4-isolation-design.md's alerting
// service-identity finding -- alerting evaluations are audited, but
// aren't attributable to a human user).
type Entry struct {
TenantID string
UserID *string
Source Source
EventType EventType
QueryText *string
RowCount *int
DurationMS *int
Status Status
ErrorMessage *string
Detail json.RawMessage
}
// Record is a written entry plus the fields the store assigned.
type Record struct {
Entry
ID int64
PrevHash *string
RowHash string
}
// Store writes via a connection pool authenticated as the audit_writer
// role -- never the shared pool other stores in this repo use. Passing
// a pool opened with any other role's credentials silently defeats the
// grant-restriction half of this package's guarantee; there's no way
// for this package to verify its own pool's role at runtime, so this is
// an integration-time discipline documented here, not something this
// code can enforce on itself.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// advisoryLockKey serializes concurrent Append calls so two writers
// never read the same prev_hash and each compute a hash chained off it
// -- that would fork the chain. Arbitrary fixed value, held only for
// the duration of one transaction (pg_advisory_xact_lock releases
// automatically at commit/rollback).
const advisoryLockKey = 784129035
func (s *Store) Append(ctx context.Context, e Entry) (*Record, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("audit: beginning transaction: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", advisoryLockKey); err != nil {
return nil, fmt.Errorf("audit: acquiring serialization lock: %w", err)
}
var prevHash *string
row := tx.QueryRow(ctx, "SELECT row_hash FROM audit_log ORDER BY id DESC LIMIT 1")
if err := row.Scan(&prevHash); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("audit: reading previous row hash: %w", err)
}
if len(e.Detail) == 0 {
e.Detail = json.RawMessage(`{}`)
}
rec := &Record{Entry: e, PrevHash: prevHash, RowHash: computeHash(prevHash, e)}
err = tx.QueryRow(ctx, `
INSERT INTO audit_log (tenant_id, user_id, source, event_type, query_text, row_count,
duration_ms, status, error_message, detail, prev_hash, row_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
e.TenantID, e.UserID, e.Source, e.EventType, e.QueryText, e.RowCount,
e.DurationMS, e.Status, e.ErrorMessage, e.Detail, prevHash, rec.RowHash,
).Scan(&rec.ID)
if err != nil {
return nil, fmt.Errorf("audit: inserting row: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("audit: committing: %w", err)
}
return rec, nil
}
// computeHash is deliberately a fixed, explicit field order (not "hash
// the JSON encoding," which is not guaranteed stable across Go versions
// or map key ordering) -- \x00 is used as a field separator since it
// cannot appear in any of these string fields in practice, and even if
// it somehow did, the goal here is deterministic tamper-detection
// against accidental/naive modification, not cryptographic
// collision-resistance against a chosen-plaintext adversary.
func computeHash(prevHash *string, e Entry) string {
h := sha256.New()
write := func(s string) {
h.Write([]byte(s))
h.Write([]byte{0})
}
write(deref(prevHash))
write(e.TenantID)
write(deref(e.UserID))
write(string(e.Source))
write(string(e.EventType))
write(deref(e.QueryText))
write(intToStr(e.RowCount))
write(intToStr(e.DurationMS))
write(string(e.Status))
write(deref(e.ErrorMessage))
write(string(e.Detail))
return hex.EncodeToString(h.Sum(nil))
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
func intToStr(n *int) string {
if n == nil {
return ""
}
return fmt.Sprintf("%d", *n)
}
+123
View File
@@ -0,0 +1,123 @@
package audit
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
// Checkpoint is a rolling hash over a range of audit_log rows, chained
// to the previous checkpoint the same way row_hash chains individual
// rows. The chain in audit_log alone only proves internal consistency:
// anyone with enough Postgres privilege to wipe the table can
// regenerate a perfectly self-consistent new chain from row 1.
// Checkpoints answer a different question -- "does what's in Postgres
// right now match what it was an hour ago" -- but only if they're
// written somewhere the same privileged actor can't also reach. That's
// CheckpointSink's job, not this package's: this package computes
// checkpoints correctly and hands them to a sink; it does not claim any
// particular sink is actually tamper-proof.
type Checkpoint struct {
FromID int64
ToID int64
PrevCheckpointHash string
Hash string
CreatedAt time.Time
}
// CheckpointSink persists checkpoints somewhere external. FileSink
// (below) is a working, testable implementation appropriate for
// development -- a real deployment needs a sink that genuinely isn't
// reachable by whatever could tamper with Postgres (S3 with Object
// Lock, a separate append-only service, etc.), which this package
// deliberately does not implement: that's an operational/infrastructure
// decision, not something to hardcode a specific cloud vendor's SDK for
// without discussing the dependency first.
type CheckpointSink interface {
// LastCheckpoint returns the most recently written checkpoint, or
// nil if none exists yet.
LastCheckpoint(ctx context.Context) (*Checkpoint, error)
Write(ctx context.Context, cp Checkpoint) error
}
// Checkpointer periodically rolls up new audit_log rows since the last
// checkpoint into a new one.
type Checkpointer struct {
store *Store
sink CheckpointSink
}
func NewCheckpointer(store *Store, sink CheckpointSink) *Checkpointer {
return &Checkpointer{store: store, sink: sink}
}
// Run computes and writes at most one new checkpoint covering every
// audit_log row added since the last one. Returns (nil, nil) if there's
// nothing new to checkpoint. Call on a schedule (e.g. hourly) from
// cmd/enterprise-auth -- this package doesn't run its own ticker, same
// "caller owns scheduling" shape as /alerting's evaluator.
func (c *Checkpointer) Run(ctx context.Context) (*Checkpoint, error) {
last, err := c.sink.LastCheckpoint(ctx)
if err != nil {
return nil, fmt.Errorf("audit: reading last checkpoint: %w", err)
}
fromID := int64(1)
prevHash := ""
if last != nil {
fromID = last.ToID + 1
prevHash = last.Hash
}
rowHashes, maxID, err := c.store.rowHashesFrom(ctx, fromID)
if err != nil {
return nil, fmt.Errorf("audit: reading rows for checkpoint: %w", err)
}
if len(rowHashes) == 0 {
return nil, nil
}
h := sha256.New()
h.Write([]byte(prevHash))
for _, rh := range rowHashes {
h.Write([]byte{0})
h.Write([]byte(rh))
}
cp := Checkpoint{
FromID: fromID, ToID: maxID,
PrevCheckpointHash: prevHash,
Hash: hex.EncodeToString(h.Sum(nil)),
CreatedAt: time.Now().UTC(),
}
if err := c.sink.Write(ctx, cp); err != nil {
return nil, fmt.Errorf("audit: writing checkpoint: %w", err)
}
return &cp, nil
}
// rowHashesFrom returns row_hash values for id >= fromID, in id order,
// plus the highest id seen (so the caller knows where the next
// checkpoint should resume).
func (s *Store) rowHashesFrom(ctx context.Context, fromID int64) ([]string, int64, error) {
rows, err := s.pool.Query(ctx, "SELECT id, row_hash FROM audit_log WHERE id >= $1 ORDER BY id ASC", fromID)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var hashes []string
var maxID int64
for rows.Next() {
var id int64
var hash string
if err := rows.Scan(&id, &hash); err != nil {
return nil, 0, err
}
hashes = append(hashes, hash)
maxID = id
}
return hashes, maxID, rows.Err()
}
+90
View File
@@ -0,0 +1,90 @@
package audit
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// FileSink is a working CheckpointSink appropriate for development and
// testing -- appends one JSON line per checkpoint to a local file.
// **Not a real external-anchoring guarantee**: a local file on the same
// host as Postgres is reachable by exactly the kind of privileged actor
// checkpointing is meant to defend against. A production deployment
// needs a genuinely separate-trust-domain sink (S3 with Object Lock, a
// separate append-only service) -- deliberately not implemented here,
// per checkpoint.go's doc comment.
type FileSink struct {
path string
}
func NewFileSink(path string) *FileSink {
return &FileSink{path: path}
}
type fileSinkLine struct {
FromID int64 `json:"from_id"`
ToID int64 `json:"to_id"`
PrevCheckpointHash string `json:"prev_checkpoint_hash"`
Hash string `json:"hash"`
CreatedAt time.Time `json:"created_at"`
}
func (f *FileSink) Write(_ context.Context, cp Checkpoint) error {
file, err := os.OpenFile(f.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("audit: opening checkpoint file: %w", err)
}
defer file.Close()
line := fileSinkLine{
FromID: cp.FromID, ToID: cp.ToID,
PrevCheckpointHash: cp.PrevCheckpointHash, Hash: cp.Hash, CreatedAt: cp.CreatedAt,
}
encoded, err := json.Marshal(line)
if err != nil {
return fmt.Errorf("audit: encoding checkpoint: %w", err)
}
if _, err := fmt.Fprintln(file, string(encoded)); err != nil {
return fmt.Errorf("audit: writing checkpoint: %w", err)
}
return nil
}
func (f *FileSink) LastCheckpoint(_ context.Context) (*Checkpoint, error) {
file, err := os.Open(f.path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("audit: opening checkpoint file: %w", err)
}
defer file.Close()
var lastLine string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if line := strings.TrimSpace(scanner.Text()); line != "" {
lastLine = line
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("audit: reading checkpoint file: %w", err)
}
if lastLine == "" {
return nil, nil
}
var line fileSinkLine
if err := json.Unmarshal([]byte(lastLine), &line); err != nil {
return nil, fmt.Errorf("audit: decoding last checkpoint line: %w", err)
}
return &Checkpoint{
FromID: line.FromID, ToID: line.ToID,
PrevCheckpointHash: line.PrevCheckpointHash, Hash: line.Hash, CreatedAt: line.CreatedAt,
}, nil
}
@@ -0,0 +1,42 @@
package audit
import (
"context"
"path/filepath"
"testing"
"time"
)
func TestFileSinkRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "checkpoints.jsonl")
sink := NewFileSink(path)
ctx := context.Background()
none, err := sink.LastCheckpoint(ctx)
if err != nil {
t.Fatalf("LastCheckpoint on a nonexistent file: %v", err)
}
if none != nil {
t.Fatalf("expected nil for a nonexistent checkpoint file, got %+v", none)
}
cp1 := Checkpoint{FromID: 1, ToID: 10, Hash: "hash1", CreatedAt: time.Now().UTC().Truncate(time.Second)}
if err := sink.Write(ctx, cp1); err != nil {
t.Fatalf("Write: %v", err)
}
cp2 := Checkpoint{FromID: 11, ToID: 20, PrevCheckpointHash: "hash1", Hash: "hash2", CreatedAt: time.Now().UTC().Truncate(time.Second)}
if err := sink.Write(ctx, cp2); err != nil {
t.Fatalf("Write: %v", err)
}
last, err := sink.LastCheckpoint(ctx)
if err != nil {
t.Fatalf("LastCheckpoint: %v", err)
}
if last == nil {
t.Fatalf("expected a checkpoint, got nil")
}
if last.ToID != cp2.ToID || last.Hash != cp2.Hash {
t.Fatalf("got %+v, want the most recently written checkpoint %+v", last, cp2)
}
}
@@ -0,0 +1,275 @@
// Integration tests against a real Postgres, authenticated as the real
// audit_writer role -- this package's whole point is a set of guarantees
// (grants, the trigger, hash-chain correctness under concurrency) that a
// mocked pgxpool can't actually exercise. Skipped unless
// AUDIT_TEST_POSTGRES_ADDR is set; run via:
//
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/enterprise \
// -e AUDIT_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
// -e AUDIT_TEST_POSTGRES_PASSWORD=audit-writer-dev-only \
// -e AUDIT_TEST_ADMIN_PASSWORD=sentry-dev-only \
// golang:1.25-alpine go test ./internal/audit/... -v
package audit
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func testPool(t *testing.T, user, password string) *pgxpool.Pool {
t.Helper()
addr := os.Getenv("AUDIT_TEST_POSTGRES_ADDR")
if addr == "" {
t.Skip("AUDIT_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
}
dsn := fmt.Sprintf("postgres://%s:%s@%s/sentry_metadata", user, password, addr)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("opening pool: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
func cleanupAuditLog(t *testing.T, adminPool *pgxpool.Pool) {
t.Helper()
ctx := context.Background()
// Errors here were previously swallowed (_, _ =) -- that hid the
// real cause of a test failure (rows accumulating across test runs)
// behind what looked like a row-count/ID-assumption bug instead.
// Surface them.
if _, err := adminPool.Exec(ctx, "ALTER TABLE audit_log DISABLE TRIGGER audit_log_immutable"); err != nil {
t.Fatalf("cleanup: disabling trigger: %v", err)
}
tag, err := adminPool.Exec(ctx, "DELETE FROM audit_log")
if err != nil {
t.Fatalf("cleanup: deleting rows: %v", err)
}
t.Logf("cleanup: deleted %d pre-existing rows", tag.RowsAffected())
if _, err := adminPool.Exec(ctx, "ALTER TABLE audit_log ENABLE TRIGGER audit_log_immutable"); err != nil {
t.Fatalf("cleanup: re-enabling trigger: %v", err)
}
}
func TestAppendAndVerifyChainRealPostgres(t *testing.T) {
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
cleanupAuditLog(t, adminPool)
defer cleanupAuditLog(t, adminPool)
store := NewStore(writerPool)
ctx := context.Background()
for i := 0; i < 5; i++ {
q := fmt.Sprintf("service=api | stats count %d", i)
rec, err := store.Append(ctx, Entry{
TenantID: "default", Source: SourceAPI, EventType: EventQuery,
QueryText: &q, Status: StatusSuccess,
})
if err != nil {
t.Fatalf("Append %d: %v", i, err)
}
if rec.RowHash == "" {
t.Fatalf("expected a non-empty row hash")
}
}
result, err := store.VerifyChain(ctx)
if err != nil {
t.Fatalf("VerifyChain: %v", err)
}
if !result.OK {
t.Fatalf("expected an intact chain, got broken at id=%d after %d rows checked", result.FirstBadID, result.RowsChecked)
}
if result.RowsChecked != 5 {
t.Fatalf("RowsChecked = %d, want 5", result.RowsChecked)
}
}
// TestVerifyChainDetectsTampering proves the chain actually catches an
// in-place row modification -- not just that VerifyChain runs without
// erroring on untampered data, which a bug returning OK unconditionally
// would also pass.
func TestVerifyChainDetectsTampering(t *testing.T) {
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
cleanupAuditLog(t, adminPool)
defer cleanupAuditLog(t, adminPool)
store := NewStore(writerPool)
ctx := context.Background()
var lastID int64
for i := 0; i < 3; i++ {
q := "service=api"
rec, err := store.Append(ctx, Entry{TenantID: "default", Source: SourceAPI, EventType: EventQuery, QueryText: &q, Status: StatusSuccess})
if err != nil {
t.Fatalf("Append: %v", err)
}
lastID = rec.ID
}
before, err := store.VerifyChain(ctx)
if err != nil || !before.OK {
t.Fatalf("expected chain to verify before tampering: ok=%v err=%v", before.OK, err)
}
// Simulate tampering: a privileged actor disables the trigger (the
// same escape hatch confirmed live in the design doc's verification
// -- this is the "even the trigger doesn't stop a superuser" case)
// and rewrites a row's status without recomputing the hash chain.
if _, err := adminPool.Exec(ctx, "ALTER TABLE audit_log DISABLE TRIGGER audit_log_immutable"); err != nil {
t.Fatalf("disabling trigger for the tamper simulation: %v", err)
}
if _, err := adminPool.Exec(ctx, "UPDATE audit_log SET status = 'error' WHERE id = $1", lastID); err != nil {
t.Fatalf("simulated tamper UPDATE: %v", err)
}
if _, err := adminPool.Exec(ctx, "ALTER TABLE audit_log ENABLE TRIGGER audit_log_immutable"); err != nil {
t.Fatalf("re-enabling trigger: %v", err)
}
after, err := store.VerifyChain(ctx)
if err != nil {
t.Fatalf("VerifyChain after tampering: %v", err)
}
if after.OK {
t.Fatalf("expected VerifyChain to detect the tampered row, got OK")
}
if after.FirstBadID != lastID {
t.Fatalf("FirstBadID = %d, want %d", after.FirstBadID, lastID)
}
}
// TestAppendConcurrentWritesProduceAValidChain exercises the advisory
// lock: without it, concurrent Append calls could read the same
// prev_hash and fork the chain. Real concurrency, real Postgres, not a
// unit test of the Go code alone.
func TestAppendConcurrentWritesProduceAValidChain(t *testing.T) {
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
cleanupAuditLog(t, adminPool)
defer cleanupAuditLog(t, adminPool)
store := NewStore(writerPool)
ctx := context.Background()
const n = 20
var wg sync.WaitGroup
errs := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
q := fmt.Sprintf("query-%d", i)
_, err := store.Append(ctx, Entry{TenantID: "default", Source: SourceAPI, EventType: EventQuery, QueryText: &q, Status: StatusSuccess})
errs <- err
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent Append failed: %v", err)
}
}
result, err := store.VerifyChain(ctx)
if err != nil {
t.Fatalf("VerifyChain: %v", err)
}
if !result.OK {
t.Fatalf("expected an intact chain after %d concurrent appends, got broken at id=%d", n, result.FirstBadID)
}
if result.RowsChecked != n {
t.Fatalf("RowsChecked = %d, want %d", result.RowsChecked, n)
}
}
// TestCheckpointerRun ties Store + FileSink together against real
// audit_log data: writes some rows, checkpoints, writes more, checkpoints
// again, and confirms the second checkpoint picks up exactly where the
// first left off (FromID = previous ToID + 1) with a hash chained off
// the previous checkpoint's hash.
func TestCheckpointerRun(t *testing.T) {
writerPool := testPool(t, "audit_writer", os.Getenv("AUDIT_TEST_POSTGRES_PASSWORD"))
adminPool := testPool(t, "sentry", os.Getenv("AUDIT_TEST_ADMIN_PASSWORD"))
cleanupAuditLog(t, adminPool)
defer cleanupAuditLog(t, adminPool)
store := NewStore(writerPool)
sink := NewFileSink(filepath.Join(t.TempDir(), "checkpoints.jsonl"))
checkpointer := NewCheckpointer(store, sink)
ctx := context.Background()
// DELETE doesn't reset the BIGSERIAL sequence, so IDs are not
// guaranteed to start at 1 -- but Checkpoint.FromID is a *cursor
// position* (1, or the previous checkpoint's ToID+1), not "the
// lowest row ID that happens to still exist." In real usage audit_log
// never has gaps (append-only, protected by the immutability
// trigger), so those always coincide; here, this test's own
// destructive cleanupAuditLog between test functions creates a gap
// (rows from earlier tests were deleted, advancing the sequence)
// that real usage never produces -- so FromID is asserted against
// the cursor's own logic (1, since no prior checkpoint exists for
// this fresh FileSink), and ToID against the actual last ID Append
// returned.
var firstBatchLastID int64
for i := 0; i < 3; i++ {
q := "first batch"
rec, err := store.Append(ctx, Entry{TenantID: "default", Source: SourceAPI, EventType: EventQuery, QueryText: &q, Status: StatusSuccess})
if err != nil {
t.Fatalf("Append: %v", err)
}
firstBatchLastID = rec.ID
}
cp1, err := checkpointer.Run(ctx)
if err != nil {
t.Fatalf("first Run: %v", err)
}
if cp1 == nil {
t.Fatalf("expected a checkpoint after 3 rows, got nil")
}
if cp1.FromID != 1 || cp1.ToID != firstBatchLastID {
t.Fatalf("cp1 = %+v, want FromID=1 ToID=%d", cp1, firstBatchLastID)
}
// Nothing new since the last checkpoint -- Run should be a no-op.
noop, err := checkpointer.Run(ctx)
if err != nil {
t.Fatalf("no-op Run: %v", err)
}
if noop != nil {
t.Fatalf("expected nil (nothing new to checkpoint), got %+v", noop)
}
var secondBatchLastID int64
for i := 0; i < 2; i++ {
q := "second batch"
rec, err := store.Append(ctx, Entry{TenantID: "default", Source: SourceAPI, EventType: EventQuery, QueryText: &q, Status: StatusSuccess})
if err != nil {
t.Fatalf("Append: %v", err)
}
secondBatchLastID = rec.ID
}
cp2, err := checkpointer.Run(ctx)
if err != nil {
t.Fatalf("second Run: %v", err)
}
if cp2 == nil {
t.Fatalf("expected a second checkpoint, got nil")
}
if cp2.FromID != cp1.ToID+1 || cp2.ToID != secondBatchLastID {
t.Fatalf("cp2 = %+v, want FromID=%d ToID=%d", cp2, cp1.ToID+1, secondBatchLastID)
}
if cp2.PrevCheckpointHash != cp1.Hash {
t.Fatalf("cp2.PrevCheckpointHash = %q, want %q (chained to cp1)", cp2.PrevCheckpointHash, cp1.Hash)
}
}
+100
View File
@@ -0,0 +1,100 @@
package audit
import (
"context"
"fmt"
)
// VerifyResult reports whether the chain is intact and, if not, the
// first row where it breaks -- everything after that point is
// untrustworthy regardless of whether later rows individually
// "verify," since a break means the chain was forked or rows were
// altered/removed at that point.
type VerifyResult struct {
OK bool
FirstBadID int64 // 0 if OK
RowsChecked int64
}
// VerifyChain walks audit_log in id order, recomputing each row's hash
// from its own fields plus the previous row's hash, and confirms it
// matches the stored row_hash and that prev_hash matches the actual
// previous row -- catching both in-place tampering (a row's fields
// changed, its stored row_hash no longer matches what recomputing it
// produces) and forgery (a row inserted with a prev_hash that doesn't
// match what actually preceded it).
//
// This proves internal consistency only. It cannot detect an attacker
// who deletes the whole table and replays a self-consistent chain from
// row 1 -- that's what checkpoint.go's external anchoring is for. Run
// both in the runbook/threat-model verification, not just this one.
func (s *Store) VerifyChain(ctx context.Context) (VerifyResult, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_id, user_id, source, event_type, query_text, row_count,
duration_ms, status, error_message, detail, prev_hash, row_hash
FROM audit_log ORDER BY id ASC`)
if err != nil {
return VerifyResult{}, fmt.Errorf("audit: querying for verification: %w", err)
}
defer rows.Close()
var expectedPrevHash *string
var checked int64
for rows.Next() {
var rec Record
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.UserID, &rec.Source, &rec.EventType,
&rec.QueryText, &rec.RowCount, &rec.DurationMS, &rec.Status, &rec.ErrorMessage,
&rec.Detail, &rec.PrevHash, &rec.RowHash); err != nil {
return VerifyResult{}, fmt.Errorf("audit: scanning row for verification: %w", err)
}
checked++
if !hashPtrEqual(rec.PrevHash, expectedPrevHash) {
return VerifyResult{OK: false, FirstBadID: rec.ID, RowsChecked: checked}, nil
}
recomputed := computeHash(rec.PrevHash, rec.Entry)
if recomputed != rec.RowHash {
return VerifyResult{OK: false, FirstBadID: rec.ID, RowsChecked: checked}, nil
}
hash := rec.RowHash
expectedPrevHash = &hash
}
if err := rows.Err(); err != nil {
return VerifyResult{}, fmt.Errorf("audit: reading verification rows: %w", err)
}
return VerifyResult{OK: true, RowsChecked: checked}, nil
}
func hashPtrEqual(a, b *string) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
// ListForTenant reads a tenant's audit trail, most recent first --
// what a tenant Admin/Owner sees per /docs/phase-4-rbac-design.md's
// permission matrix.
func (s *Store) ListForTenant(ctx context.Context, tenantID string, limit int) ([]Record, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, tenant_id, user_id, source, event_type, query_text, row_count,
duration_ms, status, error_message, detail, prev_hash, row_hash
FROM audit_log WHERE tenant_id = $1 ORDER BY id DESC LIMIT $2`, tenantID, limit)
if err != nil {
return nil, fmt.Errorf("audit: listing for tenant: %w", err)
}
defer rows.Close()
var out []Record
for rows.Next() {
var rec Record
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.UserID, &rec.Source, &rec.EventType,
&rec.QueryText, &rec.RowCount, &rec.DurationMS, &rec.Status, &rec.ErrorMessage,
&rec.Detail, &rec.PrevHash, &rec.RowHash); err != nil {
return nil, fmt.Errorf("audit: scanning row: %w", err)
}
out = append(out, rec)
}
return out, rows.Err()
}
@@ -0,0 +1,109 @@
// Package authhandler implements enterprise-auth's POST /internal/authorize
// endpoint -- the HTTP side of the "network boundary, not import boundary"
// pattern api/internal/authz.HTTPAuthorizer calls into (see that package's
// doc comment). It resolves a caller's credentials (session cookie or
// service-token Bearer header) to an identity, using session.Manager for
// both -- a human session and /alerting's service token are both just
// signed tokens with a different Role claim, so one validation path
// handles both, and the Role claim (not which header carried it) is what
// determines whether the result looks like a human or a service identity.
package authhandler
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
"github.com/sentry/sentry/enterprise/internal/session"
)
// SessionCookieName matches the name api/internal/authz.HTTPAuthorizer's
// tests and doc comments already assume ("sentry_session").
const SessionCookieName = "sentry_session"
// Features reports which SSO mechanisms are configured -- the response
// shape /docs/phase-4-rbac-design.md's "Web UI boundary" section commits
// to ({"sso_configured", "oidc_enabled", "saml_enabled"}), so web can
// show/hide enterprise settings sections as a runtime capability check
// rather than a conditional import.
type Features struct {
OIDCEnabled bool
SAMLEnabled bool
}
type Handler struct {
logger *slog.Logger
manager *session.Manager
features Features
}
func New(logger *slog.Logger, manager *session.Manager, features Features) *Handler {
return &Handler{logger: logger, manager: manager, features: features}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /internal/authorize", h.handleAuthorize)
mux.HandleFunc("GET /auth/features", h.handleFeatures)
}
type featuresResponse struct {
SSOConfigured bool `json:"sso_configured"`
OIDCEnabled bool `json:"oidc_enabled"`
SAMLEnabled bool `json:"saml_enabled"`
}
func (h *Handler) handleFeatures(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(featuresResponse{
SSOConfigured: h.features.OIDCEnabled || h.features.SAMLEnabled,
OIDCEnabled: h.features.OIDCEnabled,
SAMLEnabled: h.features.SAMLEnabled,
})
}
type authorizeResponse struct {
TenantID string `json:"tenant_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
}
// handleAuthorize checks the Authorization Bearer header first (the
// service-token path /alerting uses), falling back to the session
// cookie (the human path a browser sends). Both resolve through the same
// session.Manager.Validate -- see the package doc comment for why that's
// safe: the Role claim inside the token is what determines the result,
// not which header it arrived on.
func (h *Handler) handleAuthorize(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r.Header.Get("Authorization"))
if token == "" {
if c, err := r.Cookie(SessionCookieName); err == nil {
token = c.Value
}
}
if token == "" {
http.Error(w, "no credentials presented", http.StatusUnauthorized)
return
}
claims, err := h.manager.Validate(token)
if err != nil {
http.Error(w, "invalid or expired credentials", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(authorizeResponse{
TenantID: claims.TenantID,
UserID: claims.UserID,
Role: claims.Role,
})
}
func bearerToken(header string) string {
const prefix = "Bearer "
if !strings.HasPrefix(header, prefix) {
return ""
}
return strings.TrimPrefix(header, prefix)
}
@@ -0,0 +1,175 @@
package authhandler
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/sentry/sentry/enterprise/internal/session"
)
func testHandler(t *testing.T) (*Handler, *session.Manager) {
t.Helper()
m, err := session.NewManager([]byte("this-is-a-32-byte-test-signing-key!"))
if err != nil {
t.Fatalf("session.NewManager: %v", err)
}
return New(slog.New(slog.NewTextHandler(io.Discard, nil)), m, Features{}), m
}
func doAuthorize(t *testing.T, h *Handler, mutate func(*http.Request)) *httptest.ResponseRecorder {
t.Helper()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPost, "/internal/authorize", nil)
if mutate != nil {
mutate(req)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func TestAuthorizeViaServiceToken(t *testing.T) {
h, m := testHandler(t)
token, err := m.IssueServiceToken("alerting")
if err != nil {
t.Fatalf("IssueServiceToken: %v", err)
}
rec := doAuthorize(t, h, func(r *http.Request) {
r.Header.Set("Authorization", "Bearer "+token)
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var body authorizeResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decoding response: %v", err)
}
if body.Role != "service" || body.TenantID != "" || body.UserID != "" {
t.Fatalf("unexpected response: %+v", body)
}
}
func TestAuthorizeViaSessionCookie(t *testing.T) {
h, m := testHandler(t)
token, err := m.IssueUserSession("acme", "u1", "editor")
if err != nil {
t.Fatalf("IssueUserSession: %v", err)
}
rec := doAuthorize(t, h, func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: token})
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var body authorizeResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decoding response: %v", err)
}
if body.TenantID != "acme" || body.UserID != "u1" || body.Role != "editor" {
t.Fatalf("unexpected response: %+v", body)
}
}
func TestAuthorizeBearerTakesPrecedenceOverCookie(t *testing.T) {
h, m := testHandler(t)
serviceToken, err := m.IssueServiceToken("alerting")
if err != nil {
t.Fatalf("IssueServiceToken: %v", err)
}
sessionToken, err := m.IssueUserSession("acme", "u1", "viewer")
if err != nil {
t.Fatalf("IssueUserSession: %v", err)
}
rec := doAuthorize(t, h, func(r *http.Request) {
r.Header.Set("Authorization", "Bearer "+serviceToken)
r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: sessionToken})
})
var body authorizeResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decoding response: %v", err)
}
if body.Role != "service" {
t.Fatalf("expected the Bearer service token to win, got role %q", body.Role)
}
}
func TestAuthorizeNoCredentialsIsUnauthorized(t *testing.T) {
h, _ := testHandler(t)
rec := doAuthorize(t, h, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestAuthorizeInvalidTokenIsUnauthorized(t *testing.T) {
h, _ := testHandler(t)
rec := doAuthorize(t, h, func(r *http.Request) {
r.Header.Set("Authorization", "Bearer not-a-real-token")
})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestFeaturesReflectsConfiguredMechanisms(t *testing.T) {
m, err := session.NewManager([]byte("this-is-a-32-byte-test-signing-key!"))
if err != nil {
t.Fatalf("session.NewManager: %v", err)
}
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), m, Features{OIDCEnabled: true, SAMLEnabled: false})
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/features", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var body featuresResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decoding response: %v", err)
}
if !body.SSOConfigured || !body.OIDCEnabled || body.SAMLEnabled {
t.Fatalf("unexpected features response: %+v", body)
}
}
func TestFeaturesAllFalseWhenNothingConfigured(t *testing.T) {
h, _ := testHandler(t)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/features", nil))
var body featuresResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decoding response: %v", err)
}
if body.SSOConfigured || body.OIDCEnabled || body.SAMLEnabled {
t.Fatalf("expected all-false features when nothing is configured, got %+v", body)
}
}
func TestAuthorizeTokenFromWrongManagerIsUnauthorized(t *testing.T) {
h, _ := testHandler(t)
otherManager, err := session.NewManager([]byte("a-completely-different-32-byte-key!"))
if err != nil {
t.Fatalf("session.NewManager: %v", err)
}
token, err := otherManager.IssueServiceToken("alerting")
if err != nil {
t.Fatalf("IssueServiceToken: %v", err)
}
rec := doAuthorize(t, h, func(r *http.Request) {
r.Header.Set("Authorization", "Bearer "+token)
})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
+91
View File
@@ -0,0 +1,91 @@
// Package config loads enterprise-auth's configuration from environment
// variables, same convention as every other Go service in this repo.
package config
import (
"fmt"
"os"
)
type Config struct {
HTTPListenAddr string
Postgres PostgresConfig
OIDC OIDCConfig
SAML SAMLConfig
SessionSigningKey []byte
}
type PostgresConfig struct {
Addr string
Database string
Username string
Password string
}
// OIDCConfig is optional -- a deployment might configure OIDC, SAML,
// both, or (during early rollout) neither yet. Load() doesn't fail if
// these are unset; internal/oidc.New is only called once IssuerURL is
// actually present.
type OIDCConfig struct {
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
}
// SAMLConfig is likewise optional. Note this only records *presence* --
// enough for /auth/features (internal/authhandler) to report
// saml_enabled -- it does not itself fetch/parse IDPMetadataURL into the
// *saml.EntityDescriptor internal/saml.New requires; that fetch (and the
// login/ACS HTTP handlers that would use it) is deferred, same as OIDC's
// login/callback handlers -- see cmd/enterprise-auth/main.go's doc
// comment.
type SAMLConfig struct {
EntityID string
ACSURL string
IDPMetadataURL string
}
func Load() (Config, error) {
cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8082"),
Postgres: PostgresConfig{
Addr: getenv("POSTGRES_ADDR", "localhost:5432"),
Database: getenv("POSTGRES_DATABASE", "sentry_metadata"),
Username: getenv("POSTGRES_USERNAME", "sentry"),
Password: getenv("POSTGRES_PASSWORD", ""),
},
OIDC: OIDCConfig{
IssuerURL: getenv("OIDC_ISSUER_URL", ""),
ClientID: getenv("OIDC_CLIENT_ID", ""),
ClientSecret: getenv("OIDC_CLIENT_SECRET", ""),
RedirectURL: getenv("OIDC_REDIRECT_URL", ""),
},
SAML: SAMLConfig{
EntityID: getenv("SAML_ENTITY_ID", ""),
ACSURL: getenv("SAML_ACS_URL", ""),
IDPMetadataURL: getenv("SAML_IDP_METADATA_URL", ""),
},
}
// Required, unlike OIDC/SAML above: every enterprise-auth deployment
// issues and validates session/service tokens (internal/session),
// even one that hasn't configured any IdP yet. 32 bytes matches
// internal/session.MinSigningKeyBytes -- not imported here to avoid
// a config->session dependency for one constant, but the two values
// must be kept in sync.
signingKey := getenv("ENTERPRISE_SESSION_SIGNING_KEY", "")
if len(signingKey) < 32 {
return Config{}, fmt.Errorf("ENTERPRISE_SESSION_SIGNING_KEY must be set to at least 32 bytes (got %d)", len(signingKey))
}
cfg.SessionSigningKey = []byte(signingKey)
return cfg, nil
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+116
View File
@@ -0,0 +1,116 @@
// Package oidc wires coreos/go-oidc into a small relying-party client:
// discovery, the login redirect, and code exchange + ID token
// verification. Deliberately thin -- this package answers "is this
// person who they say they are, and what's their email/subject" and
// nothing about tenants/roles; internal/session maps a verified identity
// to a tenant.ID via tenant.TrustFromValidatedSession, kept as a
// separate concern per /docs/phase-4-isolation-design.md.
package oidc
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
goidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type Config struct {
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
// Scopes beyond the mandatory "openid" -- "email" and "profile" are
// the common additions IdPs support without extra configuration.
Scopes []string
}
// Provider wraps a discovered OIDC issuer and the oauth2 config derived
// from it. Construction does real network discovery (GET
// {issuer}/.well-known/openid-configuration) -- see New's doc comment.
type Provider struct {
verifier *goidc.IDTokenVerifier
oauth2 oauth2.Config
}
// Claims is the subset of ID token claims Sentry actually uses. Extend
// deliberately, not by passing the raw claim map further up the stack --
// every field added here is a field internal/session has to decide how
// to trust.
type Claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
// New performs OIDC discovery against cfg.IssuerURL. Real network I/O --
// call once at startup (or lazily, cached), not per request.
func New(ctx context.Context, cfg Config) (*Provider, error) {
if cfg.IssuerURL == "" || cfg.ClientID == "" || cfg.RedirectURL == "" {
return nil, fmt.Errorf("oidc: IssuerURL, ClientID, and RedirectURL are required")
}
issuer, err := goidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, fmt.Errorf("oidc: discovering issuer %q: %w", cfg.IssuerURL, err)
}
scopes := append([]string{goidc.ScopeOpenID}, cfg.Scopes...)
return &Provider{
verifier: issuer.Verifier(&goidc.Config{ClientID: cfg.ClientID}),
oauth2: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: issuer.Endpoint(),
Scopes: scopes,
},
}, nil
}
// NewState generates a CSRF-protection state value for the login
// redirect. The caller is responsible for storing it (session/cookie)
// and comparing it against what comes back to the callback endpoint --
// this package doesn't hold any server-side state itself.
func NewState() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("oidc: generating state: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// AuthCodeURL is where the browser gets redirected to start login.
func (p *Provider) AuthCodeURL(state string) string {
return p.oauth2.AuthCodeURL(state)
}
// Exchange trades an authorization code for tokens and returns the
// verified ID token's claims. Verification (signature, issuer,
// audience, expiry) happens inside p.verifier.Verify -- this is the
// step that actually establishes trust, not just "we got a token back."
func (p *Provider) Exchange(ctx context.Context, code string) (*Claims, error) {
token, err := p.oauth2.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("oidc: exchanging code: %w", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
return nil, fmt.Errorf("oidc: token response had no id_token")
}
idToken, err := p.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("oidc: verifying id_token: %w", err)
}
var claims Claims
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("oidc: decoding claims: %w", err)
}
return &claims, nil
}
+75
View File
@@ -0,0 +1,75 @@
package oidc
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestNewRejectsMissingConfig(t *testing.T) {
_, err := New(context.Background(), Config{})
if err == nil {
t.Fatalf("expected an error for an empty config")
}
}
// TestNewDiscoversRealIssuer spins up a real HTTP server serving a
// minimal valid OIDC discovery document and confirms New() actually
// performs discovery against it successfully -- not just "the code
// compiles and looks plausible." Doesn't cover the full Exchange() flow
// (needs a signed JWKS/token response, real crypto scaffolding better
// suited to task 5's end-to-end auth integration tests), but discovery
// is exactly the step that would silently break on a URL-construction or
// JSON-shape mistake, so it's worth actually running.
func TestNewDiscoversRealIssuer(t *testing.T) {
mux := http.NewServeMux()
srv := httptest.NewServer(mux)
defer srv.Close()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/authorize",
"token_endpoint": srv.URL + "/token",
"jwks_uri": srv.URL + "/jwks",
"userinfo_endpoint": srv.URL + "/userinfo",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{}})
})
p, err := New(context.Background(), Config{
IssuerURL: srv.URL, ClientID: "sentry", ClientSecret: "secret", RedirectURL: "http://localhost/callback",
})
if err != nil {
t.Fatalf("New: %v", err)
}
if p.AuthCodeURL("state123") == "" {
t.Fatalf("expected a non-empty auth code URL")
}
}
func TestNewStateIsNonEmptyAndUnique(t *testing.T) {
a, err := NewState()
if err != nil {
t.Fatalf("NewState: %v", err)
}
b, err := NewState()
if err != nil {
t.Fatalf("NewState: %v", err)
}
if a == "" || b == "" {
t.Fatalf("expected non-empty state values")
}
if a == b {
t.Fatalf("expected two calls to NewState to produce different values")
}
}
+254
View File
@@ -0,0 +1,254 @@
// Package rbacstore is the pgx-backed CRUD layer over the tenant/user/
// role schema (metadata/migrations/0017-0021) described in
// /docs/phase-4-rbac-design.md: users (global SSO identity), tenants,
// and tenant_memberships (per-tenant role). It uses the same shared
// "sentry" Postgres role/pool every other metadata store does (unlike
// enterprise/internal/audit's deliberately separate, narrower-granted
// pool) -- ordinary read/write CRUD on control-plane config, not an
// append-only ledger, so it has no analogous reason to restrict its own
// write access.
//
// This package is the storage building block a future OIDC/SAML login
// HTTP handler would call to resolve "which tenant/role does this SSO
// identity map to" and issue a session (internal/session) accordingly --
// that handler itself isn't built yet (see cmd/enterprise-auth/main.go's
// doc comment), so today rbacstore's only production caller is
// -mint-service-token's future tenant-aware successor and its own tests.
// dashboard_permissions and data_sources (also part of the schema) don't
// have CRUD here yet -- no caller needs them until dashboards' handler
// wiring reads per-resource grants, named as deferred in task 5's
// summary.
package rbacstore
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned by Get-shaped methods when the row doesn't exist.
var ErrNotFound = errors.New("rbacstore: not found")
type User struct {
ID string
Email string
DisplayName string
SSOSubject string
CreatedAt time.Time
UpdatedAt time.Time
}
type Tenant struct {
ID string
DisplayName string
Status string
OwnerUserID string // empty until a first Owner is assigned
CreatedAt time.Time
UpdatedAt time.Time
}
// Role mirrors api/internal/authz.Role's string values, kept as a plain
// string here rather than importing authz -- rbacstore is enterprise
// code and api/internal/authz is core; enterprise may depend on
// nothing-shaped-like-an-import-from-core per the module boundary
// (see /docs/phase-4-isolation-design.md), even though the reverse
// (core importing enterprise) is the one hack/check-tenant-boundary.sh
// actually enforces. Values must stay in sync with authz.Role's
// constants by convention, verified by rbacstore_test.go.
type Role string
const (
RoleViewer Role = "viewer"
RoleEditor Role = "editor"
RoleAdmin Role = "admin"
RoleOwner Role = "owner"
)
type Membership struct {
TenantID string
UserID string
Role Role
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// UpsertUserBySSO finds an existing user by ssoSubject, falling back to
// email (covers a user pre-provisioned by an Admin before their first
// SSO login -- see 0017_create_users.sql's ssoSubject nullability
// comment), or creates a new row. This is the one place a user's
// display_name/ssoSubject are refreshed from IdP claims on every login,
// matching a typical SSO-managed-identity pattern (the IdP is the
// source of truth for name/email; role assignment stays local, per
// /docs/phase-4-rbac-design.md's "manual role assignment" baseline).
func (s *Store) UpsertUserBySSO(ctx context.Context, ssoSubject, email, displayName string) (*User, error) {
if ssoSubject == "" || email == "" {
return nil, fmt.Errorf("rbacstore: ssoSubject and email are required")
}
var u User
row := s.pool.QueryRow(ctx, `
INSERT INTO users (id, email, display_name, sso_subject)
VALUES ($1, $2, $3, $4)
ON CONFLICT (email) DO UPDATE
SET display_name = EXCLUDED.display_name,
sso_subject = EXCLUDED.sso_subject,
updated_at = now()
RETURNING id, email, display_name, sso_subject, created_at, updated_at`,
uuid.NewString(), email, displayName, ssoSubject)
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.SSOSubject, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("rbacstore: upserting user: %w", err)
}
return &u, nil
}
func (s *Store) GetUser(ctx context.Context, id string) (*User, error) {
var u User
row := s.pool.QueryRow(ctx, `
SELECT id, email, display_name, sso_subject, created_at, updated_at
FROM users WHERE id = $1`, id)
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.SSOSubject, &u.CreatedAt, &u.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("rbacstore: getting user: %w", err)
}
return &u, nil
}
// CreateTenant inserts a new tenant in 'provisioning' status -- callers
// (future tenant-provisioning code, per /docs/phase-4-isolation-design.md's
// ordered provisioning state machine) move it to 'active' via
// SetTenantStatus only after ClickHouse/Tantivy provisioning succeeds.
func (s *Store) CreateTenant(ctx context.Context, id, displayName string) (*Tenant, error) {
if id == "" || displayName == "" {
return nil, fmt.Errorf("rbacstore: id and displayName are required")
}
var t Tenant
row := s.pool.QueryRow(ctx, `
INSERT INTO tenants (id, display_name, status)
VALUES ($1, $2, 'provisioning')
RETURNING id, display_name, status, coalesce(owner_user_id::text, ''), created_at, updated_at`,
id, displayName)
if err := row.Scan(&t.ID, &t.DisplayName, &t.Status, &t.OwnerUserID, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, fmt.Errorf("rbacstore: creating tenant: %w", err)
}
return &t, nil
}
func (s *Store) GetTenant(ctx context.Context, id string) (*Tenant, error) {
var t Tenant
row := s.pool.QueryRow(ctx, `
SELECT id, display_name, status, coalesce(owner_user_id::text, ''), created_at, updated_at
FROM tenants WHERE id = $1`, id)
if err := row.Scan(&t.ID, &t.DisplayName, &t.Status, &t.OwnerUserID, &t.CreatedAt, &t.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("rbacstore: getting tenant: %w", err)
}
return &t, nil
}
// SetTenantStatus is the only way a tenant's status column changes --
// every tenant-resolution path elsewhere must re-check this via
// GetTenant, never cache/assume 'active', per
// /docs/phase-4-isolation-design.md's provisioning gate.
func (s *Store) SetTenantStatus(ctx context.Context, id, status string) error {
tag, err := s.pool.Exec(ctx, `UPDATE tenants SET status = $2, updated_at = now() WHERE id = $1`, id, status)
if err != nil {
return fmt.Errorf("rbacstore: setting tenant status: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// SetOwner sets a tenant's owner_user_id -- separate from
// SetMembership because the schema's Owner is a tenant-level column
// (exactly one, non-removable except by itself/platform break-glass per
// /docs/phase-4-rbac-design.md), not just the highest tenant_memberships
// role. Callers are expected to also call SetMembership(tenantID,
// userID, RoleOwner) so the membership table and this column agree --
// this package doesn't wrap both in one method because tenant creation
// (no owner yet) and ownership transfer (existing owner changes) are
// different call sites with different validation needs.
func (s *Store) SetOwner(ctx context.Context, tenantID, userID string) error {
tag, err := s.pool.Exec(ctx, `UPDATE tenants SET owner_user_id = $2, updated_at = now() WHERE id = $1`, tenantID, userID)
if err != nil {
return fmt.Errorf("rbacstore: setting tenant owner: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// SetMembership upserts a user's role for a tenant -- the sole mutation
// path for tenant_memberships, so every role change naturally funnels
// through one method a future audit-log hook (EventRoleChange, see
// enterprise/internal/audit) can wrap.
func (s *Store) SetMembership(ctx context.Context, tenantID, userID string, role Role) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO tenant_memberships (id, tenant_id, user_id, role)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_id, user_id) DO UPDATE
SET role = EXCLUDED.role, updated_at = now()`,
uuid.NewString(), tenantID, userID, string(role))
if err != nil {
return fmt.Errorf("rbacstore: setting membership: %w", err)
}
return nil
}
func (s *Store) GetMembership(ctx context.Context, tenantID, userID string) (*Membership, error) {
var m Membership
var role string
row := s.pool.QueryRow(ctx, `
SELECT tenant_id, user_id, role FROM tenant_memberships
WHERE tenant_id = $1 AND user_id = $2`, tenantID, userID)
if err := row.Scan(&m.TenantID, &m.UserID, &role); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("rbacstore: getting membership: %w", err)
}
m.Role = Role(role)
return &m, nil
}
// ListMembershipsForUser supports "which tenants can this user act in,
// and at what role" -- the shape a login/session-issuance handler needs
// when a user belongs to more than one tenant and must pick (or be
// asked to pick) which one to act as for a given session.
func (s *Store) ListMembershipsForUser(ctx context.Context, userID string) ([]Membership, error) {
rows, err := s.pool.Query(ctx, `
SELECT tenant_id, user_id, role FROM tenant_memberships WHERE user_id = $1 ORDER BY tenant_id`, userID)
if err != nil {
return nil, fmt.Errorf("rbacstore: listing memberships: %w", err)
}
defer rows.Close()
var out []Membership
for rows.Next() {
var m Membership
var role string
if err := rows.Scan(&m.TenantID, &m.UserID, &role); err != nil {
return nil, fmt.Errorf("rbacstore: scanning membership: %w", err)
}
m.Role = Role(role)
out = append(out, m)
}
return out, rows.Err()
}
@@ -0,0 +1,227 @@
// Integration tests against a real Postgres -- rbacstore's whole job is
// SQL (upserts, FK constraints, unique constraints on
// (tenant_id, user_id)/(dashboard_id, user_id)), so a mocked pool
// wouldn't actually exercise it. Skipped unless RBACSTORE_TEST_POSTGRES_ADDR
// is set; run via:
//
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/enterprise \
// -e RBACSTORE_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
// -e RBACSTORE_TEST_POSTGRES_PASSWORD=sentry-dev-only \
// golang:1.25-alpine go test ./internal/rbacstore/... -v
package rbacstore
import (
"context"
"fmt"
"os"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func testStore(t *testing.T) *Store {
t.Helper()
addr := os.Getenv("RBACSTORE_TEST_POSTGRES_ADDR")
if addr == "" {
t.Skip("RBACSTORE_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
}
password := os.Getenv("RBACSTORE_TEST_POSTGRES_PASSWORD")
dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("opening pool: %v", err)
}
t.Cleanup(pool.Close)
return NewStore(pool)
}
// uniqueTestTenant/uniqueTestEmail avoid collisions across repeated test
// runs against a persistent dev Postgres (no cleanup step deletes rows,
// unlike audit's cleanupAuditLog -- these rows are meant to look like
// real, retained control-plane data, not scratch state).
func uniqueSuffix() string {
return uuid.NewString()[:8]
}
func TestCreateAndGetTenant(t *testing.T) {
s := testStore(t)
id := "test-tenant-" + uniqueSuffix()
created, err := s.CreateTenant(context.Background(), id, "Test Tenant")
if err != nil {
t.Fatalf("CreateTenant: %v", err)
}
if created.Status != "provisioning" {
t.Fatalf("new tenant status = %q, want provisioning", created.Status)
}
if created.OwnerUserID != "" {
t.Fatalf("new tenant owner = %q, want empty until an Owner is assigned", created.OwnerUserID)
}
got, err := s.GetTenant(context.Background(), id)
if err != nil {
t.Fatalf("GetTenant: %v", err)
}
if got.DisplayName != "Test Tenant" {
t.Fatalf("DisplayName = %q, want %q", got.DisplayName, "Test Tenant")
}
}
func TestGetTenantNotFound(t *testing.T) {
s := testStore(t)
if _, err := s.GetTenant(context.Background(), "does-not-exist-"+uniqueSuffix()); err != ErrNotFound {
t.Fatalf("GetTenant error = %v, want ErrNotFound", err)
}
}
func TestSetTenantStatusGatesProvisioning(t *testing.T) {
s := testStore(t)
id := "test-tenant-" + uniqueSuffix()
if _, err := s.CreateTenant(context.Background(), id, "Test Tenant"); err != nil {
t.Fatalf("CreateTenant: %v", err)
}
if err := s.SetTenantStatus(context.Background(), id, "active"); err != nil {
t.Fatalf("SetTenantStatus: %v", err)
}
got, err := s.GetTenant(context.Background(), id)
if err != nil {
t.Fatalf("GetTenant: %v", err)
}
if got.Status != "active" {
t.Fatalf("Status = %q, want active", got.Status)
}
}
func TestSetTenantStatusNotFound(t *testing.T) {
s := testStore(t)
if err := s.SetTenantStatus(context.Background(), "does-not-exist-"+uniqueSuffix(), "active"); err != ErrNotFound {
t.Fatalf("SetTenantStatus error = %v, want ErrNotFound", err)
}
}
func TestUpsertUserBySSOCreatesThenUpdates(t *testing.T) {
s := testStore(t)
email := "user-" + uniqueSuffix() + "@example.com"
u1, err := s.UpsertUserBySSO(context.Background(), "sub-1", email, "First Name")
if err != nil {
t.Fatalf("UpsertUserBySSO (create): %v", err)
}
if u1.SSOSubject != "sub-1" || u1.DisplayName != "First Name" {
t.Fatalf("unexpected user: %+v", u1)
}
// Second call with the same email (as if the IdP changed the
// display name, or re-issued a new "sub") must update the same row,
// not create a second one -- email is the natural key here.
u2, err := s.UpsertUserBySSO(context.Background(), "sub-2", email, "Updated Name")
if err != nil {
t.Fatalf("UpsertUserBySSO (update): %v", err)
}
if u2.ID != u1.ID {
t.Fatalf("upsert created a second row: first ID %q, second ID %q", u1.ID, u2.ID)
}
if u2.SSOSubject != "sub-2" || u2.DisplayName != "Updated Name" {
t.Fatalf("upsert did not refresh IdP-sourced fields: %+v", u2)
}
}
func TestSetOwnerAndMembershipRoundTrip(t *testing.T) {
s := testStore(t)
ctx := context.Background()
tenantID := "test-tenant-" + uniqueSuffix()
email := "owner-" + uniqueSuffix() + "@example.com"
if _, err := s.CreateTenant(ctx, tenantID, "Test Tenant"); err != nil {
t.Fatalf("CreateTenant: %v", err)
}
user, err := s.UpsertUserBySSO(ctx, "sub-owner", email, "Owner")
if err != nil {
t.Fatalf("UpsertUserBySSO: %v", err)
}
if err := s.SetMembership(ctx, tenantID, user.ID, RoleOwner); err != nil {
t.Fatalf("SetMembership: %v", err)
}
if err := s.SetOwner(ctx, tenantID, user.ID); err != nil {
t.Fatalf("SetOwner: %v", err)
}
tenant, err := s.GetTenant(ctx, tenantID)
if err != nil {
t.Fatalf("GetTenant: %v", err)
}
if tenant.OwnerUserID != user.ID {
t.Fatalf("tenant OwnerUserID = %q, want %q", tenant.OwnerUserID, user.ID)
}
membership, err := s.GetMembership(ctx, tenantID, user.ID)
if err != nil {
t.Fatalf("GetMembership: %v", err)
}
if membership.Role != RoleOwner {
t.Fatalf("membership role = %q, want owner", membership.Role)
}
// Re-setting the membership (e.g. a role change) must update in
// place, not create a duplicate row for the same (tenant, user).
if err := s.SetMembership(ctx, tenantID, user.ID, RoleAdmin); err != nil {
t.Fatalf("SetMembership (update): %v", err)
}
membership, err = s.GetMembership(ctx, tenantID, user.ID)
if err != nil {
t.Fatalf("GetMembership after update: %v", err)
}
if membership.Role != RoleAdmin {
t.Fatalf("membership role after update = %q, want admin", membership.Role)
}
}
func TestGetMembershipNotFound(t *testing.T) {
s := testStore(t)
ctx := context.Background()
tenantID := "test-tenant-" + uniqueSuffix()
if _, err := s.CreateTenant(ctx, tenantID, "Test Tenant"); err != nil {
t.Fatalf("CreateTenant: %v", err)
}
user, err := s.UpsertUserBySSO(ctx, "sub-no-membership", "no-membership-"+uniqueSuffix()+"@example.com", "Nobody")
if err != nil {
t.Fatalf("UpsertUserBySSO: %v", err)
}
if _, err := s.GetMembership(ctx, tenantID, user.ID); err != ErrNotFound {
t.Fatalf("GetMembership error = %v, want ErrNotFound", err)
}
}
func TestListMembershipsForUserAcrossTenants(t *testing.T) {
s := testStore(t)
ctx := context.Background()
user, err := s.UpsertUserBySSO(ctx, "sub-multi", "multi-"+uniqueSuffix()+"@example.com", "Multi Tenant User")
if err != nil {
t.Fatalf("UpsertUserBySSO: %v", err)
}
tenantA := "test-tenant-a-" + uniqueSuffix()
tenantB := "test-tenant-b-" + uniqueSuffix()
if _, err := s.CreateTenant(ctx, tenantA, "Tenant A"); err != nil {
t.Fatalf("CreateTenant A: %v", err)
}
if _, err := s.CreateTenant(ctx, tenantB, "Tenant B"); err != nil {
t.Fatalf("CreateTenant B: %v", err)
}
if err := s.SetMembership(ctx, tenantA, user.ID, RoleViewer); err != nil {
t.Fatalf("SetMembership A: %v", err)
}
if err := s.SetMembership(ctx, tenantB, user.ID, RoleAdmin); err != nil {
t.Fatalf("SetMembership B: %v", err)
}
memberships, err := s.ListMembershipsForUser(ctx, user.ID)
if err != nil {
t.Fatalf("ListMembershipsForUser: %v", err)
}
if len(memberships) != 2 {
t.Fatalf("got %d memberships, want 2: %+v", len(memberships), memberships)
}
}
+173
View File
@@ -0,0 +1,173 @@
// Package saml wires crewjam/saml into a small SP (service provider)
// client: build the login redirect, and validate/parse an incoming
// assertion. Deliberately not using crewjam's samlsp.Middleware, which
// owns its own session/cookie handling -- Sentry's session concept lives
// in internal/session, one layer up, so this package only does the SAML
// protocol mechanics (XML signing/parsing), per the explicit instruction
// not to hand-roll that crypto.
package saml
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"net/http"
"net/url"
"time"
"github.com/crewjam/saml"
)
type Config struct {
// EntityID identifies Sentry to the IdP, conventionally Sentry's own
// metadata URL.
EntityID string
// ACSURL is where the IdP redirects the browser back to with the
// assertion (the "assertion consumer service" endpoint).
ACSURL string
// IDPMetadata is the IdP's metadata XML, fetched out-of-band (IdP
// admin provides a URL or a file) and parsed by the caller via
// samltypes/crewjam's metadata parsing -- kept out of this package's
// constructor so it isn't doing its own network fetch of
// admin-supplied, potentially untrusted URLs.
IDPMetadata *saml.EntityDescriptor
// Certificate/Key sign outgoing AuthnRequests and are required by
// crewjam/saml's ServiceProvider even when the IdP doesn't mandate
// signed requests. If nil, New generates a self-signed keypair --
// fine for development, but a real deployment should supply a
// certificate its IdP is configured to trust for encrypted
// assertions, not rely on the generated one long-term.
Certificate *tls.Certificate
}
type ServiceProvider struct {
sp saml.ServiceProvider
}
func New(cfg Config) (*ServiceProvider, error) {
if cfg.EntityID == "" || cfg.ACSURL == "" {
return nil, fmt.Errorf("saml: EntityID and ACSURL are required")
}
if cfg.IDPMetadata == nil {
return nil, fmt.Errorf("saml: IDPMetadata is required")
}
cert := cfg.Certificate
if cert == nil {
generated, err := selfSignedCert()
if err != nil {
return nil, fmt.Errorf("saml: generating a self-signed certificate: %w", err)
}
cert = generated
}
acsURL, err := url.Parse(cfg.ACSURL)
if err != nil {
return nil, fmt.Errorf("saml: parsing ACSURL: %w", err)
}
entityID, err := url.Parse(cfg.EntityID)
if err != nil {
return nil, fmt.Errorf("saml: parsing EntityID: %w", err)
}
return &ServiceProvider{
sp: saml.ServiceProvider{
Key: cert.PrivateKey.(*rsa.PrivateKey),
Certificate: parseLeaf(cert),
MetadataURL: *entityID,
AcsURL: *acsURL,
IDPMetadata: cfg.IDPMetadata,
},
}, nil
}
// LoginURL builds the redirect that starts SP-initiated SSO. relayState
// round-trips through the IdP and comes back with the response --
// typically where to send the browser after login completes, validated
// by the caller the same way OIDC's state parameter is (this package
// doesn't store it).
func (s *ServiceProvider) LoginURL(relayState string) (string, error) {
req, err := s.sp.MakeAuthenticationRequest(s.sp.GetSSOBindingLocation(saml.HTTPRedirectBinding), saml.HTTPRedirectBinding, saml.HTTPPostBinding)
if err != nil {
return "", fmt.Errorf("saml: building authentication request: %w", err)
}
redirectURL, err := req.Redirect(relayState, &s.sp)
if err != nil {
return "", fmt.Errorf("saml: building redirect URL: %w", err)
}
return redirectURL.String(), nil
}
// Claims is the subset of an assertion Sentry uses -- same "extend
// deliberately" reasoning as oidc.Claims.
type Claims struct {
NameID string
Email string
}
// ParseResponse validates an incoming SAML response (signature, issuer,
// audience, timing) and extracts the fields Sentry cares about. This is
// the step that actually establishes trust -- crewjam/saml's
// ParseResponse does the XML signature verification, not this package.
func (s *ServiceProvider) ParseResponse(r *http.Request, possibleRequestIDs []string) (*Claims, error) {
assertion, err := s.sp.ParseResponse(r, possibleRequestIDs)
if err != nil {
return nil, fmt.Errorf("saml: parsing/validating response: %w", err)
}
claims := &Claims{}
if assertion.Subject != nil && assertion.Subject.NameID != nil {
claims.NameID = assertion.Subject.NameID.Value
}
for _, stmt := range assertion.AttributeStatements {
for _, attr := range stmt.Attributes {
if attr.Name == "email" || attr.Name == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" {
if len(attr.Values) > 0 {
claims.Email = attr.Values[0].Value
}
}
}
}
return claims, nil
}
func parseLeaf(cert *tls.Certificate) *x509.Certificate {
if len(cert.Certificate) == 0 {
return nil
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return nil
}
return leaf
}
// selfSignedCert generates a throwaway RSA keypair + certificate for
// development use, per Config.Certificate's doc comment.
func selfSignedCert() (*tls.Certificate, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, err
}
template := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "sentry-saml-sp-dev"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour * 365),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
return nil, err
}
return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil
}
+58
View File
@@ -0,0 +1,58 @@
package saml
import (
"testing"
"github.com/crewjam/saml"
)
func fakeIDPMetadata() *saml.EntityDescriptor {
return &saml.EntityDescriptor{
EntityID: "https://idp.example.com/metadata",
IDPSSODescriptors: []saml.IDPSSODescriptor{
{
SingleSignOnServices: []saml.Endpoint{
{Binding: saml.HTTPRedirectBinding, Location: "https://idp.example.com/sso"},
},
},
},
}
}
func TestNewRejectsMissingConfig(t *testing.T) {
_, err := New(Config{})
if err == nil {
t.Fatalf("expected an error for an empty config")
}
}
func TestNewRejectsMissingIDPMetadata(t *testing.T) {
_, err := New(Config{EntityID: "https://sentry.example.com/saml/metadata", ACSURL: "https://sentry.example.com/saml/acs"})
if err == nil {
t.Fatalf("expected an error when IDPMetadata is missing")
}
}
// TestLoginURLBuildsAgainstRealIDPMetadata exercises the actual
// crewjam/saml AuthnRequest-building and redirect-encoding path (deflate
// + base64 + query-string construction) against IdP metadata shaped like
// what a real IdP publishes, confirming the wiring produces a usable
// redirect rather than just "the code compiles."
func TestLoginURLBuildsAgainstRealIDPMetadata(t *testing.T) {
sp, err := New(Config{
EntityID: "https://sentry.example.com/saml/metadata",
ACSURL: "https://sentry.example.com/saml/acs",
IDPMetadata: fakeIDPMetadata(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
redirectURL, err := sp.LoginURL("relay-state-123")
if err != nil {
t.Fatalf("LoginURL: %v", err)
}
if redirectURL == "" {
t.Fatalf("expected a non-empty redirect URL")
}
}
+133
View File
@@ -0,0 +1,133 @@
// Package session issues and validates the signed tokens enterprise-auth
// hands back to /api's authz.HTTPAuthorizer -- both human sessions
// (issued after a successful OIDC/SAML login) and the long-lived
// RoleService credential /alerting's queryclient presents as a Bearer
// token. HS256/JWT rather than a bespoke format: boring, well-understood,
// and go-jose is already a dependency via oidc.
//
// One shared signing key (ENTERPRISE_SESSION_SIGNING_KEY) issues and
// validates both kinds of token -- there is deliberately no separate key
// per token type, since the Role claim (not the key used) is what
// authz.Role.Satisfies enforces downstream.
package session
import (
"errors"
"fmt"
"time"
josev4 "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
)
// Claims mirrors api/internal/authz.Identity's fields (TenantID, UserID,
// Role as a string) plus the standard registered JWT claims. Role is
// deliberately a plain string, not enterprise's own type, since its only
// consumer -- authz.Role -- is defined in core and this package must not
// import it (core must not import enterprise/, but the reverse also
// stays a network boundary here: this package has no reason to depend on
// api's Go types either).
type Claims struct {
TenantID string `json:"tenant_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Role string `json:"role"`
jwt.Claims
}
const (
// HumanSessionTTL matches a typical browser-session lifetime; re-auth
// happens via a fresh OIDC/SAML round trip, not silent refresh (no
// refresh-token flow is built yet -- named future work).
HumanSessionTTL = 12 * time.Hour
// ServiceTokenTTL is long-lived by design: /alerting runs as a
// continuously-deployed workload with no interactive re-auth path.
// Rotation is by redeploying alerting with a freshly issued token,
// not automatic refresh.
ServiceTokenTTL = 24 * 365 * time.Hour
// MinSigningKeyBytes: HS256 wants a key at least as long as its
// output (32 bytes/256 bits) to not weaken the MAC.
MinSigningKeyBytes = 32
)
// ErrInvalidToken covers every validation failure (bad signature,
// malformed token, expired) -- deliberately not distinguished further so
// callers can't be tempted to treat "expired" as a softer case than
// "forged"; both mean "do not trust this caller."
var ErrInvalidToken = errors.New("session: invalid or expired token")
type Manager struct {
signer josev4.Signer
key []byte
}
func NewManager(signingKey []byte) (*Manager, error) {
if len(signingKey) < MinSigningKeyBytes {
return nil, fmt.Errorf("session: signing key must be at least %d bytes, got %d", MinSigningKeyBytes, len(signingKey))
}
signer, err := josev4.NewSigner(
josev4.SigningKey{Algorithm: josev4.HS256, Key: signingKey},
(&josev4.SignerOptions{}).WithType("JWT"),
)
if err != nil {
return nil, fmt.Errorf("session: creating signer: %w", err)
}
return &Manager{signer: signer, key: signingKey}, nil
}
// IssueUserSession issues a human session token for a resolved
// tenant/user/role -- called only after a successful OIDC/SAML callback
// validates the caller's identity; this function trusts its inputs
// completely, same "one production call site, verified by review" shape
// as tenant.TrustFromValidatedSession.
func (m *Manager) IssueUserSession(tenantID, userID, role string) (string, error) {
now := time.Now()
claims := Claims{
TenantID: tenantID,
UserID: userID,
Role: role,
Claims: jwt.Claims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(HumanSessionTTL)),
},
}
return jwt.Signed(m.signer).Claims(claims).Serialize()
}
// IssueServiceToken issues a RoleService credential for a named machine
// caller (subject identifies which one, e.g. "alerting", for audit/
// revocation bookkeeping). TenantID/UserID are deliberately left empty:
// per /docs/phase-4-isolation-design.md's alerting↔api gap, the caller's
// tenant is resolved server-side per-request from the resource being
// acted on (alert_rules.tenant_id), never taken from the token or the
// request body -- a service token proves "this caller is alerting," not
// "this caller may act as tenant X."
func (m *Manager) IssueServiceToken(subject string) (string, error) {
now := time.Now()
claims := Claims{
Role: "service",
Claims: jwt.Claims{
Subject: subject,
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(ServiceTokenTTL)),
},
}
return jwt.Signed(m.signer).Claims(claims).Serialize()
}
// Validate verifies signature and expiry and returns the token's claims.
// Every failure mode collapses to ErrInvalidToken -- see its doc comment.
func (m *Manager) Validate(token string) (Claims, error) {
parsed, err := jwt.ParseSigned(token, []josev4.SignatureAlgorithm{josev4.HS256})
if err != nil {
return Claims{}, ErrInvalidToken
}
var claims Claims
if err := parsed.Claims(m.key, &claims); err != nil {
return Claims{}, ErrInvalidToken
}
if err := claims.Claims.Validate(jwt.Expected{}); err != nil {
return Claims{}, ErrInvalidToken
}
return claims, nil
}
+118
View File
@@ -0,0 +1,118 @@
package session
import (
"strings"
"testing"
"time"
"github.com/go-jose/go-jose/v4/jwt"
)
func testKey() []byte {
return []byte("this-is-a-32-byte-test-signing-key!")
}
func TestNewManagerRejectsShortKey(t *testing.T) {
if _, err := NewManager([]byte("too-short")); err == nil {
t.Fatal("expected an error for a signing key under 32 bytes")
}
}
func TestIssueAndValidateUserSession(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
token, err := m.IssueUserSession("acme", "u1", "editor")
if err != nil {
t.Fatalf("IssueUserSession: %v", err)
}
claims, err := m.Validate(token)
if err != nil {
t.Fatalf("Validate: %v", err)
}
if claims.TenantID != "acme" || claims.UserID != "u1" || claims.Role != "editor" {
t.Fatalf("unexpected claims: %+v", claims)
}
}
func TestIssueAndValidateServiceToken(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
token, err := m.IssueServiceToken("alerting")
if err != nil {
t.Fatalf("IssueServiceToken: %v", err)
}
claims, err := m.Validate(token)
if err != nil {
t.Fatalf("Validate: %v", err)
}
if claims.Role != "service" || claims.Subject != "alerting" {
t.Fatalf("unexpected claims: %+v", claims)
}
if claims.TenantID != "" || claims.UserID != "" {
t.Fatalf("service token must not carry a tenant/user -- tenant is resolved server-side per request, got %+v", claims)
}
}
func TestValidateRejectsTamperedToken(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
token, err := m.IssueUserSession("acme", "u1", "viewer")
if err != nil {
t.Fatalf("IssueUserSession: %v", err)
}
// Flip a character in the payload segment to simulate tampering.
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("expected a 3-segment JWT, got %d segments", len(parts))
}
tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2]
if _, err := m.Validate(tampered); err != ErrInvalidToken {
t.Fatalf("Validate(tampered) error = %v, want ErrInvalidToken", err)
}
}
func TestValidateRejectsWrongKey(t *testing.T) {
m1, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
m2, err := NewManager([]byte("a-completely-different-32-byte-key!"))
if err != nil {
t.Fatalf("NewManager: %v", err)
}
token, err := m1.IssueUserSession("acme", "u1", "viewer")
if err != nil {
t.Fatalf("IssueUserSession: %v", err)
}
if _, err := m2.Validate(token); err != ErrInvalidToken {
t.Fatalf("Validate with wrong key error = %v, want ErrInvalidToken", err)
}
}
func TestValidateRejectsExpiredToken(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
now := time.Now()
claims := Claims{
TenantID: "acme", UserID: "u1", Role: "viewer",
Claims: jwt.Claims{
IssuedAt: jwt.NewNumericDate(now.Add(-2 * time.Hour)),
Expiry: jwt.NewNumericDate(now.Add(-1 * time.Hour)),
},
}
token, err := jwt.Signed(m.signer).Claims(claims).Serialize()
if err != nil {
t.Fatalf("building an already-expired token: %v", err)
}
if _, err := m.Validate(token); err != ErrInvalidToken {
t.Fatalf("Validate(expired) error = %v, want ErrInvalidToken", err)
}
}
+67
View File
@@ -0,0 +1,67 @@
// Package tenant is the single source of truth for "which tenant is
// this request for" -- see /docs/phase-4-isolation-design.md's "TenantID:
// an honest framing, not an oversold one" section before changing
// anything here.
//
// The unexported field on ID and the single production constructor make
// *accidental* misuse cheap to audit (grep for call sites) -- they do
// not make misuse impossible by the Go compiler alone. The real
// invariant: TrustFromValidatedSession has exactly one production call
// site, verified by CI (hack/check-tenant-boundary.sh) and code review
// at every change to this package. The database/index grant layer in
// internal/chrunner and internal/searchclient is the actual backstop.
// Do not add a second exported or reflection-accessible construction
// path (e.g. an UnmarshalJSON method) without re-reading that design
// doc section first -- it exists specifically because a future
// "convenience" constructor is the most realistic way this boundary
// gets quietly reopened.
package tenant
import "context"
// ID identifies a tenant. The zero value is not a valid ID -- always
// check the bool from FromContext.
type ID struct {
value string
}
func (id ID) String() string {
return id.value
}
// contextKey is unexported specifically so nothing outside this package
// can set or shadow the context value via context.WithValue with a
// string or exported key -- see the design doc's "context key collision"
// gap.
type contextKey struct{}
// FromContext is the only read path for a request's tenant.
func FromContext(ctx context.Context) (ID, bool) {
id, ok := ctx.Value(contextKey{}).(ID)
return id, ok
}
// WithContext attaches id to ctx. Called once, by auth middleware, right
// after TrustFromValidatedSession.
func WithContext(ctx context.Context, id ID) context.Context {
return context.WithValue(ctx, contextKey{}, id)
}
// TrustFromValidatedSession is the only construction path from a raw
// string. In production code, DO NOT CALL OUTSIDE auth middleware
// (internal/session) -- enforced by hack/check-tenant-boundary.sh, which
// greps *.go files (excluding _test.go) for call sites outside an
// allowlist. Other packages' tests calling this directly is expected and
// fine: test code isn't attacker-controlled the way a network-facing
// handler is, so there's no separate "test constructor" here -- an
// earlier draft of this design proposed one living in a _test.go file,
// on the mistaken assumption that would make it importable by other
// packages' tests as a compiler-enforced guarantee. It doesn't: Go never
// compiles _test.go files into what other packages (or other packages'
// tests) import, so a same-package-only test constructor would have been
// unreachable from anywhere outside this package, including its
// intended callers. This function, called directly, is simpler and
// actually works.
func TrustFromValidatedSession(raw string) ID {
return ID{value: raw}
}
+37
View File
@@ -0,0 +1,37 @@
package tenant
import (
"context"
"testing"
)
func TestFromContextRoundTrip(t *testing.T) {
id := TrustFromValidatedSession("acme-corp")
ctx := WithContext(context.Background(), id)
got, ok := FromContext(ctx)
if !ok {
t.Fatalf("expected FromContext to find a tenant")
}
if got.String() != "acme-corp" {
t.Fatalf("got %q, want %q", got.String(), "acme-corp")
}
}
func TestFromContextMissing(t *testing.T) {
_, ok := FromContext(context.Background())
if ok {
t.Fatalf("expected no tenant in a bare context")
}
}
func TestFromContextDoesNotMatchUnrelatedStringKey(t *testing.T) {
// The unexported contextKey type is what closes the "collision" gap
// the design doc calls out -- a context.WithValue using a plain
// string key must not be found by FromContext.
ctx := context.WithValue(context.Background(), "tenant_id", "spoofed") //nolint:staticcheck
_, ok := FromContext(ctx)
if ok {
t.Fatalf("FromContext must not find a value set under an unrelated key type")
}
}