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

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

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

Also adds deploy/ (Go Operator + Helm chart, validated offline only --
no cluster was reachable in this environment).
This commit is contained in:
2026-08-13 22:16:59 -07:00
parent 9435115ab7
commit 3eb0f4c589
116 changed files with 8589 additions and 126 deletions
+116
View File
@@ -0,0 +1,116 @@
// Package oidc wires coreos/go-oidc into a small relying-party client:
// discovery, the login redirect, and code exchange + ID token
// verification. Deliberately thin -- this package answers "is this
// person who they say they are, and what's their email/subject" and
// nothing about tenants/roles; internal/session maps a verified identity
// to a tenant.ID via tenant.TrustFromValidatedSession, kept as a
// separate concern per /docs/phase-4-isolation-design.md.
package oidc
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
goidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type Config struct {
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
// Scopes beyond the mandatory "openid" -- "email" and "profile" are
// the common additions IdPs support without extra configuration.
Scopes []string
}
// Provider wraps a discovered OIDC issuer and the oauth2 config derived
// from it. Construction does real network discovery (GET
// {issuer}/.well-known/openid-configuration) -- see New's doc comment.
type Provider struct {
verifier *goidc.IDTokenVerifier
oauth2 oauth2.Config
}
// Claims is the subset of ID token claims Sentry actually uses. Extend
// deliberately, not by passing the raw claim map further up the stack --
// every field added here is a field internal/session has to decide how
// to trust.
type Claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
// New performs OIDC discovery against cfg.IssuerURL. Real network I/O --
// call once at startup (or lazily, cached), not per request.
func New(ctx context.Context, cfg Config) (*Provider, error) {
if cfg.IssuerURL == "" || cfg.ClientID == "" || cfg.RedirectURL == "" {
return nil, fmt.Errorf("oidc: IssuerURL, ClientID, and RedirectURL are required")
}
issuer, err := goidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, fmt.Errorf("oidc: discovering issuer %q: %w", cfg.IssuerURL, err)
}
scopes := append([]string{goidc.ScopeOpenID}, cfg.Scopes...)
return &Provider{
verifier: issuer.Verifier(&goidc.Config{ClientID: cfg.ClientID}),
oauth2: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: issuer.Endpoint(),
Scopes: scopes,
},
}, nil
}
// NewState generates a CSRF-protection state value for the login
// redirect. The caller is responsible for storing it (session/cookie)
// and comparing it against what comes back to the callback endpoint --
// this package doesn't hold any server-side state itself.
func NewState() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("oidc: generating state: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// AuthCodeURL is where the browser gets redirected to start login.
func (p *Provider) AuthCodeURL(state string) string {
return p.oauth2.AuthCodeURL(state)
}
// Exchange trades an authorization code for tokens and returns the
// verified ID token's claims. Verification (signature, issuer,
// audience, expiry) happens inside p.verifier.Verify -- this is the
// step that actually establishes trust, not just "we got a token back."
func (p *Provider) Exchange(ctx context.Context, code string) (*Claims, error) {
token, err := p.oauth2.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("oidc: exchanging code: %w", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
return nil, fmt.Errorf("oidc: token response had no id_token")
}
idToken, err := p.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("oidc: verifying id_token: %w", err)
}
var claims Claims
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("oidc: decoding claims: %w", err)
}
return &claims, nil
}
+75
View File
@@ -0,0 +1,75 @@
package oidc
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestNewRejectsMissingConfig(t *testing.T) {
_, err := New(context.Background(), Config{})
if err == nil {
t.Fatalf("expected an error for an empty config")
}
}
// TestNewDiscoversRealIssuer spins up a real HTTP server serving a
// minimal valid OIDC discovery document and confirms New() actually
// performs discovery against it successfully -- not just "the code
// compiles and looks plausible." Doesn't cover the full Exchange() flow
// (needs a signed JWKS/token response, real crypto scaffolding better
// suited to task 5's end-to-end auth integration tests), but discovery
// is exactly the step that would silently break on a URL-construction or
// JSON-shape mistake, so it's worth actually running.
func TestNewDiscoversRealIssuer(t *testing.T) {
mux := http.NewServeMux()
srv := httptest.NewServer(mux)
defer srv.Close()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/authorize",
"token_endpoint": srv.URL + "/token",
"jwks_uri": srv.URL + "/jwks",
"userinfo_endpoint": srv.URL + "/userinfo",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{}})
})
p, err := New(context.Background(), Config{
IssuerURL: srv.URL, ClientID: "sentry", ClientSecret: "secret", RedirectURL: "http://localhost/callback",
})
if err != nil {
t.Fatalf("New: %v", err)
}
if p.AuthCodeURL("state123") == "" {
t.Fatalf("expected a non-empty auth code URL")
}
}
func TestNewStateIsNonEmptyAndUnique(t *testing.T) {
a, err := NewState()
if err != nil {
t.Fatalf("NewState: %v", err)
}
b, err := NewState()
if err != nil {
t.Fatalf("NewState: %v", err)
}
if a == "" || b == "" {
t.Fatalf("expected non-empty state values")
}
if a == b {
t.Fatalf("expected two calls to NewState to produce different values")
}
}