Files
cairnobs/enterprise/internal/session/session.go
T
jcoffey-dev 1d57e697b1 Phase 4: real per-tenant ClickHouse isolation via a new enterprise-api binary
Closes the threat model's headline finding for the SQL query path:
enterprise/internal/tenantprovision does real CREATE DATABASE/USER/GRANT
against ClickHouse, and enterprise/internal/chrunner is a per-tenant
connection registry implementing api's SQLRunner interface, resolving
the tenant from the authenticated request identity -- never a
caller-suppliable parameter. Both are wired into a new binary,
enterprise/cmd/enterprise-api, alongside the unchanged single-tenant
api/cmd/api, since AGPL core can never import enterprise/ and Go's own
internal/ package visibility rules meant enterprise/ couldn't implement
core's SQLRunner interface without importing the package that defines
it. That required moving api/internal/{authz,queryapi,dashboards,
querylang/executor,searchclient,httpserver} out of internal/ -- the
minimal set enterprise-api needs to import; querylang's compiler
internals (planner/lexer/parser/ast/ir) and api's own config stay
internal, since nothing outside api needs them directly.

Also finally wires enterprise/internal/audit into queryapi.AuditLogger
(nil since Phase 4 task 4) via a new adapter, and adds live-ClickHouse
integration tests for two of the four adversarial probes named in
docs/phase-4-isolation-design.md's verification plan.

Corrected several overclaims in the docs while writing this up: an
earlier claim that rbacstore's CRUD was "verified against a live
Postgres" was never actually true in this environment (only
internal/audit was, earlier in this phase, before Docker access was
lost) -- threat-model.md, phase-4-runbook.md, CLAUDE.md, and
enterprise/README.md all now distinguish "a real integration test
exists" from "this was confirmed against a live database."

Still not built: Tantivy/free-text tenant isolation
(enterprise/internal/searchclient), and any deployment-topology
mechanism that actually routes traffic to enterprise-api instead of
plain api -- both binaries exist side by side today with nothing
enforcing or flagging which one a deployment runs.
2026-08-13 22:48:38 -07:00

134 lines
5.0 KiB
Go

// 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/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
}