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.
212 lines
8.2 KiB
Go
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)
|
|
}
|