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:
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user