Plan: Universe Deployment
Ship the enterprise-grade deployment for demarkus. The deliverable is the deployment: a customer's ops team installs a Helm chart, runs a broker, runs an agent, and has a federated demarkus universe their org can use. Any company evaluating demarkus (their internal "POC") installs the same product an established customer runs in production. There is no separate "POC slice" — the slice mentality is rejected. We build it once, right, and customers trial the real thing.
Goal
Deliver a complete, supportable, production-grade Kubernetes deployment package for demarkus, including:
- A Helm chart for
demarkus-server(one world). - A Helm chart for
demarkus-broker(OIDC token issuance + revocation). - A Helm chart for
demarkus-agent(hub aggregator, crawl-and-index). - Reference topology examples (Argo CD
ApplicationSet, Kustomize overlay). - Backend-agnostic observability — structured slog emission + reference configs for Datadog, OTel Collector, Vector, Fluent Bit, Grafana Alloy.
- Customer-facing documentation — installation, security/threat model, operations, upgrade path, per-provider OIDC setup, observability recipes.
- A release pipeline producing images and chart releases consumable from GHCR.
The same artifacts power first-customer trial and steady-state operations.
Non-Goals (Phase 7+ territory)
- Multi-replica worlds with shared storage (RWX / object backend).
- Cross-cluster universe federation.
- Operator with a
WorldCRD. - Hosted / managed SaaS.
- Non-markdown content.
Constraints
- No core protocol/server changes. Period. Per prior precedent (Claude Code plugin, Obsidian plugin,
feedback_plugin_scope.md). Observability is achieved by log derivation in a collector, not by adding/metricsor OTel SDK calls to the server. Note: in Slice A we promotedHashTokenand the token-mint library toprotocol/(underprotocol/auth.goandprotocol/token/). These are additive helper relocations consumed by server + CLI + future broker; no wire-protocol or server behavior changed. Slice C.2 addedprotocol/token.ParseBytesin the same additive spirit — a read-side helper so the broker's drift sweeper can inspect a world'stokens.tomlpayload via the map shape rather than substring matching on serialized TOML. - Capability-based auth model is non-negotiable. The server never learns identity — only labels.
- Markdown-only scope is non-negotiable.
Decisions (resolved during planning)
- Health probes use
exec, not HTTP. Both charts ship liveness/readinessexecprobes that invoke thedemarkusCLI fetching/.well-known/agent-manifest.md(always public per/architecture.md). No core change. Same pattern asredis-cli ping/pg_isready. - UDP port is a values knob. Default
6309, override viaserver.udpPort. Documented option: switch to443for VPN/middlebox-hostile networks (Cloudflare Warp Zero Trust, corp firewalls that filter non-standard UDP). Protocol default stays6309. - Wildcard TLS via cert-manager DNS-01. Single
*.<root>cert covers all worlds + broker hostnames. HTTP-01 cannot work for worlds (no HTTP); DNS-01 is the standard path for QUIC services. - DNS topology. One A record per world + one for the broker, all under the same wildcard zone. Each world exposed by
Service: LoadBalancerprotocol: UDP; broker by standard Ingress (HTTPS). - Hub aggregator is the existing
demarkus-agentatclient/cmd/demarkus-agent/. Already implementscrawl+daemonsubcommands with TOML config, multi-worker crawl, per-host token auth, per-server + aggregated index publishing. Verified end-to-end on 2026-05-11. Phase 5 is no longer a blocking prerequisite. demarkus-agentlives inclient/cmd/, nottools/. It's a protocol client (usesfedcrawl,fetch,tokens,links), not a utility. Memory rule sharpened:client/cmd/= protocol clients (CLI, TUI, MCP, agent);tools/= utilities (broker, future sync/ops binaries).- Token-mint library lives at
protocol/token/, nottools/internal/token/(revised mid-Slice-A after CodeRabbit review).protocol/is reachable by bothserver/(wheredemarkus-tokenCLI lives) andtools/(where the broker will live), whichtools/internal/was not.protocol/already owns theHashTokenbyte-shape contract, so it's the natural home for the on-disk token primitives that must round-trip identically across CLI mints and broker mints. demarkus-tokenCLI stays atserver/cmd/demarkus-token/for now (rewired to importprotocol/token). Moving the binary totools/was deferred to a follow-up that splits the release pipeline (server release currently ships demarkus-token; tools has no release cadence yet). Same applies todemarkus-publish— still atserver/cmd/demarkus-publish/, deferred because hoisting it requires also hoistingserver/internal/store/out of internal-package scope.- Multi-OIDC. Broker speaks generic OIDC, not Google-specific. Provider behind a
Verifierinterface. Google validated first; Okta, Entra ID, Auth0 follow with config only. - Workload Identity (GKE) as a broker option. Values flag annotates the broker ServiceAccount with the GSA mapping. Off by default; on for GKE customers wanting no static SA keys. Not applicable to world servers.
- Broker is HA. Multi-replica with
resourceVersionoptimistic concurrency on Secret writes, retry on conflict. - Observability is log-derived, backend-agnostic. demarkus already emits structured slog. Charts ship Datadog autodiscovery annotations + reference configs for OTel Collector, Vector, Fluent Bit, Grafana Alloy. Customer's SRE picks the agent/backend. Latency histograms deferred — depends on what current slog includes per request; revisit during trials if needed.
- Backup/DR is documented, not built. The chart deliberately does not run backup CronJobs. Operations doc covers Velero, VolumeSnapshot, and
demarkus-agent syncas DR options. - Image hosting:
ghcr.io/latebit-io/{demarkus-server,demarkus-broker,demarkus-agent}. - Chart registry: OCI charts in GHCR.
- Cosign signing: deferred to backlog. Half-day CI add when wanted.
- Groups-claim case sensitivity (revised mid-Slice-C.1 after CodeRabbit back-and-forth). Group names match case-insensitively:
AllowConfig.Groupsis lowercased+trimmed at config load (same asDomainsandEmails) andgroupsMatchlowercases the claim's groups before compare. The validated IdP set (Google, Okta, Entra ID, Auth0) enforces case-insensitive group-name uniqueness, so distinct case-variant groups can't exist; a case-sensitive compare would silently fail when an operator writes "Engineering" but the IdP emits "engineering" after upstream normalization. Keycloak with deliberately distinct case-variant groups is out of scope; revisit if a customer asks. Domains and emails get the same lowercase-at-load + plain==treatment for symmetry. - Sweeper interval capped at 24h (Slice C.2). The
defaultToken.expiresAftertypical is 24h; a sweep interval longer than that would let expired tokens linger past their ExpiresAt for up to a whole sweep cycle, defeating the short-lived-tokens identity-lifecycle model (§Revocation §3).validate()rejects withsweeper.interval must be <= 24h. Lease-election timings (15s lease / 10s renew / 2s retry) intentionally not exposed in YAML — the client-go defaults match our failover budget and adding knobs invites misconfiguration. Revisit if a customer needs sub-15s failover. sweeper.disabledrather thansweeper.enabled(Slice C.2). YAML zero-value (false) is the production-safe default so an operator who omits the block gets the sweeper running. Naming the field positively (Enabled) would have made omission silently disable the janitor — exactly the wrong default for multi-replica deployments where expired tokens accumulate forever without it.- Rotate is re-login semantics, not refresh (Slice C.3).
POST /tokens/:label/rotatere-runsworldAllows(world.Allow, claims)on every call, so a user removed from the world's allowlist between mint and rotate can NOT extend access via rotation. The bearer ID-token verification at the HTTP layer already proves the IdP identity is still active; the per-world predicate re-check catches in-org permission changes (group removed, domain renamed, email taken off carve-out). Picked over rotation-as-refresh because rotation extends the lifetime — without re-auth, a user whose IdP account stayed live could rotate forever even after losing world-scope authorization. - Rotate scope vs. lifetime asymmetry (Slice C.3). Scope (paths + operations) stays frozen to the issuance record on rotate: operator narrowing of
DefaultToken.Pathsbetween mint and rotate does NOT shrink the rotated token's reach, because the user shouldn't be surprised by reduced access mid-session — they re-login to pick up new scope. Lifetime resets tonow + DefaultToken.ExpiresAfterfrom the operator's CURRENT config: operator tightening of expiry DOES apply on rotate, because shorter lifetimes are an explicit security-tightening lever and rotation must not bypass it. The asymmetry is intentional and pinned byTestRotateLabelPreservesIssuanceScope+TestRotateLabelLifetimeUpdatesToCurrentConfig. - Rotate sequence: mint new, then revoke old (Slice C.3). On revoke failure the new token is returned anyway with a wrapped soft error; the sweeper retires the orphan old label on expiry (or via drift if an operator hand-cleans). Picked over revoke-then-mint because that alternative leaves the user with no token if mint then fails, forcing a full re-login — worse UX than a brief two-valid-tokens window for the same user. Mirrors the Slice B partial-mint convention:
(MintResult{Label:""}, err)for hard failures,(MintResult{non-empty}, err)for soft partial success. - Middleware-not-handler auth + rate limit (Slice C.4). The three authed
/tokensroutes go throughrequireAuth → subjectRateLimit → handler;/auth/logingoes throughipRateLimit → authLogin;/auth/callback,/healthz,/readyzstay middleware-free. The old per-handlers.authenticate(w, r)was extracted into therequireAuthmiddleware that stashes verifiedClaimsonr.Context()via a typed key; handlers read claims viaclaimsFromCtx. The alternative — keeping auth in handlers and adding rate-limit inside each one — would either double-verify the bearer (one verify for the limit key, one in the handler) or hide the limiter inside each handler, opaque to the route-registration block. Middleware composition keeps the per-route policy visible inRoutes(). - One shared subject bucket across the three /tokens routes (Slice C.4).
subjectRateLimitkeys onhashSubject(claims.Subject)and the same registry covers GET/tokens, DELETE/tokens/:label, and POST/tokens/:label/rotate. Picked over three per-route buckets so a misbehaving client cannot multiply effective throughput by fanning out (3 routes × 10/min would give 30/min effective rather than the operator-intended 10/min). Pinned byTestRateLimitTokensSharedBucketAcrossRoutes. Reserve()+Cancel()on denial, notAllow()(Slice C.4). Functional equivalence — neither pattern consumes budget on denial — butReserve().Delay()gives the precise wait time, which we surface asRetry-Afterwith a 1s minimum floor. (Retry-After: 0reads as "retry immediately" to aggressive clients and would defeat the limiter.) Pinned byTestRateLimitRegistryDenialDoesNotConsumeBudget(50 denials in a tight loop, then 150ms regen window allows the 51st request) and theRetry-Afterassertions on the integration 429 tests.trustForwardedFor: falsedefault in the binary (Slice C.4). The broker behind an Ingress sees the controller's IP inr.RemoteAddr, so the per-IP limiter on/auth/logincollapses into one bucket for every client unless we honor XFF. But trusting XFF when NOT behind a proxy lets an attacker rotate the header to bypass per-IP limits. The binary ships safe; chart-side §6.3.A inverts the default totrue(the chart's "behind an Ingress" assumption holds in deployment).TestRateLimitLoginIPIgnoresForwardedForByDefaultpins the default-untrusted behavior at the binary level;TestRateLimitLoginIPCrossIPIsolationpins the trust-enabled behavior.rateLimit.disablednotenabled(Slice C.4). Same shape assweeper.disabled. Zero-value (false) gives the production-safe behavior so an operator who omits therateLimit:block in their values file still gets the protection. Defaults applied at validate-time: 10/min subject burst 5, 20/min IP burst 5. Field validation skipped entirely whenDisabled: trueso an operator opting out can leave the per-route knobs empty.- Per-replica unbounded registry, by design (Slice C.4). The
rateLimitRegistrykeeps one*rate.Limiterper key with no TTL eviction and no max-entry cap. CodeRabbit flagged this as a memory-DoS vector; rejected after re-examining the threat model: (a)subjectReggrowth is bounded by IdP user count (every key required a successfulrequireAuthpass, which means a valid IdP-issued token), (b)loginReggrowth is bounded by the deployment posture (withtrustForwardedFor=falsethe key isr.RemoteAddr, bounded by clients reaching the listener; withtrustForwardedFor=trueplus a correctly-configured XFF-stripping Ingress, the key space is bounded by real client IPs), and (c) TTL/LRU eviction makes the property worse under the stated attack: an attacker rotating keys faster than the TTL gets fresh buckets indefinitely, and LRU eviction during attack evicts legitimate users' older buckets first while the attacker's fresh buckets stay in the map. The right Phase-7+ fix lives at a different layer (cluster-shared rate limiter so per-replica eviction can't reset attacker state, or per-IP rate-limit annotations at the ingress controller). Realistic worst-case sizing is ~500KB at ~10k authenticated subjects — not memory-DoS-shaped. Mirrors the C.2 "sweep is unbounded" pushback in shape. OIDC_CLIENT_SECRETenv-var override (§6.3.C, additive to §6.2).broker.LoadConfig.applyEnvOverrides()(tools/demarkus-broker/internal/broker/config.go:262) lets the OAuth client secret come from the environment instead of the on-disk config file. Env wins over file when both set; empty env is treated as unset so an accidentally-cleared variable can't blank out a file-supplied value. Production deployments keep the OAuth secret in an externally-managed Kubernetes Secret (External Secrets Operator, Sealed Secrets, Vault) mounted viasecretKeyRef, instead of baking it into the chart-rendered config Secret where it would leak into helm release history. This is a one-line escape hatch for the production-secret-ref deployment shape, NOT a §6.2 reopening —§6.2 broker binary completeframing stands. Pinned byTestLoadConfigOIDCClientSecretEnvOverride(4-row table).- helm-unittest pinned to v0.6.2 in CI (§6.3.D.1). v1.0+ uses
platformHooksin plugin.yaml which needs helm v3.16+; Fritz's local helm is v3.13.2. Pinning v0.6.2 in CI matches the local-dev validation surface so test behavior is identical between developer machines and CI. Bump together when helm itself bumps. Helm pinned to v3.13.2 alongside. test-brokerGo CI job folded into §6.3.D.1. The §6.2 broker binary had zero CI coverage at the start of §6.3 chart work —.github/workflows/ci.ymlonly filtered protocol/server/client, not tools/. Adding helm-unittest plumbing while leaving the binary CI-uncovered would have been incoherent. Newtest-brokerjob runs Go test/vet/lint/build fortools/demarkus-broker/, withworking-directory: tools/demarkus-broker(narrowed past the broadertools/scope to avoid lint failures on the package-marker filetools/tools.go). Path filter scoped totools/demarkus-broker/**+tools/go.mod+tools/go.sum+protocol/**— broker CI does NOT run on unrelated tool changes.- Three-branch hybrid template for upgrade-safe Secrets (§6.3.D.2).
deploy/helm/demarkus-server/templates/tokens.yamlkeys on the live Secret's state: (1) first install → render fresh + keep annotation; (2) legacy upgrade (Secret exists, no keep annotation) → render existing data verbatim + add the annotation, a one-time migration so future upgrades flip to branch 3; (3) race-free no-op (Secret exists + has keep annotation) → emit nothing, helm GC blocked by live annotation, broker stays sole writer. Picked over Option A (always render fromlookup) because Option A keeps helm "managing" the Secret on every upgrade, opening a race window betweenhelm template's lookup snapshot andhelm applywhile the broker is concurrently writing viamutateSecret— apply uses last-writer-wins on fields outside strategic-merge-patch and would clobber concurrent broker mutations. The hybrid's branch 3 eliminates the race entirely by removing helm from the write path post-migration. SIMULATE_LEGACY_NO_KEEPtest-mode flag over a separate script (§6.3.D.2).deploy/helm/test-upgrade-wipe.shexercises both the normal three-branch path and the migration path via one env-flag-controlled script. Two scripts would have drifted in assertion shape; one script keeps the property-under-test identical. The flag strips the annotation from the live Secret between install and upgrade to simulate a pre-D.2 release, then asserts the legacy branch re-applies the annotation while preserving data.test-charts-kindCI job (§6.3.D.2). New 47-line job in.github/workflows/ci.yml; spins up a kind cluster and runstest-upgrade-wipe.shthree times: server normal + serverSIMULATE_LEGACY_NO_KEEP+ broker normal. Triggers only on charts-path changes. Broker chart doesn't have a legacy state to migrate from (shipped with the keep annotation in §6.3.A) so a broker-legacy entry would test nothing.kubectl jsonpathbracket notation for hyphenated keys (§6.3.D.2 CodeRabbit round 2).{.data['key-with-hyphens']}, NOT{.data.key-with-hyphens}. The dot form happens to work on kubectl's permissive parser but isn't spec-conformant; a stricter version could break it silently. The kind regression script uses the bracket form throughout. Pin this convention for any future jsonpath against k8s data fields.
Open Questions
- First customer trial. Nesto (
*.library.nesto.ca) is path-B — trial waits for product. Trial runbook lands at/trials/nesto.mdwhen scoping starts. - CLI relocation to
tools/. Follow-up to Slice A. Requirestools/.goreleaser.yml, arelease-toolsjob in the workflow, aninstall.shblock fetching the tools archive, and updates to plugin scripts + helm image build. Bundled into §6.7. - Order of attack for the §6.4–§6.7 remainder. §6.7 is the most-unblocking (§6.4 examples and §6.6 docs both reference not-yet-released artifacts); §6.5 is independent and can land in parallel. Recommended: §6.7 → §6.5 (parallelable) → §6.6 incremental → §6.4 last. Surface to Fritz before branching.
- §6.7 scope: multi-binary server image NOW or split into 6.7.A/B? The §6.1 chart's exec probes reference
/demarkus(CLI) and broker integration needs/demarkus-tokenon the server image. Currentserver/Dockerfileships onlydemarkus-server. Documented caveat since §6.1 shipped (PR #107): "Charts are YAML-correct; probes will fail against the current single-binary image until 6.7 lands." Either §6.7 includes the multi-binary build, or §6.7 splits into 6.7.A (image fix + GHCR push) and 6.7.B (chart OCI publish + CLI relocation). Surface to Fritz. - Docs location:
deploy/docs/vsdocs/deployment/? Repo already hasdocs/at root for product docs (docs/SPEC.md,docs/DESIGN.md).deploy/is k8s artifacts. Slightly leansdocs/deployment/for consistency. Surface to Fritz. - Hardened broker chart symmetry (deferred from §6.3.D.2). The broker chart's
secret-issuances.yamluses the simpler lookup-skip pattern because the chart shipped with the keep annotation from §6.3.A day one (no legacy state to migrate from). If Fritz wants the same three-branch hybrid for defense-in-depth against an operator manually stripping the annotation, it's a one-paragraph follow-up sidecar PR. Not load-bearing for §6.4–§6.7; only act on if Fritz brings it up.
Repository Layout
Reflects state as of §6.3.D.2 merge (2026-05-13):
deploy/
helm/
demarkus-server/ # one-world chart (Phase 6.1) — shipped PR #107
templates/tokens.yaml # three-branch hybrid; §6.3.D.2 PR #119 added keep
# annotation + race-free no-op + legacy-migration
tests/ # helm-unittest suites — wired into CI by §6.3.D.1
# PR #118; §6.3.D.2 PR #119 added keep+namespace
# assertions on tokens_test.yaml
demarkus-broker/ # OIDC token broker chart (Phase 6.3) — shipped via
# PR #115, #116, #117
tests/ # helm-unittest suites — shipped by §6.3.D.1 PR #118
demarkus-agent/ # crawl/index agent chart (Phase 6.0) — shipped PR #106
test-upgrade-wipe.sh # generic kind regression: install → kubectl-patch
# → upgrade → assert data persists. SIMULATE_LEGACY_NO_KEEP
# env flag exercises migration path. (§6.3.D.2 PR #119)
k8s/
examples/
applicationset.yaml # Argo CD ApplicationSet over a worlds: list (§6.4)
kustomize-overlay/ # Kustomize alternative (§6.4)
observability/
datadog/ # autodiscovery annotations + dashboard JSON (§6.5)
otel-collector/ # collector config recipes (§6.5)
vector/ # vector config recipes (§6.5)
fluent-bit/ # fluent-bit parser + filter recipes (§6.5)
scripts/ # operator helpers (cert pre-check, MTU probe, etc.)
protocol/
auth.go # HashToken — sha256-<hex> contract (Slice A)
token/ # Generate, ReadFile, AppendEntry, WriteFile,
# FormatEntry, flock helpers (Slice A) +
# AppendBytes, RemoveBytes in-memory helpers
# (Slice B) + ParseBytes read-side helper
# (Slice C.2) for the broker's drift sweep.
server/cmd/
demarkus-server/ # the server binary (existing)
demarkus-token/ # token mint CLI — uses protocol/token
demarkus-publish/ # publish CLI (existing; deferred move)
client/cmd/
demarkus-agent/ # protocol client — federation crawler (existing)
tools/
demarkus-broker/ # broker binary (Slice B PR #109, Slice C.1 PR #110,
# Slice C.2 PR #111, Slice C.3 PR #112, Slice C.4
# PR #114). main.go + internal/broker/
# { config, session, oidc, issuer, server,
# labels, sweeper, ratelimit }. §6.2 broker
# binary complete; SCIM webhook stays in backlog.
Sub-Phases
6.0 — demarkus-agent verified + chart ✓ (merged PR #106)
Existing demarkus-agent binary verified end-to-end (2 team worlds + 1 hub smoke test) and chart at deploy/helm/demarkus-agent/ shipped. Stateless Deployment; ConfigMap holds TOML agent config; Secret holds per-host tokens; exec liveness probe; outbound-only (no Service). Three publish bugs fixed during verification (publishIndex status acceptance, expected_version=-1 for idempotent re-publish, Makefile make client build).
6.1 — demarkus-server Helm chart ✓ (merged PR #107) + §6.3.D.2 follow-up ✓ merged
deploy/helm/demarkus-server/ — StatefulSet, 1 replica, volumeClaimTemplates (each world owns its PVC, never shared), exec probes against /.well-known/agent-manifest.md, Service type LoadBalancer protocol: UDP. Bootstrap path seeds the initial admin token via protocol/token.Generate + AppendEntry (Slice A). Cert-manager Certificate resource behind a flag. helm-unittest test files at tests/ — initially shipped broken (the statefulset_test.yaml suite only loaded statefulset.yaml, so the StatefulSet template's include "...tokens.yaml" for checksum/config failed render and all 13 tests errored), then fixed in §6.3.D.1 PR #118 and wired into CI for the first time.
§6.3.D.2 sidecar landed: the tokens Secret rendered by templates/tokens.yaml now carries helm.sh/resource-policy: keep via the three-branch hybrid template — first install renders the annotation, legacy upgrade re-renders existing data + adds the annotation as a one-time migration, race-free no-op skips render once the annotation is in place. PR #119 merged 2026-05-13 (commit 3d3b4bc). The kind upgrade-wipe regression in CI (test-charts-kind job) pins the property end-to-end against a live cluster rather than re-asserting it in prose.
6.2 — demarkus-broker binary (tools/demarkus-broker/) ✓ complete
Slice B (single-world OIDC mint flow) merged as PR #109 (commit 2a6aae6, 2026-05-11). Slice C.1 (groups-claim authorization + AllowEmails carve-out + email canonicalization) merged as PR #110 (commit 343f808, 2026-05-12). Slice C.2 (expiry + drift sweeper with Lease-based leader election, RBAC-denied mint guard) merged as PR #111 (commit cb5f800, 2026-05-12). Slice C.3 (POST /tokens/:label/rotate with re-login semantics + scope-frozen lifetime-reset asymmetry) merged as PR #112 (commit 6ee0d8b, 2026-05-12). Slice C.4 (per-subject + per-IP rate-limit middleware with shared subject buckets across the three /tokens routes, optional leftmost-XFF trust for the IP limiter, Reserve()+Cancel() for denial-doesn't-consume-budget) merged as PR #114 (commit e2933ff, 2026-05-12). §6.2 broker binary complete.
The §6.3.C OIDC_CLIENT_SECRET env-var override (applyEnvOverrides in config.go:262) is a one-line additive surface that does NOT reopen §6.2 — see §Decisions for the framing.
Role & topology
- Issuance authority, not a request proxy. Broker sits on the
demarkus loginpath; clients then talk to world servers directly carrying the raw token. World servers stay identity-blind; broker never seesmark://requests. Capability model preserved. - One broker per universe (cluster) by default. Single OIDC client registration, single
worlds:list in values, single issuance state Secret. - Broker SA holds a namespace-scoped
Role+RoleBindingin each world's namespace, withget/updatelimited to that world'stokensSecret only. NoClusterRole. Slice C.2 sweeper addscoordination.k8s.io/leasesget/create/updatein the broker namespace for leader election; §6.3 chart bundles both into the broker SA.
State
Two distinct Kubernetes Secrets, never merged:
| Secret | Lives in | Contents | Reader |
|---|---|---|---|
<world>-tokens |
each world's namespace | TOML: [tokens.<label>] hash=, paths=, operations=, expires= |
demarkus-server |
<broker>-issuances |
broker namespace | JSON: label → {email, world, paths, operations, issued_at, expires} + secondary index email → [labels] |
broker only |
World servers see only hashes. Email and identity live in broker state alone. Both Secrets carry helm.sh/resource-policy: keep end-to-end: the broker-side issuances Secret since §6.3.A, the world-side tokens Secret since §6.3.D.2.
Plus one cluster-scoped coordination object (Slice C.2):
| Resource | Lives in | Contents | Reader |
|---|---|---|---|
demarkus-broker-sweeper Lease |
broker namespace | holderIdentity, renewTime, leaseDurationSeconds |
leader-election library only |
Per-world authorization (values schema as shipped in C.1)
worlds:
- name: team-a
namespace: team-a
tokensSecret: team-a-tokens
allow:
domains: ["nesto.ca"]
groups: ["engineering"]
emails: ["alice@example.com"]
defaultToken:
paths: ["/team-a/*"]
operations: ["read", "publish"]
expiresAfter: 24h
sweeper:
disabled: false
interval: 5m
leaseName: demarkus-broker-sweeper
rateLimit:
disabled: false
tokens:
perMinute: 10
burst: 5
login:
perMinute: 20
burst: 5
trustForwardedFor: false # binary default; chart §6.3.A inverts to true
WorldConfig.Allow is a nested struct. All three lists lowercased+trimmed at config load with empty entries rejected. Match is case-insensitive everywhere. The authorization predicate (worldAllows):
- All three lists empty → match (back-compat).
- Email in
Allow.Emails→ match (per-user carve-out). - Only
Emailswas configured and step 2 didn't fire → reject. domainMatches AND groupsMatch.
Mint and RotateLabel canonicalize claims.Email (trim + lowercase) once at the top before authorization runs.
Token-mint library
Broker imports github.com/latebit/demarkus/protocol/token. Slice A on-disk + pure helpers (Generate, AppendEntry, WriteFile, ReadFile, FormatEntry). Slice B in-memory helpers (AppendBytes, RemoveBytes) for k8s-Secret round-trips. Slice C.2 in-memory read-side helper (ParseBytes) for drift detection — empty input gives an empty File with a non-nil Tokens map. Slice C.3 refactored Issuer.mintForWorld to take explicit paths, operations []string parameters so Mint and RotateLabel share the cross-world-collision + rollback machinery.
Revocation
Three triggers, one cleanup index (the issuances Secret):
- User-initiated (
DELETE /tokens/:labelorPOST /tokens/:label/rotate, both bearer-authed via OIDC ID token). Owner check +revokeIssuance(Slice C.2 helper extracted fromRevoke; shared with the sweeper). Rotate re-runsworldAllowsbefore minting (re-login semantics). - Expiry sweeper (Slice C.2, leader-elected via
Lease). Reads issuances once per tick, groups non-expired entries by world, reads each affected world's tokens Secret once viaParseBytes, retires (expired ∪ drifted) entries viarevokeIssuance. - Identity lifecycle — Default: short-lived tokens (
defaultToken.expiresAfter~24h). SCIM webhook backlogged.
SIGHUP intentionally not implemented in C.2/C.3/C.4 — revocation latency equals kubelet Secret-mount propagation + server re-read behavior. Verified during §6.3 chart work as acceptable for the deployment posture (kubelet's default ~60s propagation is within the operator-visible bound).
Cleanup edge cases
- Orphan in
tokens.toml(admin-minted via CLI): broker never claims it. - Orphan in issuances Secret (admin hand-deleted from world tokens Secret): Slice C.2 sweeper detects drift via
ParseBytesand prunes. - Issuance for an unconfigured world: conservative skip (log, never silently drop). Verified by
TestSweepSkipsUnconfiguredWorldandTestRotateLabelMissingWorldErrors. - In-flight request after revoke/rotate: a raw token already authenticated on a live QUIC connection completes its current request. Property of model.
- Partial mint across multiple worlds: failed world has zero state in either Secret; prior worlds in the same Mint stay committed. Verified by
TestMintRBACDeniedNoPartialState. - Soft-partial rotate (Slice C.3): mint succeeded but old revoke failed → both labels coexist until sweeper retires the old. User gets 200 + new token; operator sees WARN log. Verified by
TestRotateTokenSoftPartial.
Routes (HTTP, behind Ingress) — middleware chains as shipped in C.4
GET /auth/login—ipRateLimit → authLogin. OIDC redirect entry point; sets signed state cookie (HMAC-SHA256, HttpOnly+Secure+SameSite=Lax, path-scoped to/auth/callback, default 5-min TTL).GET /auth/callback— no middleware. State cookie HMAC + 5-min TTL is the natural rate gate. Verifies state cookie, exchanges code, mints tokens for every qualifying world, returns JSON{world → raw_token}once. No broker session cookie.GET /tokens—requireAuth → subjectRateLimit → listTokens. Bearer-authed (OIDC ID token).DELETE /tokens/:label— same chain. Bearer-authed.POST /tokens/:label/rotate— same chain (Slice C.3). Bearer-authed. Owner check +worldAllowsre-validation against current Allow config.GET /healthz,GET /readyz— no middleware. Probes must succeed without auth or rate-limit gating.
Rationale for skipping a broker session: the CLI is the primary /tokens consumer and already handles token lifetimes; the browser only sees the one-time JSON callback response. The Verifier interface exposes VerifyIDToken(ctx, raw) (Claims, error) so handlers authenticate bearer tokens uniformly.
OIDC providers
Provider behind a Verifier interface (AuthCodeURL, Exchange, VerifyIDToken). Initial implementation wraps coreos/go-oidc/v3 + golang.org/x/oauth2. Follow-ons (Okta, Entra ID, Auth0) are config only. Groups claim sourcing (Slice C.1 scope): ID-token only; userinfo-based groups in backlog.
HA
Multi-replica. Issuance writes to k8s Secrets use resourceVersion optimistic concurrency with retry-on-conflict. Sweeper (C.2) uses a coordination.k8s.io/Lease for leader election via k8s.io/client-go/tools/leaderelection; ReleaseOnCancel=true for sub-second handoff on rolling restarts. Mint/List/Revoke/Rotate handlers stay on every replica. Rate limit (C.4) is per-replica per the rateLimitRegistry doc-comment — effective rate seen by an abusive client is up to N× the configured value in a multi-replica deployment. Cluster-shared rate limiting backlogged.
Slice B test surface (shipped)
87% line coverage. Mint flow + state cookie + config + issuer (resourceVersion retry, label collision retry, mint ordering) + server (clock decoupling).
Slice C.1 test surface (shipped — PR #110)
- 15-row
TestMintAuthorizationPredicatecovers every (Domains × Groups × Emails) × accept/reject cell. TestMintCanonicalizesEmail— pins email trim+lowercase at top of Mint.TestLoadConfigrows for lowercase normalization + empty-entry rejection on all three allow dimensions.TestDeleteTokenNotOwner— owner-check 403.
Slice C.2 test surface (shipped — PR #111)
TestSweepRetiresExpiredKeepsFresh, TestSweepPrunesDrift, TestSweepSkipsUnconfiguredWorld, TestSweepEmptyIssuancesIsNoop, TestSweeperLeaderElection (two replicas + handoff), TestMintRBACDeniedNoPartialState, TestLoadConfig rows for sweeper.interval defaults + cap + negative-rejection. Coverage 81.0%.
Slice C.3 test surface (shipped — PR #112)
Issuer-layer (8): TestRotateLabelHappyPath, TestRotateLabelRejectsUnverifiedEmail, TestRotateLabelNotOwner, TestRotateLabelUnknownLabel, TestRotateLabelPreservesIssuanceScope, TestRotateLabelLifetimeUpdatesToCurrentConfig, TestRotateLabelReauthorizesAgainstCurrentAllow, TestRotateLabelMissingWorldErrors. HTTP-layer (5): success, not-owner, no-longer-authorized, not-found, soft-partial, unauthenticated. Coverage 81.1%.
Slice C.4 test surface (shipped — PR #114)
Registry-level (ratelimit_test.go):
TestRateLimitRegistryNilWhenDisabledOrZero(5-row table): nil for non-positiveperMinuteor non-positiveburst. Pins the "disabled = no enforcement" contract.TestRateLimitRegistryAllowsBurstThenDenies: burst+1 → first two allowed, third denied,retryAfter >= 1s(1s floor).TestRateLimitRegistryCrossKeyIsolation: k1 exhausted, k2 still allowed.TestRateLimitRegistryDenialDoesNotConsumeBudget: 50 denials in a tight loop + 150ms regen recovers. PinsReserve()+Cancel()no-consume-on-denial.
ClientIP / middleware misuse:
TestServerClientIP(7-row table): untrusted XFF ignored, trusted single-hop, trusted multi-hop takes leftmost, trusted-but-empty falls back, trusted-whitespace-only falls back, IPv6, garbage RemoteAddr.TestSubjectRateLimitMissingClaimsIs500: regression guard for misuse — 500 + ERROR log rather than silent enforcement disable.
HTTP integration:
TestRateLimitTokensExhaustsAndReturns429WithRetryAfter: burst=2 exhaust → 3rd returns 429 + non-zeroRetry-After.TestRateLimitTokensCrossSubjectIsolation: alice exhausts; bob (different Subject via multi-bearer verifier) still passes.TestRateLimitTokensSharedBucketAcrossRoutes: 1 GET + 1 DELETE consumes burst 2; 3rd on a different route 429s. Pins single-bucket-for-three.TestRateLimitLoginIPExhaustsAndReturns429WithRetryAfter: XFF trusted, same XFF entry 3× → 3rd 429s.TestRateLimitLoginIPCrossIPIsolation: XFF trusted, two different spoofs → distinct buckets.TestRateLimitLoginIPIgnoresForwardedForByDefault: XFF untrusted, three different XFF spoofs collapse to one bucket.TestRateLimitDisabledBypasses: 20 rapid requests all 200 whenrateLimit.disabled: true.
Config-level (config_test.go): defaults applied when block omitted (10/5 + 20/5), operator overrides honored, disabled: true bypasses field defaults, four "negative value rejected" rows. Coverage 83.4% (up from C.3's 81.1%).
6.3 — demarkus-broker Helm chart ✓ complete (all five sub-slices merged)
Multi-replica HA at deploy/helm/demarkus-broker/, shipped across five sub-PRs, all merged 2026-05-13:
- §6.3.A ✓ merged (PR #115, commit
c71f499) — chart skeleton: Deployment + Service + config Secret + issuances Secret + ServiceAccount + values schema. Cookie-keylookup-preserve across upgrades. Issuances Secret seeded withhelm.sh/resource-policy: keep. OIDC client-secret mutual-exclusion +existingSecretRef.keyrequired guards at template-render time. PodSecurityContext + container hardening (runAsNonRoot,readOnlyRootFilesystem,seccompProfile: RuntimeDefault,capabilities: drop ["ALL"],allowPrivilegeEscalation: false). TopologySpreadConstraints bykubernetes.io/hostname,ScheduleAnyway.checksum/configpod annotation.rateLimit.trustForwardedFor: truechart default (inverts binary). - §6.3.B ✓ merged (PR #116, commit
175e230) — RBAC + NetworkPolicy + PDB. Per-worldRole+RoleBindingloop, verbs scoped toget/updateon the named tokens Secret (NOTcreate). Broker-namespaceRoleforcoordination.k8s.io/leasesget/create/update(createnecessarily unscoped per k8s RBAC; documented) +secretsget/updateon the issuances Secret. NetworkPolicy default-on with ingress from the configured ingress-controller namespace and egress allowing DNS to kube-system + TCP 443 unrestricted. PDB defaultminAvailable: 1. ServiceAccount Workload Identity annotation plumbing. - §6.3.C ✓ merged (PR #117, commit
599f63a) — Ingress + cert-manager Certificate templates +oidc.existingSecretRefdeployment path. Three Ingress TLS modes mutex-guarded at render-time.OIDC_CLIENT_SECRETenv-var injected viasecretKeyRefwhen existingSecretRef path is taken; renderedconfig.yamlleavesclientSecretblank. Binary additive surface:applyEnvOverrides()intools/demarkus-broker/internal/broker/config.go— env wins over file, empty env is unset, 4-rowTestLoadConfigOIDCClientSecretEnvOverridepins all rows. README + NOTES.txt expanded. - §6.3.D.1 ✓ merged (PR #118) — helm-unittest test files for the broker chart at
deploy/helm/demarkus-broker/tests/(49 tests across 7 suites), CI plumbing wiring helm-unittest into PR runs for BOTH demarkus-server and demarkus-broker charts, and a folded-intest-brokerGo CI job (tools/demarkus-broker/) closing the §6.2 binary-CI gap. Path-filter narrowed to broker-specific paths to keep unrelated tool changes from triggering broker CI. helm-unittest pinned to v0.6.2; both new jobstimeout-minutes: 20. Fixed a real prior-work bug indeploy/helm/demarkus-server/tests/statefulset_test.yamlthat had silently errored all 13 §6.1 tests since the day it was written (template-include issue masked by helm-unittest never running in CI). - §6.3.D.2 ✓ merged (PR #119, commit
3d3b4bc) — sidecar closing the §6.1 prior-work debt. Three-branch hybrid template atdeploy/helm/demarkus-server/templates/tokens.yaml(first install / legacy upgrade / race-free no-op) lands thehelm.sh/resource-policy: keepannotation on the demarkus-server tokens Secret without opening ahelm template-vs-helm applyrace against the broker'smutateSecretwrites. Newdeploy/helm/test-upgrade-wipe.shgeneric kind regression script withSIMULATE_LEGACY_NO_KEEPenv flag exercising both the normal three-branch path and the migration path. Newtest-charts-kindCI job runs three matrix entries: server normal + serverSIMULATE_LEGACY_NO_KEEP+ broker normal. Three new assertions ondeploy/helm/demarkus-server/tests/tokens_test.yaml(10 tests total) for the keep annotation + explicitmetadata.namespace. CodeRabbit round 1 caught a critical legacy-upgrade footgun (original two-branch draft would have wiped Secrets on first upgrade of any pre-D.2 release); round 2 caught akubectl jsonpathdot-notation issue with hyphenated keys (switched to spec-conformant bracket form{.data['key']}).
§6.3 chart test surface (shipped — PR #118 + PR #119)
helm-unittest test files at deploy/helm/demarkus-broker/tests/:
deployment_test.yaml(12 tests,template: deployment.yaml): replicas + RollingUpdate strategy, image tag default = AppVersion,checksum/configannotation regex-pinned, pod-level securityContext (runAsNonRoot,runAsUser/Group: 65532,fsGroup: 65532,seccompProfile: RuntimeDefault), container securityContext (readOnlyRootFilesystem,allowPrivilegeEscalation: false,capabilities: drop ALL), HTTP probes on/healthz+/readyz, probes omitted when disabled,POD_NAMEenv from fieldRef,OIDC_CLIENT_SECRETenv ABSENT when cleartext clientSecret only,OIDC_CLIENT_SECRETenv present viasecretKeyRefwhenexistingSecretRef.nameset, topologySpreadmatchLabelspopulated from selector labels (NOT the empty{}in values), container args reference/etc/demarkus-broker/config.yaml.rbac_test.yaml(7 tests): broker-ns Role + RoleBinding render inbrokerNamespace, leases rule pinned toget/create/updateon configuredleaseName, secrets rule pinned toget/updateon issuancesSecret (regression-guarded againstcreate/delete/listdrift), per-world template emits N×2 resources for N worlds, per-world Role verbsget/updateonly (NOTcreate/delete), per-world RoleBinding subject references broker SA in release namespace,rbac.create=falseskips all.networkpolicy_test.yaml(7 tests): default-on,policyTypesincludes both Ingress AND Egress, ingress scoped toingress-nginxnamespace by default + override, egress DNS to kube-system on UDP+TCP 53, egress TCP 443 unrestricted (noto:selector), disabled-state.secret-config_test.yaml(8 tests): rendered Secretmetadata.namespaceexplicit, rate-limit defaults rendered (trustForwardedFor: true, 10/5, 20/5), sweeper defaults rendered, existingSecretRef leavesclientSecretblank, clientSecret cleartext rendered verbatim, fail-render mutex (both set), fail-render required (neither set), fail-render existingSecretRef.key blank, world with noallowblock renders empty arrays for all three dimensions.secret-issuances_test.yaml(4 tests): empty Opaque Secret +data: {},helm.sh/resource-policy: keepannotation,metadata.namespacetracksbrokerNamespacehelper,server.brokerNamespaceoverride moves the Secret.ingress_test.yaml(8 tests): both absent by default, host-required fail-render, TLS mode mutex fail-render, existingSecret references provided Secret, certManager-mode Certificate emitted with correct dnsName/secretName/issuerRef, Ingress in certManager mode references chart-generated TLS Secret,ingressClassNameoverride propagates.pdb_test.yaml(3 tests): defaultminAvailable: 1+ selector labels, override propagates, disabled-state.
helm-unittest test file at deploy/helm/demarkus-server/tests/tokens_test.yaml (10 tests, §6.3.D.2 added three):
- Tokens Secret carries
helm.sh/resource-policy: keep(regression for the §6.3.D.2 fix). - Token-values Secret carries the same.
- Tokens Secret renders explicit
metadata.namespaceso the lookup-skip branch checks the right namespace on upgrade.
Live-cluster regression at deploy/helm/test-upgrade-wipe.sh (§6.3.D.2):
- Install chart with values stub → kubectl-patch the rendered Secret to seed fake data → helm upgrade with a values change → assert seeded data persists → uninstall → assert Secret survives uninstall under the keep annotation.
SIMULATE_LEGACY_NO_KEEPenv flag strips the annotation between install and upgrade to simulate a pre-D.2 release; asserts the legacy branch re-applies the annotation while preserving data.
CI plumbing (.github/workflows/ci.yml):
detect-changesfilter addsbroker(tools/demarkus-broker/**+tools/go.mod+tools/go.sum+protocol/**) andcharts(deploy/helm/**).test-brokerjob — Go test/vet/lint/build fortools/demarkus-broker/. Closes the §6.2 binary-CI gap (§6.3.D.1).test-chartsjob —helm linton all three charts +helm unitteston server + broker (§6.3.D.1).test-charts-kindjob — kind cluster +test-upgrade-wipe.sh× 3 matrix entries (§6.3.D.2).
6.4 — Universe topology examples
ApplicationSet + Kustomize overlay at deploy/k8s/examples/. ApplicationSet template generator iterates a worlds: list and templates one Application per world pointing at the demarkus-server chart with per-world values. Kustomize overlay is the GitOps-without-Argo alternative: base + per-world overlay directories. Customer's ops team picks one. Verification surface: argocd app create --dry-run or kustomize build against a kind cluster.
6.5 — Observability recipes
Per-backend configs in deploy/observability/. Slice C.2 added sweeper-side log lines (broker: swept, broker: sweep failed, broker: sweeper observing new leader, broker: sweeper lost leadership); Slice C.3 added rotate-side log lines; Slice C.4 added rate-limit log lines (broker: rate limit exceeded with route, subject or ip, retryAfter fields) — same audit-log routing target. Recipes are pure config — no core code changes. Datadog autodiscovery via pod annotations + a dashboard JSON; OTel Collector via a config.yaml consuming container stdout; Vector via a [transforms.parse] block; Fluent Bit via parser + filter; Grafana Alloy via a discovery.kubernetes + loki.process chain. Independent of §6.7.
6.6 — Documentation suite
docs/deployment/*.md (location pending Fritz decision — see Open Questions). Per-chart READMEs at deploy/helm/<chart>/README.md (broker README exists from §6.3.C; server + agent need similar treatment). Install guide (per-provider OIDC setup for Google / Okta / Entra ID / Auth0; chart install order: server → broker → agent; cert-manager + DNS topology). Security/threat model (capability auth, namespace-scoped broker RBAC, identity-blind world servers, the XFF-trust invariant from Slice C.4). Operations (upgrade path including the now-tested resource-policy:keep contract, backup/DR via Velero or demarkus-agent sync, sweeper lease handoff observability). Observability recipes (links to §6.5).
6.7 — Release pipeline
GHCR images + OCI charts. Per the existing server/.goreleaser.yml and client/.goreleaser.yml patterns: add tools/.goreleaser.yml for the broker, add a release-tools job to .github/workflows/release.yml. Multi-binary image build for demarkus-server (needs the CLI on the image for exec probes, plus demarkus-token for broker integration — currently single-binary, this is the §6.1 footnote that's been carried since PR #107 shipped). OCI chart publish via helm push oci://ghcr.io/... from the same workflow. Folds in the deferred CLI relocation: demarkus-token and demarkus-publish move from server/cmd/ to tools/, with the release pipeline updated to match. Cosign signing stays backlogged. Open question: scope as one PR or split 6.7.A (image fix + GHCR push) / 6.7.B (chart OCI publish + CLI relocation) — see §Open Questions.
Sequencing
- Slice A — token-mint library ✓ merged 2026-05-11 (PR #108).
- 6.0 chart ✓ merged 2026-05-11 (PR #106).
- 6.1 server chart ✓ merged 2026-05-11 (PR #107). §6.3.D.2 sidecar ✓ merged 2026-05-13.
- 6.2 broker binary — Slice B ✓ merged 2026-05-11 (PR #109, commit
2a6aae6). - 6.2 broker binary — Slice C, split into four landed PRs:
- C.1 ✓ merged 2026-05-12 (PR #110, commit
343f808) — groups-claim +AllowEmailsauthorization. - C.2 ✓ merged 2026-05-12 (PR #111, commit
cb5f800) — expiry + drift sweeper withcoordination.k8s.io/Leaseleader election. - C.3 ✓ merged 2026-05-12 (PR #112, commit
6ee0d8b) —POST /tokens/:label/rotatewith re-login semantics + scope-frozen/lifetime-reset asymmetry. - C.4 ✓ merged 2026-05-12 (PR #114, commit
e2933ff) — per-subject + per-IP rate limit middleware viagolang.org/x/time/rate, in-memory per-replica. - SCIM webhook stays in backlog.
- C.1 ✓ merged 2026-05-12 (PR #110, commit
- 6.3 broker chart ✓ five-slice trajectory all merged 2026-05-13:
- 6.3.A ✓ PR #115, commit
c71f499— chart skeleton + security defaults. - 6.3.B ✓ PR #116, commit
175e230— RBAC + NetworkPolicy + PDB. - 6.3.C ✓ PR #117, commit
599f63a— Ingress + cert-manager Certificate +oidc.existingSecretRef+OIDC_CLIENT_SECRETenv-var override. - 6.3.D.1 ✓ PR #118 — helm-unittest test files for broker chart, CI plumbing for both charts, folded-in
test-brokerGo CI job. - 6.3.D.2 ✓ PR #119, commit
3d3b4bc— §6.1 chart three-branch hybrid for keep-annotation lockdown + kind upgrade-wipe regression in CI.
- 6.3.A ✓ PR #115, commit
- 6.4 topology examples.
- 6.5 observability recipes.
- 6.6 docs — incremental throughout.
- 6.7 release pipeline — final. Possibly split 6.7.A/B (multi-binary image now vs. with CLI relocation) — see §Open Questions.
Rough effort: ~3-5 days of focused work remaining (§6.4 examples + §6.5 recipes + §6.6 docs + §6.7 release pipeline).
Backlog (deferred, easy to add later)
- Cosign signing of images + chart releases. Half-day CI add.
- Latency log-enrichment (
duration_msfield on request slog lines). Tiny additive change. - SCIM lifecycle webhook on the broker. Optional add when a customer asks.
- CLI relocation to
tools/(demarkus-token,demarkus-publish). Bundled with 6.7. - Userinfo-based groups in the broker
Verifier. Slice C.1 ships ID-token-only;AllowEmailsis the documented workaround. - Case-sensitive group matching for Keycloak. Slice C.1 chose case-insensitive for the validated IdP set.
- Configurable lease timings (
sweeper.leaseDuration,renewDeadline,retryPeriod). C.2 hardcodes the client-go defaults. - SIGHUP on revoke + sweep + rotate to shrink the revoke-to-effect window. Needs
pods/execRBAC across world namespaces. - Sweeper backing-store migration when the issuances Secret hits the ~1MB / ~5000-issuance k8s storage ceiling.
- Cluster-shared rate limiter (Slice C.4 follow-up). Backend (Redis / memcached / k8s Lease-coordinated tokens) so a multi-replica broker enforces one budget across all replicas, instead of N× the configured rate. Required if a customer's failure model is "an attacker who can spread requests across replicas" rather than "a noisy legitimate user."
- Idle-key GC for the rate-limit registry (Slice C.4 follow-up). Background goroutine evicting limiters whose token bucket is full and whose
lastSeenexceeds a TTL. Only worth adding if a customer hits a sized-up deployment with millions of distinct authenticated subjects over the broker's uptime; per the C.4 doc-comment, ~10k subjects is ~500KB. Eviction does NOT defend against attacker-driven inflation — that wants cluster-shared rate limiting upstream of the eviction point. - Timeouts on the existing
test-protocol/test-server/test-clientCI jobs. §6.3.D.1 addedtimeout-minutes: 20to the newtest-brokerandtest-chartsjobs per CodeRabbit feedback; the older jobs still rely on the GitHub Actions default (360 min). One-line cleanup. - Hardened broker chart Secret symmetry. Broker's
secret-issuances.yamluses the simpler lookup-skip pattern (vs. the §6.3.D.2 three-branch hybrid on the server chart). Defense-in-depth follow-up for the scenario "operator strips the annotation manually → broker upgrade re-applies it without wiping data." Not load-bearing — broker chart shipped with keep annotation from §6.3.A day one so there's no real legacy state to migrate.
Risks
- Observability via logs ceiling. If customers want signals not derivable from current slog, we hit a wall. Mitigation: log enrichment is a small additive change.
- Broker secret-write blast radius. Mitigated by namespace-scoped
Roles, audit log, optionalNetworkPolicy. Per-worldRoleis non-negotiable.TestMintRBACDeniedNoPartialStatevalidates clean failure. - OIDC provider coupling. First impl is Google; structure so second provider is a one-day add.
- Token revocation in-flight latency. Revocation latency = kubelet Secret propagation + whatever the server does on re-mount. Acceptable for the deployment posture (~60s default kubelet propagation).
- Rotate transient two-tokens window. Mint-then-revoke means both labels are briefly valid; widens to next sweep cycle on soft-partial. Documented; verified by
TestRotateTokenSoftPartial. - §6.1 chart upgrade-wipe gap. ✓ Resolved by §6.3.D.2 PR #119. Three-branch hybrid template lands the
helm.sh/resource-policy: keepannotation; kind regression intest-charts-kindpins the property end-to-end. Both branches of the legacy migration path AND the race-free no-op path are exercised in CI on every charts-touching PR. - §6.1 chart-test plumbing latent failure. ✓ Resolved by §6.3.D.1 PR #118. helm-unittest runs in CI; the structural bug in
statefulset_test.yaml(only loaded one template, silently errored all 13 tests since day one) is fixed. - Issuances Secret scaling wall at the ~1MB / ~5000-issuance k8s storage ceiling. Phase-7+ fix is a different backing store.
- Chart proliferation. Three charts + examples + dashboards. Mitigate with shared common-labels templates.
- Trial scope creep. First customer is path-B.
- Per-replica rate limit ⇒ N× effective rate. Slice C.4's rate limiter is per-pod, so a multi-replica broker enforces up to N× the configured rate. Same shape as the
Sweeper.ReleaseOnCancelposture: a known per-replica property documented in therateLimitRegistrydoc-comment, deferred fix (cluster-shared limiter) sized for Phase-7+. If a customer's threat model requires strict global rate-limiting, the cluster-shared backend in §Backlog needs to land before they go live. - Rate-limit registry growth under misconfigured XFF trust. Slice C.4's
loginRegkeys ons.clientIP(r). WithtrustForwardedFor=trueand no trusted proxy stripping spoofed XFF in front, an attacker can rotate the header to inflate the map. The defense at this layer (TTL/LRU eviction) makes the property worse under the same attack — see the per-replica unbounded Decision bullet. The correct fix is at the deployment layer: the §6.3 chart README +rateLimit.trustForwardedFordoc-comment call out that the chart defaulttrueis only safe behind an Ingress that strips spoofed XFF. Operators flipping the flag in a directly-internet-exposed broker is the failure mode. - §6.7 multi-binary image hand-off. The §6.1 chart's exec probes reference
/demarkus(CLI) on the server image; the broker integration references/demarkus-token. Currentserver/Dockerfileships onlydemarkus-server. Until §6.7 multi-binary work lands, the charts are YAML-correct but probes fail against the as-shipped image. Documented caveat since §6.1; pin to §6.7 sequencing or split 6.7.A.
Status
Plan v15, 2026-05-13. Slice A, 6.0 chart, 6.1 chart (incl. §6.3.D.2 sidecar), 6.2 broker binary (Slices B + C.1–C.4), 6.3 broker chart (all five sub-slices A + B + C + D.1 + D.2) all merged (PRs #106, #107, #108, #109, #110, #111, #112, #114, #115, #116, #117, #118, #119). §6.2 broker binary complete. §6.3 broker chart fully complete. §6.1 chart upgrade-wipe gap resolved end-to-end via kind regression.
§6.3 trajectory recap (all 2026-05-13):
- 6.3.A (PR #115,
c71f499): chart skeleton + security defaults. Cookie-key lookup-preserve, issuances Secretresource-policy: keep, OIDC mutual-exclusion guards at render-time, full PodSecurityContext lockdown, topology spread,checksum/configannotation,rateLimit.trustForwardedFor: truechart default. - 6.3.B (PR #116,
175e230): per-world + broker-ns RBAC, NetworkPolicy default-on, PodDisruptionBudget, Workload Identity SA annotation. Per-world Role verbs pinned toget/update(NOTcreate); broker-ns leases rule includescreateper k8s RBAC caveat (resourceName scoping doesn't apply tocreate). - 6.3.C (PR #117,
599f63a): Ingress + cert-manager Certificate +oidc.existingSecretRefdeployment shape +OIDC_CLIENT_SECRETenv-var override in the binary (config.go:262). Three TLS modes mutex-guarded;existingSecretRef.keyrequired when name set. README + NOTES.txt expanded with production checklist. - 6.3.D.1 (PR #118): seven helm-unittest test suites (49 tests) covering the chart's load-bearing invariants, CI plumbing for both charts via new
test-chartsjob, folded-intest-brokerGo CI job closing the §6.2 binary-CI gap. helm-unittest pinned to v0.6.2. Fixed real prior-work bug in §6.1's statefulset_test.yaml that had silently errored since day one. - 6.3.D.2 (PR #119,
3d3b4bc): three-branch hybrid template atdeploy/helm/demarkus-server/templates/tokens.yamllands thehelm.sh/resource-policy: keepannotation without opening ahelm template-vs-helm applyrace.deploy/helm/test-upgrade-wipe.shgeneric kind regression withSIMULATE_LEGACY_NO_KEEPflag;test-charts-kindCI job runs server normal + server legacy + broker normal. Three new assertions ontokens_test.yaml. Closes the §6.1 prior-work debt that earlier plan versions had carried as an open risk.
Next: §6.4–§6.7 remainder. Pending Fritz decision on order of attack and §6.7 scope split — see Open Questions §3, §4. Likely sequence: §6.7 release pipeline (most unblocking) → §6.5 observability recipes (parallelable) → §6.6 docs (incremental) → §6.4 topology examples (last; depends on §6.7 release artifacts). Surface the open questions before branching.