Files
cairnobs/api/authz/authz.go
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

88 lines
3.5 KiB
Go

// Package authz is core's RBAC extension point -- deliberately minimal
// and tenant-agnostic in shape, matching queryapi.AuditLogger's pattern:
// core defines the interface and the types, enterprise/ supplies the
// implementation. Unlike AuditLogger, the implementation Authorizer
// wires in here is NOT enterprise Go code injected directly (that would
// require api to import enterprise/, violating the module boundary
// confirmed in /docs/phase-4-isolation-design.md) -- it's an HTTP client
// to enterprise-auth's /internal/authorize endpoint (see httpauthz.go),
// the same "network boundary, not import boundary" pattern already
// established between /api and /alerting.
package authz
import (
"context"
"net/http"
)
// Role is ordered for human roles (Viewer < Editor < Admin < Owner, per
// /docs/phase-4-rbac-design.md) plus a separate, non-comparable Service
// lane for machine callers like /alerting's evaluator -- see Satisfies.
type Role string
const (
RoleViewer Role = "viewer"
RoleEditor Role = "editor"
RoleAdmin Role = "admin"
RoleOwner Role = "owner"
RoleService Role = "service"
)
var roleRank = map[Role]int{RoleViewer: 1, RoleEditor: 2, RoleAdmin: 3, RoleOwner: 4}
// Satisfies reports whether this role meets a requirement. RoleService
// only ever satisfies RoleService -- a service credential never
// satisfies a human-role requirement, and a human role never satisfies
// a RoleService requirement, by design: /docs/phase-4-isolation-design.md's
// alerting service identity is deliberately not a point on the human
// role scale, so it can't accidentally inherit broader access by
// ranking above Viewer.
func (r Role) Satisfies(required Role) bool {
if required == RoleService || r == RoleService {
return r == required
}
return roleRank[r] >= roleRank[required]
}
// Identity is what a successful Authorize call resolves. UserID is
// empty for RoleService (see /docs/phase-4-isolation-design.md's
// alerting↔api gap -- a service credential proves "this caller is
// alerting," not "this caller is acting as a specific human").
type Identity struct {
TenantID string
UserID string
Role Role
}
// Authorizer resolves an Identity from an incoming request's
// credentials (session cookie or service token) without knowing
// anything about *what* the caller is trying to do -- permission
// checking against a required Role happens in the middleware
// (middleware.go), not here, so this interface stays a pure
// "who is this" question.
type Authorizer interface {
Authorize(r *http.Request) (Identity, error)
}
type identityContextKey struct{}
// IdentityFromContext lets a handler read the resolved identity a
// RequireRole/RequireRoleOrService middleware attached -- e.g. to
// populate QueryAuditEntry's tenant/user once Phase 4's audit wiring
// threads identity through (see queryapi.AuditLogger's doc comment).
func IdentityFromContext(ctx context.Context) (Identity, bool) {
id, ok := ctx.Value(identityContextKey{}).(Identity)
return id, ok
}
// WithIdentity attaches an already-resolved Identity to ctx -- exported
// (not just middleware.go's internal use) so packages that construct
// their own request context outside an HTTP handler -- e.g. enterprise/
// internal/chrunner's tests, or a future non-HTTP caller -- can put a
// real Identity in context the same way RequireRole/RequireRoleOrService
// do, rather than reaching for an unexported field via reflection or
// duplicating this one-line function.
func WithIdentity(ctx context.Context, id Identity) context.Context {
return context.WithValue(ctx, identityContextKey{}, id)
}