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).
117 lines
3.9 KiB
Go
117 lines
3.9 KiB
Go
// 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
|
|
}
|