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).
This commit is contained in:
2026-08-13 22:16:59 -07:00
parent 9435115ab7
commit 3eb0f4c589
116 changed files with 8589 additions and 126 deletions
+71
View File
@@ -0,0 +1,71 @@
// Exercises the hand-written DeepCopy methods in zz_generated.deepcopy.go
// -- see that file's doc comment for why these aren't controller-gen
// output here. A DeepCopy that accidentally shares a slice/map with the
// original is a real, easy-to-introduce bug (client-go relies on
// DeepCopyObject returning something safe to mutate independently), so
// these tests mutate the copy and assert the original is unaffected.
package v1alpha1
import (
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestTenantDeepCopyIsIndependent(t *testing.T) {
orig := &Tenant{
ObjectMeta: metav1.ObjectMeta{Name: "acme", Labels: map[string]string{"a": "1"}},
Spec: TenantSpec{DisplayName: "Acme", Suspended: false},
Status: TenantStatus{
Phase: PhaseActive,
Conditions: []metav1.Condition{
{Type: ConditionReady, Status: metav1.ConditionTrue, Reason: "x"},
},
},
}
cp := orig.DeepCopy()
cp.Spec.DisplayName = "Changed"
cp.Status.Conditions[0].Reason = "changed"
cp.Labels["a"] = "changed"
if orig.Spec.DisplayName != "Acme" {
t.Fatalf("mutating the copy's Spec affected the original: %q", orig.Spec.DisplayName)
}
if orig.Status.Conditions[0].Reason != "x" {
t.Fatalf("mutating the copy's Conditions affected the original: %q", orig.Status.Conditions[0].Reason)
}
// Labels comes from metav1.ObjectMeta.DeepCopyInto, which this
// package doesn't implement itself -- this assertion is really
// checking that Tenant.DeepCopyInto actually calls
// ObjectMeta.DeepCopyInto rather than doing a shallow `out.ObjectMeta
// = in.ObjectMeta`.
if orig.Labels["a"] != "1" {
t.Fatalf("mutating the copy's Labels affected the original: %q", orig.Labels["a"])
}
}
func TestTenantDeepCopyObjectPreservesData(t *testing.T) {
orig := &Tenant{ObjectMeta: metav1.ObjectMeta{Name: "acme"}, Spec: TenantSpec{DisplayName: "Acme"}}
obj := orig.DeepCopyObject()
cp, ok := obj.(*Tenant)
if !ok {
t.Fatalf("DeepCopyObject returned %T, want *Tenant", obj)
}
if cp.Name != "acme" || cp.Spec.DisplayName != "Acme" {
t.Fatalf("unexpected copy: %+v", cp)
}
}
func TestTenantListDeepCopyIsIndependent(t *testing.T) {
orig := &TenantList{Items: []Tenant{
{ObjectMeta: metav1.ObjectMeta{Name: "acme"}},
{ObjectMeta: metav1.ObjectMeta{Name: "globex"}},
}}
cp := orig.DeepCopy()
cp.Items[0].Name = "changed"
if orig.Items[0].Name != "acme" {
t.Fatalf("mutating the copy's Items affected the original: %q", orig.Items[0].Name)
}
}
@@ -0,0 +1,27 @@
// Package v1alpha1 contains the Tenant API's Go types -- kubebuilder's
// standard api/<version>/ layout, hand-written rather than scaffolded
// (no kubebuilder/controller-gen binary available in this environment;
// see /home/john/Projects/sentry/deploy/README.md's verification
// section for what that means for this package specifically: it's real,
// compiling, unit-tested Go code, never reconciled against a live
// cluster).
//
// +kubebuilder:object:generate=true
// +groupName=sentry.io
package v1alpha1
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
// GroupVersion is group sentry.io, version v1alpha1.
GroupVersion = schema.GroupVersion{Group: "sentry.io", Version: "v1alpha1"}
// SchemeBuilder is used to add go types to the GroupVersionKind scheme.
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
// AddToScheme adds the types in this group-version to the given scheme.
AddToScheme = SchemeBuilder.AddToScheme
)
@@ -0,0 +1,117 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// TenantPhase mirrors the provisioning state machine from
// /docs/phase-4-isolation-design.md: every tenant-resolution path
// elsewhere must refuse to serve a tenant not in PhaseActive, checked
// server-side (today, against enterprise/internal/rbacstore's tenants
// table -- this CR is a K8s-native *view* of the same state machine at
// the deployment-topology layer, not a second source of truth. Reconciling
// the two together is exactly the kind of tenant-provisioning wiring
// named as deferred in /docs/phase-4-runbook.md's task 6 section: today
// this operator only manages the K8s-side artifact (a per-tenant
// ClickHouse credential Secret + a ConfigMap recording the tenant's
// database name/index path), not the actual `CREATE DATABASE`/`CREATE
// USER`/`GRANT` calls against ClickHouse -- that's
// enterprise/internal/tenantprovision, still unbuilt.
type TenantPhase string
const (
PhaseProvisioning TenantPhase = "Provisioning"
PhaseActive TenantPhase = "Active"
PhaseSuspended TenantPhase = "Suspended"
PhaseDeprovisioning TenantPhase = "Deprovisioning"
)
// TenantSpec is the desired state -- an operator/admin's intent, set via
// `kubectl apply` or (per the Helm chart's templates/tenants.yaml) a
// values.yaml `tenants:` entry.
type TenantSpec struct {
// DisplayName is human-readable only -- the Tenant object's own Name
// (metav1.ObjectMeta) is the stable identifier, matching
// rbacstore.Tenant.ID's "slug, not UUID" reasoning (see
// /docs/phase-4-rbac-design.md's schema section) so this CRD's name
// can be the same string used elsewhere (ClickHouse database name,
// rbacstore tenant ID) without a translation layer.
// +kubebuilder:validation:Required
DisplayName string `json:"displayName"`
// Suspended is the admin-facing lever for the Suspended phase (e.g.
// an incident-response or billing action) -- distinct from
// Provisioning/Deprovisioning, which the controller drives from
// object lifecycle (creation, deletion), not from this field.
// +optional
Suspended bool `json:"suspended,omitempty"`
}
// TenantStatus is observed state -- only the controller writes this.
type TenantStatus struct {
// +optional
Phase TenantPhase `json:"phase,omitempty"`
// ClickHouseDatabaseName is derived (today: same as the Tenant's own
// Name) rather than settable in Spec -- see task 2's design: no
// tenant traffic authenticates as ClickHouse's `default` user, and a
// database name that could diverge from the tenant identifier is a
// bookkeeping foot-gun this type avoids by construction.
// +optional
ClickHouseDatabaseName string `json:"clickHouseDatabaseName,omitempty"`
// ClickHouseSecretRef names the Secret (same namespace) holding this
// tenant's dedicated, narrowly-granted ClickHouse credentials -- see
// tenant_controller.go's reconcileSecret. Never the cluster-wide
// CLICKHOUSE_PASSWORD docker-compose.yml uses today.
// +optional
ClickHouseSecretRef string `json:"clickHouseSecretRef,omitempty"`
// TantivyIndexPath is this tenant's index directory under the shared
// search-index PVC -- see /docs/phase-4-isolation-design.md's
// Tantivy index-per-tenant section.
// +optional
TantivyIndexPath string `json:"tantivyIndexPath,omitempty"`
// +optional
// +patchMergeKey=type
// +patchStrategy=merge
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"`
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}
// ConditionReady is the one condition type this controller sets today --
// more (e.g. ClickHouseProvisioned, once internal/tenantprovision
// exists) are additive future work, not a breaking change to this type.
const ConditionReady = "Ready"
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
// Tenant is the K8s-native representation of one Sentry tenant's
// deployment-topology state -- see this file's package-level doc
// comment for what it does and does not manage today.
type Tenant struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec TenantSpec `json:"spec,omitempty"`
Status TenantStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// TenantList is a list of Tenant.
type TenantList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Tenant `json:"items"`
}
func init() {
SchemeBuilder.Register(&Tenant{}, &TenantList{})
}
@@ -0,0 +1,92 @@
// Hand-written, not `controller-gen object:headerFile=...` generated --
// no controller-gen binary available in this environment (see
// groupversion_info.go's doc comment). Kept under the conventional
// zz_generated.deepcopy.go name so its purpose is recognizable, and
// covered by api_test.go's round-trip tests since a hand-written
// DeepCopy is exactly the kind of code a typo silently breaks.
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
func (in *TenantSpec) DeepCopy() *TenantSpec {
if in == nil {
return nil
}
out := new(TenantSpec)
*out = *in
return out
}
func (in *TenantStatus) DeepCopyInto(out *TenantStatus) {
*out = *in
if in.Conditions != nil {
out.Conditions = make([]metav1.Condition, len(in.Conditions))
for i := range in.Conditions {
in.Conditions[i].DeepCopyInto(&out.Conditions[i])
}
}
}
func (in *TenantStatus) DeepCopy() *TenantStatus {
if in == nil {
return nil
}
out := new(TenantStatus)
in.DeepCopyInto(out)
return out
}
func (in *Tenant) DeepCopyInto(out *Tenant) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Status.DeepCopyInto(&out.Status)
}
func (in *Tenant) DeepCopy() *Tenant {
if in == nil {
return nil
}
out := new(Tenant)
in.DeepCopyInto(out)
return out
}
func (in *Tenant) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
func (in *TenantList) DeepCopyInto(out *TenantList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
out.Items = make([]Tenant, len(in.Items))
for i := range in.Items {
in.Items[i].DeepCopyInto(&out.Items[i])
}
}
}
func (in *TenantList) DeepCopy() *TenantList {
if in == nil {
return nil
}
out := new(TenantList)
in.DeepCopyInto(out)
return out
}
func (in *TenantList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}