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,155 @@
|
||||
// Tests use controller-runtime's fake client (sigs.k8s.io/
|
||||
// controller-runtime/pkg/client/fake), not envtest -- envtest needs a
|
||||
// real kube-apiserver/etcd binary pair (setup-envtest) that isn't
|
||||
// available in this environment (see package doc comment and
|
||||
// deploy/README.md's verification section). A fake client exercises
|
||||
// Reconcile's actual logic (object CRUD, owner references, status
|
||||
// writes) against an in-memory tracker; what it can't exercise is
|
||||
// anything a real apiserver would do for you (defaulting, admission,
|
||||
// actual garbage collection of owned objects, watch-triggered
|
||||
// re-reconciliation) -- so passing here is real signal about this
|
||||
// reconciler's logic, not proof it behaves correctly against a live
|
||||
// cluster.
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
sentryv1alpha1 "github.com/sentry/sentry/deploy/operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
func newFakeReconciler(t *testing.T, objs ...client.Object) *TenantReconciler {
|
||||
t.Helper()
|
||||
scheme := runtime.NewScheme()
|
||||
if err := corev1.AddToScheme(scheme); err != nil {
|
||||
t.Fatalf("adding corev1 to scheme: %v", err)
|
||||
}
|
||||
if err := sentryv1alpha1.AddToScheme(scheme); err != nil {
|
||||
t.Fatalf("adding sentryv1alpha1 to scheme: %v", err)
|
||||
}
|
||||
fakeClient := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(objs...).
|
||||
WithStatusSubresource(&sentryv1alpha1.Tenant{}).
|
||||
Build()
|
||||
return &TenantReconciler{Client: fakeClient, Scheme: scheme}
|
||||
}
|
||||
|
||||
func testTenant(name string, suspended bool) *sentryv1alpha1.Tenant {
|
||||
return &sentryv1alpha1.Tenant{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
|
||||
Spec: sentryv1alpha1.TenantSpec{DisplayName: name, Suspended: suspended},
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileCreatesSecretAndSetsActivePhase(t *testing.T) {
|
||||
tenant := testTenant("acme", false)
|
||||
r := newFakeReconciler(t, tenant)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: "acme", Namespace: "default"}}); err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
|
||||
var secret corev1.Secret
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: "sentry-tenant-acme-clickhouse", Namespace: "default"}, &secret); err != nil {
|
||||
t.Fatalf("expected a ClickHouse secret to be created: %v", err)
|
||||
}
|
||||
if secret.StringData["username"] != "tenant_acme" || secret.StringData["database"] != "acme" {
|
||||
t.Fatalf("unexpected secret data: %+v", secret.StringData)
|
||||
}
|
||||
if secret.StringData["password"] == "" {
|
||||
t.Fatal("expected a non-empty generated password")
|
||||
}
|
||||
if len(secret.OwnerReferences) != 1 || secret.OwnerReferences[0].Name != "acme" {
|
||||
t.Fatalf("expected secret to be owned by the Tenant, got %+v", secret.OwnerReferences)
|
||||
}
|
||||
|
||||
var got sentryv1alpha1.Tenant
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: "acme", Namespace: "default"}, &got); err != nil {
|
||||
t.Fatalf("getting tenant: %v", err)
|
||||
}
|
||||
if got.Status.Phase != sentryv1alpha1.PhaseActive {
|
||||
t.Fatalf("Phase = %q, want Active", got.Status.Phase)
|
||||
}
|
||||
if got.Status.ClickHouseDatabaseName != "acme" {
|
||||
t.Fatalf("ClickHouseDatabaseName = %q, want acme", got.Status.ClickHouseDatabaseName)
|
||||
}
|
||||
if got.Status.ClickHouseSecretRef != "sentry-tenant-acme-clickhouse" {
|
||||
t.Fatalf("ClickHouseSecretRef = %q", got.Status.ClickHouseSecretRef)
|
||||
}
|
||||
if got.Status.TantivyIndexPath != "/var/lib/sentry-search/tenants/acme" {
|
||||
t.Fatalf("TantivyIndexPath = %q", got.Status.TantivyIndexPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileSuspendedSetsSuspendedPhaseButKeepsSecret(t *testing.T) {
|
||||
tenant := testTenant("acme", true)
|
||||
r := newFakeReconciler(t, tenant)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: "acme", Namespace: "default"}}); err != nil {
|
||||
t.Fatalf("Reconcile: %v", err)
|
||||
}
|
||||
|
||||
var got sentryv1alpha1.Tenant
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: "acme", Namespace: "default"}, &got); err != nil {
|
||||
t.Fatalf("getting tenant: %v", err)
|
||||
}
|
||||
if got.Status.Phase != sentryv1alpha1.PhaseSuspended {
|
||||
t.Fatalf("Phase = %q, want Suspended", got.Status.Phase)
|
||||
}
|
||||
|
||||
// A suspended tenant's credential Secret is NOT deleted -- suspension
|
||||
// is reversible and this controller doesn't manage ClickHouse-side
|
||||
// grants, so there's nothing at this layer to actually enforce
|
||||
// suspension; deleting the Secret would just be theater.
|
||||
var secret corev1.Secret
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: "sentry-tenant-acme-clickhouse", Namespace: "default"}, &secret); err != nil {
|
||||
t.Fatalf("expected secret to still exist for a suspended tenant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileIsIdempotentAndNeverRotatesPassword(t *testing.T) {
|
||||
tenant := testTenant("acme", false)
|
||||
r := newFakeReconciler(t, tenant)
|
||||
ctx := context.Background()
|
||||
req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "acme", Namespace: "default"}}
|
||||
|
||||
if _, err := r.Reconcile(ctx, req); err != nil {
|
||||
t.Fatalf("first Reconcile: %v", err)
|
||||
}
|
||||
var first corev1.Secret
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: "sentry-tenant-acme-clickhouse", Namespace: "default"}, &first); err != nil {
|
||||
t.Fatalf("getting secret after first reconcile: %v", err)
|
||||
}
|
||||
|
||||
if _, err := r.Reconcile(ctx, req); err != nil {
|
||||
t.Fatalf("second Reconcile: %v", err)
|
||||
}
|
||||
var second corev1.Secret
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: "sentry-tenant-acme-clickhouse", Namespace: "default"}, &second); err != nil {
|
||||
t.Fatalf("getting secret after second reconcile: %v", err)
|
||||
}
|
||||
|
||||
if first.StringData["password"] != second.StringData["password"] {
|
||||
t.Fatal("password changed across a re-reconcile -- would break every live connection for this tenant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileMissingTenantIsNoOp(t *testing.T) {
|
||||
r := newFakeReconciler(t)
|
||||
_, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: "does-not-exist", Namespace: "default"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile on a missing tenant should be a no-op, got error: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user