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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user