Files
cairnobs/api/internal/authz/middleware.go
T
jcoffey-dev 3eb0f4c589 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).
2026-08-13 22:16:59 -07:00

73 lines
2.2 KiB
Go

package authz
import (
"encoding/json"
"net/http"
)
type errorResponse struct {
Error string `json:"error"`
}
func writeUnauthorized(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(errorResponse{Error: "unauthorized"})
}
func writeForbidden(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(errorResponse{Error: "forbidden"})
}
// RequireRole wraps next so it only runs for a caller whose resolved
// Identity.Role satisfies minRole. A nil authorizer is a deliberate,
// documented no-op -- a single-tenant deployment with no enterprise/
// configured behaves exactly as Phases 0-3 did, unauthenticated, not
// locked out. This is the same nil-safety shape as
// queryapi.AuditLogger and dashboards' optional dependencies.
func RequireRole(authorizer Authorizer, minRole Role, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if authorizer == nil {
next(w, r)
return
}
identity, err := authorizer.Authorize(r)
if err != nil {
writeUnauthorized(w)
return
}
if !identity.Role.Satisfies(minRole) {
writeForbidden(w)
return
}
next(w, r.WithContext(withIdentity(r.Context(), identity)))
}
}
// RequireRoleOrService is RequireRole plus an explicit allowance for
// RoleService -- used only by endpoints /alerting's evaluator legitimately
// calls (POST /query today). Every other endpoint uses plain RequireRole,
// so a service credential can never reach dashboard/rule administration
// even though it's a valid, authenticated identity -- narrow by default,
// widened only where a real machine caller exists.
func RequireRoleOrService(authorizer Authorizer, minRole Role, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if authorizer == nil {
next(w, r)
return
}
identity, err := authorizer.Authorize(r)
if err != nil {
writeUnauthorized(w)
return
}
if identity.Role != RoleService && !identity.Role.Satisfies(minRole) {
writeForbidden(w)
return
}
next(w, r.WithContext(withIdentity(r.Context(), identity)))
}
}