Phase 4: Helm chart enforces api vs enterprise-api, closing the deployment-topology gap

deploy/helm/sentry/templates/api.yaml and the new enterprise-api.yaml
are mutually exclusive, gated on opposite sides of the same
enterprise.enabled flag -- exactly one renders, both as a Deployment+
Service named {{ .Release.Name }}-api on port 8080, so every consumer
(alerting's API_QUERY_URL, web's build args) needs zero conditional
logic of its own. This is the concrete fix for what the threat model
named as the single largest remaining gap once both storage engines'
isolation mechanisms were built: previously nothing forced or flagged
whether a deployment ran the tenant-isolated binary. Now the same flag
that turns on RBAC/audit/SSO also chooses the query binary.

Verified by parsing (not eyeballing) helm template's rendered output
under both value sets: exactly one sentry-api Deployment/Service either
way, with the right image, and kubeconform -strict clean against the
real Kubernetes 1.31 schema. Not applied to a live cluster (still no
cluster in this environment) -- docker-compose.yml also still runs
plain api unconditionally, so this enforcement is Helm-only for now.

Updated the threat model, architecture doc, CLAUDE.md, and deploy/
READMEs to reflect this and to name what's left: ingest has no tenant
concept for either storage engine (undesigned), and the Tenant CRD
(deploy/operator) and enterprise-api -provision-tenant are still two
separate, unreconciled provisioning mechanisms.
This commit is contained in:
2026-08-14 06:20:20 -07:00
parent ba2276aa1a
commit 3037b31b0f
9 changed files with 287 additions and 69 deletions
+15 -8
View File
@@ -147,15 +147,22 @@ escape hatch is opaque to any compiler-injected filter.
`chrunner`/`searchclient` becomes tenant-aware on the write side,
which is undesigned, not merely unbuilt.
- `deploy/operator`'s `Tenant` CRD still manages only the K8s-side
artifact (a credential Secret); the Helm chart has no service
definition for `enterprise-api` yet.
artifact (a credential Secret); it doesn't call
`enterprise-api -provision-tenant` or otherwise trigger ClickHouse-side
provisioning. The two mechanisms are independent today, not reconciled
into one state machine.
The deployment-topology gap — giving the system an actual way to route
traffic to `enterprise-api` instead of `api` (a Helm service, or at
minimum a documented, enforced convention) — is now the single largest
remaining gap between this system and the isolation model it was
designed to have; both storage engines' connection/index-layer
mechanisms themselves are built.
**The deployment-topology gap is closed for the Helm chart**: `deploy/
helm/sentry/templates/api.yaml`/`enterprise-api.yaml` are mutually
exclusive on `enterprise.enabled`, rendering to the same Service name
and port either way, so a Helm-deployed cluster can't accidentally run
the wrong binary — the same flag that turns on RBAC/audit/SSO now also
chooses the query binary. `docker-compose.yml` still runs plain `api`
unconditionally, so this enforcement doesn't yet extend to local/dev.
With both storage engines' connection/index-layer mechanisms built and
deployment topology enforced at the Helm layer, the largest remaining
gaps are ingest's lack of tenant-awareness (undesigned) and unifying the
`Tenant` CRD with `-provision-tenant` into one provisioning flow.
## Licensing boundary
+40 -7
View File
@@ -314,17 +314,50 @@ Docker access, alongside `enterprise/internal/loginhandler`'s OIDC
tests (§3a) — both are unusually strong evidence precisely because they
needed no infrastructure this environment lacked.
## 10. Confirm the Helm chart actually enforces the binary swap
No live cluster needed for this either — `helm template`'s output is
plain YAML, parseable without a cluster:
```sh
cd deploy/helm/sentry
helm template sentry . --include-crds > /tmp/default.yaml
helm template sentry . --include-crds --set enterprise.enabled=true \
--set 'tenants[0].name=acme' --set 'tenants[0].displayName=Acme Corp' \
> /tmp/enterprise.yaml
python3 -c "
import yaml
for f in ['/tmp/default.yaml', '/tmp/enterprise.yaml']:
docs = list(yaml.safe_load_all(open(f)))
deploys = [d for d in docs if d and d.get('kind')=='Deployment' and d.get('metadata',{}).get('name')=='sentry-api']
print(f, '->', [d['spec']['template']['spec']['containers'][0]['image'] for d in deploys])
"
# expect: default.yaml -> ['sentry-api:latest'], enterprise.yaml -> ['sentry-enterprise-api:latest']
# and exactly one Deployment named sentry-api in each file.
```
Real cluster (`kind create cluster`, or similar): `helm install` with
each set of values and confirm `kubectl get deploy sentry-api -o
jsonpath='{.spec.template.spec.containers[0].image}'` matches, and that
`kubectl get svc sentry-api` routes to whichever one is actually running.
## Known gaps (do not treat this phase as done without reading these)
Full accounting: `/docs/security/threat-model.md`. Headline items:
- **Both storage engines' isolation exists but is opt-in.**
`enterprise-api` (§8, §9) gives real per-tenant ClickHouse *and*
Tantivy isolation, but plain `api` (still the default in
`docker-compose.yml`/`web`'s base URL) has neither, and nothing flags
which one a given deployment is actually running. This is now the
single largest gap — not a missing mechanism, a missing enforcement/
default.
- **Both storage engines' isolation exists, and the Helm chart now
enforces which binary runs.** `deploy/helm/sentry/templates/api.yaml`/
`enterprise-api.yaml` are mutually exclusive on `enterprise.enabled`
(§10) -- a Helm-deployed cluster can't accidentally run the
non-isolated binary once that flag is set. `docker-compose.yml` still
runs plain `api` unconditionally alongside a separately-started
`enterprise-api` (§8), so this enforcement doesn't extend to local/dev
yet.
- The `Tenant` CRD (`deploy/operator`) and `enterprise-api
-provision-tenant` are still two independent provisioning mechanisms
-- running both for the same tenant ID today takes two separate
operator actions, not one.
- **Ingest has no tenant concept for either storage engine.** Every
record `ingest` produces lands in the one shared ClickHouse database
and the one shared Tantivy index no matter what. A newly-provisioned
+19 -11
View File
@@ -44,16 +44,23 @@ searchclient`'s tests run a real in-process gRPC server and confirm the
wire-level `SearchRequest` carries the right `tenant_id`. All pass, for
real, no disclaimer needed for this specific claim.
**But plain `api/cmd/api` still runs with one shared ClickHouse
connection and no tenant-scoped search client**, and nothing in this
repo automatically routes traffic to `enterprise-api` instead —
`docker-compose.yml` includes it "available, not defaulted into the
traffic path" (same shape as `enterprise-auth`'s own addition), and the
Helm chart has no service for it at all yet. **A deployment is only as
isolated as which binary is actually serving traffic** — this is an
operational decision nothing currently enforces or even surfaces as a
warning. This is now the single largest gap in the isolation story, not
a missing mechanism.
**The Helm chart now closes this for K8s deployments; `docker-compose.yml`
still doesn't.** `deploy/helm/sentry/templates/api.yaml` and
`enterprise-api.yaml` are mutually exclusive, gated on opposite sides of
the same `enterprise.enabled` flag, rendering to the same Service
name/port — so a Helm-deployed cluster runs exactly one of the two
binaries, chosen by the same flag that turns on RBAC/audit/SSO, not a
second independently-forgettable decision. Verified by parsing (not
eyeballing) the rendered YAML under both values: exactly one `sentry-api`
Deployment either way, with the right image. **`docker-compose.yml`
still runs plain `api` unconditionally** and includes `enterprise-api`
as an extra, separately-started service — local/dev parity with the Helm
chart's enforcement is real remaining work. And this only constrains
*deployment*, not *operation*: nothing stops an operator from manually
running plain `api`'s image against a cluster that has tenants
provisioned, pointing at the same ClickHouse/Postgres. The Helm chart
makes the *default*, chart-managed path correct; it isn't a runtime
guard against misconfiguration.
**Ingest is not tenant-aware for either storage engine**, and this is
more load-bearing than it sounds: `chrunner`/`searchclient` prove *read*
@@ -360,7 +367,8 @@ terms:
| Tantivy per-tenant index routing (`search/src/registry.rs`) | **Enforced, verified live** — real Tantivy indices, real cross-tenant probe, all passing |
| Tantivy tenant_id resolution (`enterprise/internal/searchclient`) | **Enforced, verified live** — real gRPC wire-level test |
| Ingest tenant-awareness (ClickHouse and Tantivy both) | **Not implemented, undesigned** — every ingested record lands in the single shared database/index regardless of tenant |
| Deployment actually routing traffic to `enterprise-api` | **Not implemented** — no Helm service, no default wiring; now the largest gap in the isolation story |
| Deployment actually routing traffic to `enterprise-api` (Helm) | **Enforced**`api`/`enterprise-api` are mutually exclusive, same flag as RBAC/audit/SSO |
| Deployment actually routing traffic to `enterprise-api` (docker-compose) | **Not implemented**`docker-compose.yml` runs plain `api` unconditionally |
| Human SSO login — OIDC | **Built, verified with a real fake IdP** (not yet tried against a real external IdP) |
| Human SSO login — SAML | **Not implemented** |
| Multi-tenant-membership login (tenant picker) | **Not implemented** — refused with a clear error, not guessed |