Files
jcoffey-dev 13cf9a30cb Rebrand: Sentry -> Cairn OBS
Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

Code identifiers: Go module path github.com/sentry/sentry ->
github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc
regenerated); Rust crates sentry-agent/sentry-parser/sentry-search ->
cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully
renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type,
env vars); every session/auth cookie name; agent config paths and
Windows service identity.

Deliberately preserved: the gRPC wire protocol's protobuf packages
(sentry.logs.v1, sentry.agent.v1) and their Go import directory
(proto/sentry/...) -- renaming the wire-level package would break every
currently-deployed agent binary (confirmed two real hosts, including
mail.inbuxa.com, are actively streaming through this exact contract)
until rebuilt and redeployed in lockstep with an ingest cutover. Only
the Go module path wrapping the generated code changes.

Infrastructure: every docker-compose container name (root and three
component-level compose files); the Helm chart (directory, Chart.yaml,
named-template helpers, all templates, values.yaml image repos);
Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML
files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd
package. Caught and fixed real path-coupling bugs along the way: the
Helm chart's search/ingest volume mounts and the dev-only-credential
detection constant vs. docker-compose.yml's literal values had to move
together or a security warning would have silently stopped firing.

Data plane: Postgres database sentry_metadata -> cairnobs_metadata and
role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka
topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups.
Source-level defaults, docker-compose.yml, and every migrate.sh/
provision script default updated together; already-applied migration
files left untouched per this repo's immutable-migration convention.

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
2026-08-21 20:53:32 -07:00

212 lines
8.2 KiB
Go

// Package authhandler implements enterprise-auth's POST /internal/authorize
// endpoint -- the HTTP side of the "network boundary, not import boundary"
// pattern api/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.
//
// POST /internal/authorize-ingest is a sibling endpoint, same network-
// boundary shape but for a different caller (`ingest`, core/AGPL, not
// api/authz) and a different credential type (an ingest bearer token
// checked against rbacstore's ingest_credentials table, not a
// session.Manager JWT) -- see ingestCredentialValidator's doc comment.
//
// GET /internal/active-tenants is a third sibling, for `search`
// (core/AGPL, Rust) -- see tenantLister's doc comment.
package authhandler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"github.com/cairnobs/cairnobs/enterprise/internal/session"
)
// SessionCookieName matches the name api/authz.HTTPAuthorizer's
// tests and doc comments already assume ("cairnobs_session").
const SessionCookieName = "cairnobs_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
}
// ingestCredentialValidator is the narrow interface POST
// /internal/authorize-ingest needs -- *rbacstore.Store is the production
// implementation. Unlike session-backed /internal/authorize, this
// endpoint validates a completely different credential type (an ingest
// bearer token, checked against enterprise/internal/rbacstore's
// ingest_credentials table, never a session.Manager-signed JWT), so it
// needs a dependency session.Manager alone can't supply.
type ingestCredentialValidator interface {
ValidateIngestCredential(ctx context.Context, token string) (tenantID string, err error)
}
// tenantLister is the narrow interface GET /internal/active-tenants
// needs -- *rbacstore.Store is the production implementation (the same
// concrete type ingestCredentials above already wires in, just a
// second narrow interface it happens to also satisfy). Backs
// search/src/tenants.rs's ActiveTenantTracker: search is AGPL core with
// no Postgres access and no enterprise/ import allowed, so its write-
// routing needed a network boundary to learn which tenants are active,
// the same shape ingest/internal/grpcserver.TenantResolver already uses
// against this exact service (see that package's doc comment).
type tenantLister interface {
ListActiveTenantIDs(ctx context.Context) ([]string, error)
}
type Handler struct {
logger *slog.Logger
manager *session.Manager
features Features
ingestCredentials ingestCredentialValidator
tenants tenantLister
}
func New(logger *slog.Logger, manager *session.Manager, features Features, ingestCredentials ingestCredentialValidator, tenants tenantLister) *Handler {
return &Handler{logger: logger, manager: manager, features: features, ingestCredentials: ingestCredentials, tenants: tenants}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /internal/authorize", h.handleAuthorize)
mux.HandleFunc("GET /auth/features", h.handleFeatures)
mux.HandleFunc("POST /internal/authorize-ingest", h.handleAuthorizeIngest)
mux.HandleFunc("GET /internal/active-tenants", h.handleActiveTenants)
}
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,
})
}
type authorizeIngestResponse struct {
TenantID string `json:"tenant_id"`
}
// handleAuthorizeIngest is ingest/internal/grpcserver.HTTPTenantResolver's
// server side -- ingest calls this once per PushBatch (with the bearer
// token the agent presented) to resolve which tenant the batch belongs
// to, the network-boundary equivalent of api/authz.HTTPAuthorizer
// calling /internal/authorize, for a different credential type.
func (h *Handler) handleAuthorizeIngest(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r.Header.Get("Authorization"))
if token == "" {
http.Error(w, "no credentials presented", http.StatusUnauthorized)
return
}
tenantID, err := h.ingestCredentials.ValidateIngestCredential(r.Context(), token)
if err != nil {
http.Error(w, "invalid ingest credential", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(authorizeIngestResponse{TenantID: tenantID})
}
type activeTenantsResponse struct {
TenantIDs []string `json:"tenant_ids"`
}
// handleActiveTenants is search/src/tenants.rs's ActiveTenantTracker's
// server side -- polled periodically, not per-write, to build a local
// allowlist for its write-routing gate (see that module's doc comment).
// Requires a RoleService Bearer credential (session.Manager-issued,
// Role == "service"), not a human session -- server-to-server, the same
// authentication shape /alerting presents to /api, minted via
// `enterprise-auth -mint-service-token search` (the flag is already
// generic over caller name; no change needed there for a new caller).
// Deliberately does NOT accept the tenant-scoped credential a human
// session or an ingest credential would carry: this endpoint answers
// "which tenants exist," a question no single tenant's identity should
// be able to ask on its own.
func (h *Handler) handleActiveTenants(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r.Header.Get("Authorization"))
if token == "" {
http.Error(w, "no credentials presented", http.StatusUnauthorized)
return
}
claims, err := h.manager.Validate(token)
if err != nil || claims.Role != "service" {
http.Error(w, "invalid or expired credentials", http.StatusUnauthorized)
return
}
ids, err := h.tenants.ListActiveTenantIDs(r.Context())
if err != nil {
h.logger.Error("listing active tenant ids", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(activeTenantsResponse{TenantIDs: ids})
}
func bearerToken(header string) string {
const prefix = "Bearer "
if !strings.HasPrefix(header, prefix) {
return ""
}
return strings.TrimPrefix(header, prefix)
}