Replace the shared rabbit administrator with least-privilege broker principals¶
This is the implementation plan for the credential half of the broker security contract (B0). It extends the contract: the target model, the naming, the transport design and the B2 end state stay as written there. This plan updates the facts to 2026-10-14, closes the gaps the contract left open, and orders the work into reversible phases. Chris approved writing it on 2026-10-14 and accepted every decision in §11 as recommended on 2026-10-14.
This document changes nothing live. Phase 0, Phase 1 (steps 1 to 4), Phase 2
steps 1 and 2, D11 and Phase 6 step 1 are done; Phase 2 step 3's chart switch
(syrf#4310) moves each preview to syrf-pr-N as it redeploys
from a head that includes it (§1); every remaining phase needs its own PR and its own approval. Each
step marked (P) touches production or the broker that production shares, and needs Chris's explicit
go for that step.
Contents¶
- Brief versus reality
- Evidence and markers
- Hard operational rule
- 1. Where we are on 2026-10-15
- 2. Target model
- 3. Permission regexes per principal
- 4. How the broker learns the principals
- 5. Secret distribution
- 6. Management UI exposure
- 7. Phases
- 8. Production impact per phase
- 9. Acceptance criteria
- 10. Wolverine and the MassTransit 8.5.11 bridge
- 11. Decisions for Chris
- 12. Superseded and related PRs
- 13. Risks
- 14. Follow-ups outside this plan
Brief versus reality¶
The brief for this plan stated some facts that git and the cluster contradict. The plan follows the evidence:
| # | Brief | Evidence | Effect on the plan |
|---|---|---|---|
| R1 | MassTransit 8.5.11 today | Every MassTransit* package on main is 8.4.0 (V). The 8.5.11 bridge is open as syrf#4147 (ADR-021 P1). |
Regexes are drafted against 8.4.0. The fixture must re-run on #4147's head before either merges after the other (§10). Resolved: #4147 merged first, and Phase 0.1–0.2 (syrf#4201) proved the regexes on 8.5.11. |
| R2 | One GCP Secret Manager secret per principal | The five principals that already exist get their passwords from an in-cluster ESO Password ClusterGenerator (CreatedOnce), never stored in GCP SM (cluster-gitops#1430). The broker's Erlang cookie uses the same generator (#1633). |
The plan keeps the generator and uses GCP SM only for the break-glass administrator. Decision D3. |
| R3 | The broker learns users from the ESO definitions file | True, but RabbitMQ reads that file only at boot (#1630). Every new principal or permission change would need a restart of the shared broker, which is a 2 to 5 minute outage for production. | The plan adds a live definitions applier, so phases 1 to 4 restart nothing. Decision D1. |
| R4 | Consumers are API, PM, Quartz, previews and notifiers | A Kubernetes PDF agent also runs in syrf-staging (pdf-agent-staging-syrf-pdf-agent, ConsumerState=Enabled) as rabbit (V). .claude/rules/pdf-agent.md says no PDF agent runs in Kubernetes. |
It gets its own principal. Decision D6. |
| R5 | The production notifier's ACK Function CR holds the password in plain text |
All eight ACK Function CRs do: production, staging (notifier and reconciler) and five previews. Each has a RabbitMqPassword key in spec.environment.variables (V, key names only). |
§5 covers all of them. |
| R6 | (not in the brief) | GitHub repository secrets RABBITMQ_PASSWORD exist in camaradesuk/syrf (2025-12-11, referenced by no workflow) and camaradesuk/camarades-infrastructure (2026-05-04, feeds the dormant Terraform preview-Lambda module). The local rabbitmq MCP server logs in as rabbit with --allow-mutative-tools. |
Phase 5 removes them. |
| R7 | (not in the brief) | Deleting the management Ingress would leave rabbitmq.camarades.net with no DNS owner. External-DNS runs policy: upsert-only and registry: noop and has no other source for that name. AMQPS on 5671 (Lambdas, ARRNC) depends on that name. |
§6 gives the record an owner; #1639 removed the Ingress first. |
Evidence and markers¶
The markers are the contract's:
| Marker | Meaning |
|---|---|
| V | Verified on 2026-10-14 by read-only kubectl get (key and field names only, never Secret values) on gke_camarades-net_europe-west2-a_camaradesuk, or read from camaradesuk/cluster-gitops origin/main c04bdecc, camaradesuk/camarades-infrastructure origin/main or camaradesuk/server-config origin/main. |
| S | Derived from SyRF source on main 19c5797af (MassTransit 8.4.0). |
| U | Unknown. It blocks the step that depends on it. It never means "absent". |
The broker was not queried for this plan, and is not queried to verify it. No rabbitmqctl, no
management API, no MCP broker tool and no port-forward was used. Broker-side facts come from merged PR
bodies, the definitions applier's logged results and pod logs.
Hard operational rule¶
Never run looped
rabbitmqctl, or an unfiltered queue, exchange, binding, channel or connection listing, against the shared broker. On 2026-10-10 that pattern OOM-killedrabbitmq-0, which took production messaging down.
It applies to people, agents, CI, the definitions applier and the preview reconciler alike:
- Chris's standing rule: never query the shared broker directly. No
rabbitmqctl, no management API call and no MCP broker tool, from anyone or anything, unless Chris gives an explicit go for that one call. Verify withkubectland logs instead. - Default evidence for every phase:
- The applier's logged result. The definitions applier logs the HTTP status and the sha256 of each
import. An import that fails partway returns 4xx (#4200
proved it), so a
204means every entry in the file was applied. - ExternalSecret status (
SecretSynced) and the applier Job and CronJob status. - Argo health, pod readiness, the API/PM/Quartz/PDF-agent health endpoints, MassTransit "bus started" and
reconnect log lines, workload logs for auth or refusal errors (
ACCESS_REFUSED,Broker unreachable),rabbitmq-0start time and restart count, and CloudWatch logs for the Lambdas. - Direct broker calls, only with Chris's explicit go for that call: one filtered call at a time, scoped
to a vhost or a single object, with
columns=anddisable_stats=true, made assyrf-broker-provisioner(an administrator). Thesyrf-broker-observerprincipal (tagmonitoring) cannot do user or permission reads: it gets 401 on every/api/users*and/api/permissions*endpoint, because those are administrator-only in RabbitMQ 3.13 (proved locally in cluster-gitops#1664). It can read a vhost's connections (GET /api/vhosts/<vhost>/connections?columns=user,ssl) only if it holds a^$ ^$ ^$permission entry on that vhost, which it does not today. Never call/api/queues,/api/exchanges,/api/bindings,/api/channelsor/api/connectionswithout a vhost (and, for queues, exchanges and bindings, an object name). - The only cluster-wide listings ever allowed are the small name-only ones (
GET /api/vhostsandGET /api/users, withcolumns=name, plustagsfor users, anddisable_stats=true). The preview reconciler uses these as the provisioner; people and agents need Chris's go first. - No
kubectl exec … rabbitmqctlin automation. Holds since 2026-10-17 (AC12): syrf#4251 removed the_preview-cleanup.ymlvhost deletion and syrf#4274 removedpreview-sweep.yml"Layer 4a"; the preview reconciler's teardown replaces both. - No live test messages. Only organic traffic is observed, as the contract requires.
- Credential switches re-sync the notifiers in the same change (hard rule). Every later change of a
credential or principal that a notifier uses (Phases 3 to 5, including the Phase 5.3
rabbitrotation) must re-sync each affected environment's notifier env-vars (s3-notifier-<env>and its reconciler) in the same change, and verify the Lambda's configuration before the old credential is removed. This is risk K3 and decision D9. Cost of forgetting it: see the 2026-10-14 incident in §1.
1. Where we are on 2026-10-15¶
Phase 0 and Phase 1 are complete. D11 and Phase 6 step 1 are done. D13 is moot. Nothing uses the new
principals yet: their consumer Secrets exist in syrf-staging and syrf-production, but no workload reads them.
Done (V)¶
| Contract step | What | PR |
|---|---|---|
| B1.3 | AMQPS listener on 5671 (Let's Encrypt rabbitmq-amqps-tls, server-authenticated); the broker loads the ESO-rendered rabbitmq-definitions at boot; memory 1Gi, watermark 0.6 |
cluster-gitops#1431 |
| B1.4 | ingress-nginx L4 passthrough for 5671 | #1432 |
| B1.6, part of B1.7 and B1.10 | Principals syrf-staging-s3notifier, syrf-production-s3notifier (.* on their vhost, tag none), syrf-staging-pdfagent (Paused set ^$ ^$ ^$), syrf-production-pdfagent (.* on syrf-production), syrf-broker-observer (tag monitoring, no permissions). Consumer Secret rabbitmq-s3notifier in syrf-staging and syrf-production. |
#1430, #1630 |
| B1.5, B1.5b | The broker uses its own generated Erlang cookie in rabbitmq only. rabbit-mq copies hold only rabbitmq-password. NetworkPolicy narrowed (declared intent: the cluster does not enforce NetworkPolicy). |
#1632–#1635 |
| B1.0 (code) | Strict TLS validation for 5671 in every SyRF bus (RabbitMqTls) |
syrf#3878 |
| Phase 0 | Fixtures, E2E, local proofs, parity check, API chart switch: see Phase 0 | syrf#4201, syrf#4225, syrf#4200, cluster-gitops#1656, syrf#4199 |
| Phase 1 steps 1 and 2, step 3 first run | Eight CreatedOnce ExternalSecrets (staging and production api, pm, quartz, syrf-staging-pdfagent-gke, syrf-broker-provisioner), all SecretSynced. The seven workload principals have interim .* on their own vhost; the provisioner is administrator with no permissions. The live applier (CronJob every 10 minutes plus a PostSync Job) made its first import as rabbit at 2026-10-15T01:46:27Z: HTTP 204, sha256 8cd85bd7… recorded. Merged 2026-10-15T01:45Z. |
cluster-gitops#1664 |
| Phase 1 step 3 switch | The applier authenticates as syrf-broker-provisioner (APPLIER_IDENTITY=provisioner). Merged 2026-10-15T03:21Z. No import has run since (the definitions sha256 is still 8cd85bd7…), so the next definitions change is the provisioner's first import. |
cluster-gitops#1668 |
| Phase 1 step 4 | ClusterSecretStores rabbitmq-syrf-staging and rabbitmq-syrf-production (kubernetes provider, conditions.namespaces set to that environment, reader ServiceAccount whose Role may get only that environment's principal Secrets). Consumer ExternalSecrets rabbitmq-api, rabbitmq-pm, rabbitmq-quartz in both namespaces and rabbitmq-pdfagent-gke in syrf-staging, key rabbitmq-password. Verified 2026-10-17: both stores Valid, all seven SecretSynced, each Secret holds only rabbitmq-password (48 characters); rabbitmq-0 and the environment pods did not restart. Staging merged 2026-10-17, production 2026-10-17T16:51Z. |
#1688, #1690 |
| D11 | syrf-production-pdfagent set to ^$ ^$ ^$; live with the 01:46Z import. No syrf-pdf-agent-production container exists on arrnc-api (confirmed 2026-10-15). |
cluster-gitops#1663 |
| Phase 6 step 1 | external-dns.alpha.kubernetes.io/hostname: rabbitmq.camarades.net on Service ingress-nginx/ingress-nginx-controller. Verified live: the annotation is present, the IP is still 34.13.63.21, and openssl s_client -connect rabbitmq.camarades.net:5671 gives verify 0. The record is owned again. |
cluster-gitops#1662 |
| Bus-endpoint names | Corrected in the committed files, the server-config scope and the parity check | syrf#4228, server-config#306, cluster-gitops#1659 |
Phase 1 evidence (no broker query). rabbitmq-0 started 2026-10-14T04:03:34Z and has 0 restarts, so the
import restarted nothing (AC11 holds so far). No workload logged a broker authentication or connection error
after the import.
Phase 1 is complete (2026-10-17). The applier's switch to syrf-broker-provisioner
(cluster-gitops#1668) is live but has not yet imported;
the first definitions change after it proves the provisioner's import. Step 4 is
#1688 and #1690.
Incident: production notifications lost, 2026-10-14 (D13)¶
cluster-gitops#1437 and
#1438 (the B1 rabbit password rotation) were
merged by another session around 04:00Z on 2026-10-14, not closed as this plan's D13 intended. The
production s3-notifier Lambda only received the new password at 09:43Z, so every production upload
notification between about 04:00 and 09:43 failed with "Broker unreachable". One search import was lost
(project dc7067e2…, search ec464edc…, 08:35Z); Chris chose not to replay it. D13 is therefore moot, and
this plan's §12 warning about exactly this failure came true. The lesson is now a hard rule: every credential
switch re-syncs each environment's notifier env-vars in the same change (Hard operational
rule, K3, D9). Phase 5.3 must still rotate rabbit again, under that rule.
Still rabbit (V)¶
- In-cluster:
syrf-api,syrf-projectmanagement,syrf-quartzinsyrf-production,syrf-stagingand five previews (pr-2412,pr-3292,pr-3327,pr-3394,pr-3934), plus the staging Kubernetes PDF agent. All dialamqp://rabbitmq.rabbitmq.svc.cluster.local:5672and readrabbit-mqkeyrabbitmq-password. - Lambdas: all eight notifier and reconciler functions, over public plaintext 5672. The switches are
open as (status as of 2026-10-14; re-check before use) #1433 (staging),
#1434 (production) and
#1435 (previews, still as
rabbit). - Admin paths: the API chart's preview PreSync hook (
presync-rabbitmq-vhost.yaml) createspr-Nvhosts through the management API asrabbitin previews whose head predates syrf#4310 (Phase 2 step 3). The public management Ingressrabbitmq.camarades.net(443) was removed on 2026-10-14 by cluster-gitops#1639; the DNS name was kept (External-DNSupsert-only) and got an owning source again on 2026-10-15 through cluster-gitops#1662 (Phase 6 step 1). The localrabbitmqMCP server usesrabbit, and since #1639 its public URL no longer reaches the API. - Definitions:
rabbitkeeps tagadministratorand.*on/,syrf-production,syrf-stagingandlocal. Its password was rotated on 2026-10-14 by #1437/#1438 (see the incident above).
2. Target model¶
This matches the contract's §2 with three additions: the provisioner (D1), the staging Kubernetes PDF agent (D6) and preview teardown.
| Principal | Vhost | Tags | Used by | Status |
|---|---|---|---|---|
syrf-production-api, -pm, -quartz |
syrf-production |
none | the three Deployments | new |
syrf-production-s3notifier |
syrf-production |
none | syrfAppUploadS3Notifier |
exists, .* |
syrf-production-pdfagent |
syrf-production |
none | ARRNC production slot (not cut over) | exists, .*; set to the Paused set (D11) |
syrf-staging-api, -pm, -quartz |
syrf-staging |
none | the three Deployments | new |
syrf-staging-s3notifier |
syrf-staging |
none | staging notifier and reconciler Lambdas | exists, .* |
syrf-staging-pdfagent |
syrf-staging |
none | ARRNC staging slot (Paused) | exists, Paused set |
syrf-staging-pdfagent-gke |
syrf-staging |
none | the Kubernetes staging PDF agent until it retires | new (D6) |
syrf-pr-N |
pr-N only |
none | that preview's API, PM, Quartz and Lambda | new (D2) |
syrf-broker-observer |
none | monitoring |
metrics and, if it is given a ^$ ^$ ^$ entry on a vhost, that vhost's connections. It gets 401 on /api/users* and /api/permissions* (administrator-only in 3.13). Direct calls need Chris's go |
exists |
syrf-broker-provisioner |
none | administrator |
the definitions applier and the preview reconciler, rabbitmq namespace only |
new (D1); this is the contract's standing admin identity (named syrf-topology-operator there) |
syrf-broker-breakglass |
none | administrator |
dormant; Chris approves each use | new (contract B2) |
Rules carried from the contract:
- No workload principal has a tag, so none can use the management UI or API.
- No principal has permissions in more than one vhost. Vhosts stay as a second boundary, never the only one.
- Exactly two administrators remain at the end: the provisioner (standing, automation only) and the
break-glass user (dormant). Both credentials live only in the
rabbitmqnamespace (and, for break-glass, GCP Secret Manager). No workload, Lambda or ARRNC host holds an administrator credential. - Rotation creates a second principal (
-api-2), switches to it, then deletes the old one. A live password is never edited in place, except by the preview reconciler's repair upsert, which re-applies the same password from the preview's Secret (§5).
3. Permission regexes per principal¶
Method¶
The PDF agent already sets the pattern: src/services/pdf-agent/broker-permissions.json is the contract,
and PdfAgentBrokerPermissionTests runs the agent's real MassTransit registration against a real broker
as a user holding exactly those regexes. Any ACCESS_REFUSED fails the test. This plan applies the same
method to every workload:
- Commit
broker-permissions.jsonnext to each service (api,project-management,quartz,s3-notifier), with the same{busEndpointPrefix}placeholder convention. - A broker-permission test per service runs its production registration (
ConfigureSyrfMassTransitwith the real consumers and definitions) under those regexes and exercises every path: start-up declaration, each publish, send, request, schedule, job submission, fault and scheduled redelivery. Negative tests are listed in the contract's §3 and below. - The cluster-gitops definitions carry these strings verbatim. A check compares them with the syrf file at the deployed image's commit (Phase 0.5).
The regexes below are drafts derived from source (S). As for the PDF agent, nothing goes live until the fixture proves each set. Names the fixture must pin are marked U.
Names in use on main (S)¶
These extend the contract's §3 with what has changed since 65ae1099b:
- Default-formatted PM endpoints (no
SyRF.prefix): now eight, not five.StartDailyProjectStatistics,ObserveDailyProjectStatistics,RunProjectStatisticsMaintenance,RunProjectStatisticsDriftCheck,RunProjectStatisticsRepair, plusFoldProjectStatistics,RunProjectStatisticsFoldSweepandProjectStatisticsFoldStampAdvance(their definitions set onlyConcurrentMessageLimit). - New PM command queues: the seven notifier-authority queues
(
SyRF.ProjectManagement.I{GetBulkPdfReleaseState,ResolveBulkPdfStorageBinding,RegisterBulkPdfCleanup,RegisterBulkPdfQuarantineFence,TransferBulkPdfQuarantineFence,AcknowledgeBulkPdfCleanup,ConfirmBulkPdfHoldRetired}Command),IReconcileBulkPdfReceiptDrainCommand,IRunBulkStudyUpdateOperationCommand(a job PM submits to itself) andIBulkPdfUploadObjectReadyCommand. - Recurring schedules (PM → Quartz):
StartDailyProjectStatistics,RunProjectStatisticsDriftCheck,RunProjectStatisticsRepair,RunProjectStatisticsMaintenance,RunProjectStatisticsFoldSweep. One-shot schedules:IObserveDailyProjectStatisticsCommand,IMarkSessionIdleCommand,IRemoveIdleSessionCommand,IRemoveSuspendedSessionCommand,ICheckConnectionLivenessCommandand the saga timeoutISearchUploadTimeoutExpiredEvent. A state-machineSchedulesends to the saga's own input address, so its destination isSyRF.ProjectManagement.SearchImportJobState, not an event exchange. - Scheduled redelivery (Quartz sends back to the input queue): the
BulkPdfUploadendpoint,IBulkPdfUploadObjectReadyCommand, the four review-session queues (IMarkSessionIdle,IRemoveIdleSession,IRemoveSuspendedSession,ICheckConnectionLiveness) and the PDF agent'sIProcessBulkPdfUploadCommand. - Bus endpoints. MassTransit 8.5.11 names a bus endpoint
<host>_<process>_bus_<id>(DefaultEndpointNameFormatter.GetTemporaryQueueName):<host>isEnvironment.MachineName(the pod name in Kubernetes),<id>is a 26-character lower-case NewId, and every non-alphanumeric is removed from both parts. Every image runsdotnet <dll>, butBusHostInforeplaces a process name ofdotnetwith the entry assembly's file name, so<process>isSyRFAPIEndpoint,SyRFProjectManagementEndpoint,SyRFQuartzorSyRFPdfAgent, neverdotnet. Whenhost + process + 3 + 29would exceed 72 characters, the formatter cuts the host to 14 characters, then, if still over, the process to 14. Proved 2026-10-15 on a local RabbitMQ 3.13.6, with the real agent imagesyrf-pdf-agent:1.123.0-sha.960df11and withdotnet <dll>hosts carrying the services' assembly names under live pod names:
| Workload | Host name | Bus endpoint |
|---|---|---|
| API | syrf-api-59c985b8d-fz99q |
syrfapi59c985b8dfz99q_SyRFAPIEndpoint_bus_<id> |
| PM | syrf-projectmanagement-7d998b7857-nzrjr |
syrfprojectman_SyRFProjectMan_bus_<id> (both cut, so every PM pod has this prefix) |
| Quartz | syrf-quartz-75bcb5997d-s964l |
syrfquartz75bcb5997ds964l_SyRFQuartz_bus_<id> |
| PDF agent, ARRNC | syrf-pdf-agent (pinned) |
syrfpdfagent_SyRFPdfAgent_bus_<id> |
| PDF agent, Kubernetes | pdf-agent-staging-syrf-pdf-agent-d5f96c46c-zhnp2 |
pdfagentstagin_SyRFPdfAgent_bus_<id> (host cut) |
With a pod-template hash of at most 10 characters, the stripped API host is at most 22 characters (the
cut starts above 25) and the Quartz host at most 25 (above 30), so neither is ever cut. The Lambda's
machine and process names are U. scripts/broker-principals.py (#4225) computes the same prefixes.
- #4139/#4140 add PM's ingress exchange SyRF.ProjectManagement.BulkPdfUpload.Agent and move the agent
to it. The PM regex below already allows it. The agent's file changes in #4140.
Shared fragments¶
| Name | Regex fragment |
|---|---|
PMQ |
SyRF\.ProjectManagement\.[A-Za-z]+(_error\|_skipped\|\.Agent)? |
PMDEF |
(StartDailyProjectStatistics\|ObserveDailyProjectStatistics\|RunProjectStatistics(Maintenance\|DriftCheck\|Repair\|FoldSweep)\|FoldProjectStatistics\|ProjectStatisticsFoldStampAdvance)(_error\|_skipped)? |
MSG |
SyRF\.(ProjectManagement\.Messages\.(Commands\|Events)\|API\.Messages\.Events\|S3FileSavedNotifier\.Messages\.Events\|SharedKernel\.Interfaces\|ProjectManagement\.Endpoint\.Sagas):[A-Za-z]+ |
SIGNALR |
SyRF\.API\.SignalR\.(Statistics:ProjectStatisticsChanged\|ClaimRevocations:ActivityClaimRevoked) |
APITMP |
api-(project-statistics-invalidations\|activity-claim-revocations)-?[0-9a-f]{32} (separator U) |
SCHED |
MassTransit\.Scheduling:[A-Za-z]+ |
FAULT |
MassTransit:Fault(--.+--)? |
JOBS |
MassTransit\.Contracts\.JobService:.+ |
QTZ |
(quartz\|Job\|JobType\|JobAttempt)(_error\|_skipped)? |
BUS_API / BUS_PM / BUS_QTZ |
syrfapi[a-z0-9]+_SyRFAPIEndpoint_bus_[a-z0-9]+ / syrfprojectman_SyRFProjectMan_bus_[a-z0-9]+ / syrfquartz[a-z0-9]+_SyRFQuartz_bus_[a-z0-9]+ |
BUS_AGENT |
the PDF agent's pinned {busEndpointPrefix}_[a-z0-9]+ for each slot that talks to this vhost |
API_TO_PM |
SyRF\.ProjectManagement\.(IUpdateStudyScreeningStatsCommand\|IGetBulkPdfReleaseStateCommand\|IResolveBulkPdfStorageBindingCommand\|IRegisterBulkPdfCleanupCommand\|IRegisterBulkPdfQuarantineFenceCommand\|ITransferBulkPdfQuarantineFenceCommand\|IAcknowledgeBulkPdfCleanupCommand\|IConfirmBulkPdfHoldRetiredCommand\|IStartSearchUpdateCommand) |
QTZ_DEST |
SyRF\.ProjectManagement\.Messages\.Commands:I(MarkSessionIdle\|RemoveIdleSession\|RemoveSuspendedSession\|CheckConnectionLiveness\|ObserveDailyProjectStatistics\|StartDailyProjectStatistics\|RunProjectStatistics(Maintenance\|DriftCheck\|Repair\|FoldSweep))Command\|SyRF\.SharedKernel\.Interfaces:ICommand\|SyRF\.ProjectManagement\.(SearchImportJobState\|BulkPdfUpload\|I(BulkPdfUploadObjectReady\|MarkSessionIdle\|RemoveIdleSession\|RemoveSuspendedSession\|CheckConnectionLiveness\|ProcessBulkPdfUpload\|StartParsingReferenceFile\|StartRobProcessingJob\|StartBulkStudyUpdateJob\|RunBulkStudyUpdateOperation)Command) |
(\| is a table escape. The committed JSON uses a bare |.)
Draft sets¶
Every set is wrapped as ^(…)$.
| Principal | configure | write | read |
|---|---|---|---|
-pm |
PMQ, PMDEF, BUS_PM, MSG, SCHED, FAULT, JOBS, plus (Job\|JobType\|JobAttempt) if job-consumer status sends declare them (U) |
configure set, plus BUS_API, BUS_AGENT (responses) |
PMQ, PMDEF, BUS_PM, MSG, FAULT, JOBS |
-api |
BUS_API, APITMP, SIGNALR, SyRF\.API\.Messages\.Events:[A-Za-z]+, SyRF\.ProjectManagement\.Messages\.Commands:IFoldProjectStatisticsCommand, SyRF\.SharedKernel\.Interfaces:[A-Za-z]+, API_TO_PM, SCHED, FAULT, JOBS |
same as configure | BUS_API, APITMP, SIGNALR, SyRF\.API\.Messages\.Events:[A-Za-z]+, SyRF\.ProjectManagement\.Messages\.Commands:IFoldProjectStatisticsCommand, API_TO_PM (see D14), FAULT, JOBS |
-quartz |
QTZ, BUS_QTZ, SCHED, JOBS, FAULT, QTZ_DEST |
configure set, plus BUS_API, BUS_PM |
QTZ, BUS_QTZ, SCHED, JOBS, FAULT |
-s3notifier |
SyRF\.ProjectManagement\.IBulkPdfUploadObjectReadyCommand, SyRF\.S3FileSavedNotifier\.Messages\.Events:ISearchUploadSavedToS3Event, SyRF\.SharedKernel\.Interfaces:(ICanStartSearchImportJob\|IIntegrationEvent), MassTransit\.Contracts\.JobService:SubmitJob--SyRF\.ProjectManagement\.Messages\.Commands:IStartBulkStudyUpdateJobCommand--, its bus endpoint if one is created (U) |
same as configure | SyRF\.ProjectManagement\.IBulkPdfUploadObjectReadyCommand (D14), SyRF\.S3FileSavedNotifier\.Messages\.Events:ISearchUploadSavedToS3Event, the SubmitJob exchange above |
-pdfagent, -pdfagent-gke |
broker-permissions.json: Paused or Enabled per slot state (with #4140's ingress exchange). The ARRNC agent's prefix is syrfpdfagent_SyRFPdfAgent_bus (pinned host syrf-pdf-agent). The Kubernetes agent's pod name is cut to 14 characters, so its prefix is the fixed pdfagentstagin_SyRFPdfAgent_bus and its pattern pdfagentstagin_SyRFPdfAgent_bus_[a-z0-9]+. |
||
syrf-pr-N |
.* |
.* |
.* (on pr-N only; previews hold no data that warrants finer grants, and one principal serves four workloads) |
Negative tests the fixture must add to the contract's list:
-apicannot consume, purge or deleteSyRF.ProjectManagement.SearchImportJobStateor any default-formatted statistics queue.-quartzcannot publish to anySyRF.API.*exchange or to a PM command it does not schedule (for exampleIStartParsingReferenceFileCommandvia its message-type exchange).-s3notifiercannot publishIProcessBulkPdfUploadCommandor consume any queue.- No
syrf-staging-*principal can opensyrf-production, andsyrf-pr-Ncannot openpr-Morsyrf-staging. - Every principal is refused
queue.declareonquartzunless it is-quartz.
Known residuals¶
- Configure implies delete (contract D6). It applies to
-apiand-s3notifieron theirqueue:destinations, to-quartzon every redelivery queue, and to-pmon its own queues. - Read on a
queue:destination's exchange is also read on the same-named queue. RabbitMQ does not distinguish resource kinds, so-apiand-s3notifiercould consume or purge the PM queues they send to. The fix is the #4138 pattern: PM-owned ingress exchanges with different names, andexchange:sends. Decision D14. -pmreaches the PDF agent's work queue. The release gate'squeue:send toSyRF.ProjectManagement.IProcessBulkPdfUploadCommandneeds configure on that name and read on the same-named exchange, so-pmcan also consume, purge or delete the agent's queue. The agent's_errorand_skippedqueues are not granted (syrf#4201 narrowedPMQ's dead-letter alternative to names that do not start withIP). The same D14 follow-up closes it: an agent-owned ingress exchange and anexchange:send.-pmcan delete other services' bus-endpoint exchanges. A response declares the requester's bus-endpoint exchange, so-pmneeds configure on the API's and the PDF agent's bus endpoints, which also allowsexchange.delete. That drops the binding to the pod's bus-endpoint queue, and responses to that pod are lost until it reconnects. The queue itself is exclusive to its owner's connection (delete, purge and consume answer 405 RESOURCE_LOCKED), and-pmholds no read on it.- All three
-pmresiduals are listed insrc/services/project-management/broker-permissions.json(residuals) and pinned byProjectManagementBrokerPermissionTests.KnownResiduals_TheAgentsWorkQueueAndOtherServicesBusEndpointExchanges. - Quartz is a confused deputy (contract §Risks). Anyone who can write
MassTransit.Scheduling:*can have Quartz deliver to anything inQTZ_DEST. That is whyQTZ_DESTis an explicit allowlist.
Phase 0.1–0.2 results (proved)¶
syrf#4201 committed broker-permissions.json for api,
project-management, quartz and s3-notifier and a broker-permission test per service, on MassTransit 8.5.11.
Each test runs the service's production registration as a user holding only the committed strings, and its
counterpart buses take their own bus-endpoint names, so a grant on one service's bus endpoint cannot cover
another's. The files are the source of truth; the drafts above are kept for the derivation. The fixture changed
the drafts as follows (S, proved):
| Principal | Change from the draft | Why |
|---|---|---|
-api |
APITMP is api-(project-statistics-invalidations\|activity-claim-revocations)[0-9a-f]{32}(_error)? |
MassTransit appends the 32-hex InstanceId with no separator, and a faulted fan-out delivery creates <queue>_error |
-api |
IStartSearchUpdateCommand removed from API_TO_PM |
StudyController injects its request client but never sends |
-pm |
MSG ends [A-Za-z0-9]+ |
SyRF.S3FileSavedNotifier.Messages.Events:ISearchUploadSavedToS3Event has a digit; the saga binds it at start-up |
-pm, -quartz |
add Instance[a-z0-9]+ (PM: configure, write, read; Quartz: configure, write) |
The 8.5 job service gives each job consumer host a per-instance endpoint Instance<NewId>; the JobAttempt saga sends StartJob there |
-pm |
BUS_API and BUS_AGENT move into configure as well as write |
A responder declares the requester's bus-endpoint exchange (auto-delete) before publishing to it; write alone refuses every response. New residual, see Known residuals |
-pm |
PMQ's _error/_skipped alternative is SyRF\.ProjectManagement\.(I[A-OQ-Z]\|[A-HJ-Z])[A-Za-z]*(_error\|_skipped) |
Leaves out the PDF agent's IProcessBulkPdfUploadCommand_error/_skipped without a lookahead; the start-up test checks every PM endpoint's dead-letter names stay covered |
-quartz |
write on BUS_API and BUS_PM removed |
Quartz sends nothing to either bus endpoint (proved under distinct names), and a response would need configure anyway |
-pm, -quartz |
RunDeletionLifecycle added to PMDEF (PM) and IRunDeletionLifecycleCommand to QTZ_DEST (Quartz) |
Deletion lifecycle S3a (main e0a647712) adds a default-formatted runner endpoint and its recurring schedule; both were refused under the earlier sets |
-api |
APITMP adds search-deletions; configure and read on SyRF\.ProjectManagement\.Messages\.Events:ISearchDeletedEvent |
Deletion lifecycle S4a (syrf#4241): the API consumes project-management's SearchDeleted event on a third per-pod fan-out queue and never publishes it. PM's existing MSG grant covers the publish |
-s3notifier |
unchanged; no bus-endpoint grant | The Lambdas make no request, so their bus never creates a bus endpoint |
The plan's negative list says -s3notifier cannot consume any queue. Under the accepted D14 residual it can consume
SyRF.ProjectManagement.IBulkPdfUploadObjectReadyCommand (its send binds that name); the notifier test pins this
residual and refuses every other queue. Likewise -api can consume the API_TO_PM queues and nothing else of
project management's.
4. How the broker learns the principals¶
Today: boot-only definitions¶
Since #1431, RabbitMQ imports rabbitmq-definitions (rendered by ESO from the generated passwords) at
boot only. The import creates or updates the listed users and permissions and never deletes anything.
It also never revokes a grant whose entry is removed from the file (Phase 0.4,
C1 and C11).
A change in git therefore reaches the broker only at the next restart of rabbitmq-0. That restart is a
2 to 5 minute AMQP outage for every environment, production included
(#1634 measured about 2 minutes at 18 vhosts).
Proposed: a live definitions applier (decision D1)¶
A small Argo-managed workload in the rabbitmq namespace imports the same rendered file live, with
one POST /api/definitions per run. RabbitMQ applies definitions imported over HTTP immediately and
upserts in the same way as the boot import. No restart is needed.
- Form: a
CronJob(every 10 minutes,concurrencyPolicy: Forbid, one request, 30 s timeout) plus the same Job as an ArgoPostSynchook, so a merged change lands within minutes. The file is mounted fromrabbitmq-definitions, and the Job skips the call when the file's hash equals the last applied hash (kept in a ConfigMap it owns). - Identity: at first it authenticates as
rabbit, using therabbitmq-namespace copy ofrabbit-mqthat the definitions template already reads. Its first import createdsyrf-broker-provisioner(2026-10-15T01:46:27Z, cluster-gitops#1664), and the next commit (#1668, merged 2026-10-15T03:21Z) switched the applier to the provisioner, sorabbitis no longer needed for provisioning. - Cost on the broker: one definitions import of a few dozen users and permissions, with no queues, exchanges or bindings in the file. This is far below the 2026-10-10 failure pattern. It never lists queues.
- Result logging. Each run logs the HTTP status and the sha256 of the imported file. A
204means every entry applied, because a partial failure returns 4xx (#4200); this log line is the default verification evidence for every phase (Hard operational rule). - Boot import stays. If the PVC is lost, the boot import rebuilds the same state.
- Local proof (done 2026-10-14, Phase 0.4 proofs): on RabbitMQ
3.13.6,
POST /api/definitionswith plaintextpasswordfields creates users, updates passwords and tags of existing users, and changes permissions without a restart (confirmed). Removing an entry deletes nothing (confirmed). Changingrabbit's tags to""demotes it live for HTTP (confirmed); tags do not affect AMQP. Two findings change the design: - The import is not all-or-nothing (refuted K2). A request that fails on a later entry returns 400 after applying the earlier ones. The applier therefore treats any non-2xx response as a failed run: it keeps the previous hash, exits non-zero and retries on the next schedule. Because every import is an upsert, the next good import converges.
- Every restart re-applies the boot file over anything applied live. The applier and the boot import
must keep reading the same
rabbitmq-definitionsSecret, so a restart can never roll back a live change.
The alternative is to keep boot-only definitions and batch every principal change into one approved restart window per wave. That means about four production broker restarts (staging, production, regex tightening, retirement).
5. Secret distribution¶
Passwords¶
The existing pattern (#1430) stays: a CreatedOnce ExternalSecret per principal in rabbitmq, generated by
the rabbitmq-principal-password ClusterGenerator (48 alphanumeric characters). No human sees a workload
password, and nothing is stored in git or Secret Manager. Decision D3.
The break-glass password is the exception: GCP Secret Manager secret rabbitmq-breakglass (project
camarades-net), synced only into rabbitmq by an ExternalSecret through gcpsm-secret-store-rabbitmq.
Its custody does not depend on the cluster.
In-cluster consumers¶
One ClusterSecretStore per environment (rabbitmq-syrf-production, rabbitmq-syrf-staging) with
conditions.namespaces set to that environment's namespace, and a reader ServiceAccount whose Role may
get only that environment's principal Secrets (resourceNames). In each environment namespace, one
ExternalSecret per workload produces rabbitmq-api, rabbitmq-pm and rabbitmq-quartz with the key
rabbitmq-password, so the syrf-common env.rabbitmq block works unchanged through rabbitMq.authSecretName.
The workload switch is then two values (rabbitMq.username, rabbitMq.authSecretName) in
syrf/environments/<env>/<service>/values.yaml. Decision D10 covers the store
granularity (the contract says one store per principal).
Previews¶
Each preview namespace gets a CreatedOnce ExternalSecret rabbitmq-preview-principal (key
rabbitmq-password) from the same generator, rendered by src/charts/preview-infrastructure. The preview reconciler
(Phase 2) is a CronJob in rabbitmq that runs as syrf-broker-provisioner. Its ClusterRole may list
namespaces and get Secrets named exactly rabbitmq-preview-principal (a ClusterRole's resourceNames
applies in every namespace). Every 5 minutes it (one run at a time, concurrencyPolicy: Forbid):
- lists namespaces labelled
syrf.org.uk/environment: preview(Kubernetes API, not the broker); - makes one
GET /api/vhosts?columns=name&disable_stats=trueand oneGET /api/users?columns=name&disable_stats=true; - skips, for this run only, any preview whose
rabbitmq-preview-principalSecret does not exist yet (getreturns 404 because ESO has not rendered it). A skipped preview never fails the run; - for each preview whose vhost is missing:
PUT /api/vhosts/pr-N, thenDELETE /api/permissions/pr-N/syrf-broker-provisioner. RabbitMQ gives the user who creates a vhost over HTTP.*on it (proved on a local 3.13.6 in cluster-gitops#1669), and the provisioner keeps no vhost grants; - for every live preview, on every run:
PUT /api/users/syrf-pr-N(password from the namespace's Secret, tags""),PUT /api/permissions/pr-N/syrf-pr-N(.*) and the ADR-015 vhost limits (PUT /api/vhost-limits/pr-N/max-connectionsand…/max-queues). All are idempotent upserts, so a changed limit also reaches existing previews. The cost is four small PUTs per preview per run (about twenty today). The broker therefore always holds the Secret's current password. Ifrabbitmq-preview-principalis ever recreated with a new value, the broker side recovers within one interval, but running pods keep the old value, because they readrabbitmq-passwordfrom the environment at start-up. Their open connections survive until the next reconnect, which then fails withACCESS_REFUSED. Recovery therefore also rolls the preview's API, PM and Quartz Deployments (Argo's Restart action on the preview app, or a redeploy of the preview). A Helm checksum annotation cannot do this automatically: Helm never sees an ESO-generated value; - for each
pr-*vhost orsyrf-pr-*user whose namespace is gone:DELETEthe user, then the vhost.
The admin credential never enters a preview namespace. The rabbitmqctl step in _preview-cleanup.yml
is retired (syrf#4251). Preview workloads that start before their vhost or user exists retry until it does:
MassTransit reconnects, and the Preview-Ready
contract (ADR-016) stays fail-closed.
How a preview's workloads switch (Phase 2 step 3, syrf#4310).
The preview ApplicationSet renders every chart, preview-infrastructure included, at the PR's head SHA, so
the switch lives in the charts, not in ApplicationSet values. In a preview (environment.name: preview)
with rabbitMq.previewPrincipal.enabled true (the chart default), syrf-common's env.rabbitmq block gives
the API, PM and Quartz username syrf-<namespace> (syrf-pr-N, the reconciler's syrf-$ns) and Secret
rabbitmq-preview-principal key rabbitmq-password, and the API renders no PreSync vhost Job. Any head
that contains this switch also contains the Secret (syrf#4236) and the API hook switch (syrf#4199), so a
preview moves only when its branch includes syrf#4310 and redeploys; until then the reconciler skips it
(no Secret) and it keeps rabbit and the PreSync hook. Precedence while the switch is on:
rabbitMq.username, the username key of rabbitMq.configMapName and rabbitMq.authSecretName are
ignored for the broker credential (chart defaults cannot be told apart from explicit values), and
rabbitMq.provisionPreviewVhost is ignored; an env: map entry still wins over generated env vars. To use
any other credential in a preview, set rabbitMq.previewPrincipal.enabled: false (the cluster-gitops
rollback lever). Staging, production and local renders are byte-identical before and after. Helpers:
src/charts/syrf-common/templates/_rabbitmq-preview-principal.tpl.
s3-notifier Lambdas¶
MVP (no new infrastructure): keep the chart's env-vars-job, pointed at the per-environment consumer
Secret (rabbitmq-s3notifier, as #1433/#1434 already do; previews use rabbitmq-preview-principal). Two
gaps stay open in the MVP and are documented:
- The PostSync dependency. The job runs only when the Argo app syncs, so a changed Secret never
reaches the Lambda by itself. Every switch and rotation change must re-sync that environment's notifier
env-vars in the same change (hard rule; the 2026-10-14 incident in §1
lost production notifications for about five and a half hours when it did not). Every such PR must say "then
sync
s3-notifier-<env>", and the old principal must stay valid until the sync is verified. The rotation model (new principal, switch, delete old) makes that safe. - Plain text in the Function CR. ACK mirrors the Lambda configuration into
spec.environment.variablesof every Function CR (R5), and Argo shows live manifests. Anyone withgeton those CRs, or Argo app access to thes3-notifier-*apps, can read a notifier password.
Target (decision D9): the Lambda reads its password from AWS Systems Manager
Parameter Store (SecureString, /syrf/<env>/rabbitmq/s3notifier) at cold start, caches it, and re-reads
it once on an authentication failure. The env var holds only the parameter name, and the Lambda role gets
ssm:GetParameter on that one ARN (plus kms:Decrypt on the AWS-managed key). The parameter is written
from the in-cluster principal Secret by an ESO PushSecret through an AWS provider authenticated by the
existing pod-identity-webhook. Rotation then needs no Argo sync. This needs a notifier code change, an
IAM change and a new ESO store, so it is a follow-up and not on the critical path.
ARRNC PDF agents¶
Unchanged from the contract: the -pdfagent value is copied out of band into the slot's server-config
age scope (staging) and recorded without the value. How the production slot will receive its credential
is U (no reference in arrnc-api-deploy on origin/main); it must not be rabbit.
6. Management UI exposure¶
Users of 15672 today¶
| User | Path | Credential | Fate |
|---|---|---|---|
| API preview PreSync hook | in-cluster rabbitmq.rabbitmq.svc:15672 |
rabbit |
not rendered in a preview whose head includes syrf#4310 (Phase 2 step 3); gone once no older head is deployed |
Local rabbitmq MCP server (amq-mcp-server-rabbitmq, ~/.config/mcp-proxy/config.json, not in git) |
public rabbitmq.camarades.net (no longer routed since #1639) |
rabbit, --allow-mutative-tools |
removed (decision D5) |
| People | public Ingress on 443 (removed by #1639) | rabbit |
port-forward with the observer (cluster-level metrics and per-vhost connections only; no user or permission reads), or the provisioner/break-glass with Chris's go |
docs/platform/masstransit-observability.md |
documents kubectl port-forward svc/rabbitmq 15672:15672 -n syrf (the namespace is wrong; it is rabbitmq) |
none | corrected (namespace rabbitmq, observer or approved break-glass) |
Nothing else in syrf, cluster-gitops or camarades-infrastructure references 15672 or /api/ on the broker
(V, git grep). Metrics use 9419 (Prometheus plugin, no credential). The ingress's GCE annotations
(ingress.kubernetes.io/forwarding-rule and others) are stale: the only forwarding rule in camarades-net
is the ingress-nginx one (34.13.63.21, ports 80–5672), so there is no second public path (V).
Target (decision D4)¶
- No Ingress for the management UI. Operators use
kubectl -n rabbitmq port-forward svc/rabbitmq 15672with the observer, or with break-glass when Chris approves. Any broker API call still needs Chris's go (Hard operational rule). Holding cluster credentials is the access boundary. - Give DNS an owner. Done 2026-10-15 (cluster-gitops#1662).
The annotation
external-dns.alpha.kubernetes.io/hostname: rabbitmq.camarades.netis on the ingress-nginx controller Service, so External-DNS owns the name that AMQPS clients use and follows an ingress-nginx IP change. Verified live: the annotation is present, the IP is still34.13.63.21, andopenssl s_client -connect rabbitmq.camarades.net:5671gives verify 0. (#1639 had landed the Ingress removal first, so for about a day the record held only because External-DNS isupsert-only.) - Certificate renewal still works.
rabbitmq-amqps-tlsuses HTTP-01 through thenginxclass. cert-manager creates its own temporary solver Ingress, so it does not need the management Ingress. The phase still verifies one renewal (cmctl renewin a window, or the next natural renewal) before it is closed. The old Ingress Secretrabbitmq-tlsis deleted afterwards. - Alternatives: an IP allow-list annotation (
nginx.ingress.kubernetes.io/whitelist-source-range), which is fragile with home IPs and still exposes the login page to the allowed ranges; or oauth2-proxy in front, the pattern used fordocs-production, which is more moving parts for a UI that only operators use.
7. Phases¶
Each phase is its own PR or PR set. Dependencies:
- Phases 0 and 6 depend on nothing and can start at once.
- Phase 1 needs the Phase 0.4 proofs.
- Phases 2 (previews) and 3 (staging) need Phase 1 and can run in parallel. Phase 2 also needs Phase 0.6. Each environment's regex-tightening step needs the Phase 0.1–0.3 fixtures.
- Phase 4 (production) needs Phase 3 verified. Phase 5 needs Phases 2–4 and its 7-day entry proof.
The environment order follows the brief: previews, then staging, then production.
Phase 0: code, fixtures and local proofs (syrf; nothing live)¶
| # | Work | Acceptance |
|---|---|---|
| 0.1 | Done (syrf#4201). broker-permissions.json for api, project-management, quartz and s3-notifier. Implemented in syrf#4201; corrections to the drafts in Phase 0.1–0.2 results |
AC8 |
| 0.2 | Done (syrf#4201). Generalise PdfAgentBrokerPermissionFixture into SyRF.Testing.Containers and add one broker-permission test per service: the production registration under the committed regexes, all paths in §3, plus the negative tests. Implemented in syrf#4201: RabbitMqBrokerPermissionFixture in SyRF.Testing.Common (namespace SyRF.Testing.Containers), used by the PDF agent's tests and by ApiBrokerPermissionTests, ProjectManagementBrokerPermissionTests, QuartzBrokerPermissionTests and S3NotifierBrokerPermissionTests. They are Category=Integration, so AC8 is met when the main-branch integration lane runs them green |
AC8 |
| 0.3 | Implemented (syrf#4225). E2E stack: every service connects as its own restricted user (extend bulk-pdf-lane.sh's syrf-e2e-pdfagent pattern to API, PM, Quartz and the LocalStack notifier). e2e/scripts/setup.sh creates syrf-e2e-{api,pm,quartz,s3notifier,pdfagent} from each committed broker-permissions.json through scripts/broker-principals.py, the one renderer later phases reuse for the cluster-gitops definitions (it fails on an unknown or missing placeholder). The smoke and Bulk PDF lanes pass with every service on its own user, and a removed grant is refused visibly (broker log and service log). Finding for Phase 3: MassTransit replaces a dotnet process name with the entry assembly's file name (BusHostInfo, 8.4.0 and 8.5.11), and the images run dotnet <dll>, so in-cluster bus endpoints are <host>_SyRFAPIEndpoint_bus_<id> and so on (project management truncates to syrfprojectman_SyRFProjectMan_bus_<id>), not …_dotnet_bus_…. The in-cluster values in §3 and in the files' placeholders descriptions were corrected in syrf#4228, server-config#306 and cluster-gitops#1659 |
AC9 |
| 0.4 | Done (syrf#4200). Local RabbitMQ 3.13.6 proofs for §4: live POST /api/definitions upsert and tag demotion, no deletion of unlisted users, DELETE /api/users/rabbit, and the Bitnami 14.6.6 behaviour when auth.username names a user that the definitions no longer list. Probes are already credential-free (V): with loadDefinition.enabled the live StatefulSet's readiness is rabbitmq-diagnostics -q check_running && rabbitmq-diagnostics -q check_local_alarms and liveness rabbitmq-diagnostics -q ping, so demoting or deleting auth.username cannot make rabbitmq-0 unready. The proof re-checks the rendered probes, because disabling loadDefinition would switch the chart to curl --user <auth.username> probes. Done 2026-10-14 (syrf#4200): local proofs. Probes and start-up confirmed (the chart never re-creates or re-tags auth.username while loadDefinition is on); refuted: import atomicity (K2) and "removing the entries revokes rabbit's grants" (Phase 5.2); decided: the Phase 5.4 chart change restarts the broker. |
gate for Phases 1 and 5 |
| 0.5 | Done (cluster-gitops#1656). A check that the regexes in the cluster-gitops definitions equal the syrf files at the deployed commit (a script in cluster-gitops CI that fetches the files at the image's sha). It reads syrf with the dedicated read-only GitHub App camarades-syrf-reader (Contents: read, installed on syrf only; secrets SYRF_READER_APP_ID and SYRF_READER_PRIVATE_KEY). A known_gap ratchet downgrades only a regex mismatch or a missing contract file, and nothing else |
AC7 |
| 0.6 | Done (syrf#4199). API chart: a values switch rabbitMq.provisionPreviewVhost (default true, so behaviour is unchanged) that the preview ApplicationSet turns off in Phase 2. Implemented in #4199. |
AC10 |
Phase 0 is complete (2026-10-15). Flag decision: not flagged. Phase 0 changes no runtime behaviour, and every later switch is a per-environment values change whose rollback is a revert.
Phase 1: provisioning foundation (cluster-gitops rabbitmq namespace) (P)¶
Status (2026-10-17): done. Steps 1 to 4 are live; the provisioner has not yet made an import.
- Done. ExternalSecrets for every new principal (§2) with
refreshPolicy: CreatedOnce, sync-wave-10(cluster-gitops#1664, merged 2026-10-15T01:45Z): eight, allSecretSynced. - Done. Added to the definitions template with interim grants:
.*on their own vhost and tag"", as #1430 did for the notifiers.syrf-broker-provisionerisadministratorwith no permissions.syrf-production-pdfagentis on the Paused set (#1663, D11). - The definitions applier (§4), first authenticating as
rabbit. First run done 2026-10-15T01:46:27Z: HTTP 204, sha2568cd85bd7…. Switched to the provisioner by cluster-gitops#1668 (merged 2026-10-15T03:21Z); no import since. -
Done. The per-environment ClusterSecretStores and consumer ExternalSecrets (§5): #1688 (staging, including
rabbitmq-pdfagent-gke) and #1690 (production), verified 2026-10-17 without a broker query. -
Verify (no broker query): the 8 ExternalSecrets are
SecretSynced; the renderedload_definition.jsonparses (checked without printing values); the applier's logged result is HTTP 204 with the expected sha256 (a 4xx would mean the import failed part-way); the applier Job and CronJob succeed;rabbitmq-0start time and restart count are unchanged; no workload logs a broker auth or connection error. No workload uses the new principals yet. A per-user permission read is not part of the default evidence: it needs the provisioner and Chris's go for that call (the observer gets 401 on it). Steps 1 to 3 (first run) were verified this way on 2026-10-15 (§1). - Rollback: revert. Users created live stay until Phase 5 deletes them, but they are inert: nothing
holds their passwords outside
rabbitmq.
Phase 2: previews (after Phase 0.6) (P) for steps 1, 2 and 4¶
The preview reconciler (§5) owns preview vhosts and principals, so this phase deploys it first, then switches. The reconciler runs as an administrator and writes to the broker that production shares, so deploying it, turning off its dry run and changing the shared ClusterExternalSecret each need Chris's go.
- Deploy the preview reconciler (§5) with
dryRun: true: it logs the PUTs and DELETEs it would make. Done: cluster-gitops#1669 (CronJobrabbitmq-preview-reconciler,DRY_RUN: "true", limitsmax-connections50 andmax-queues256) and syrf#4236 (the per-previewrabbitmq-preview-principalExternalSecret, inert until step 3). The dry run records absences in its state ConfigMap, so itswould DELETElines are what the first live run deletes, including vhosts left by previews that closed before the reconciler existed. - Turn off dry run. Each preview gets
syrf-pr-N. Remove therabbitmqctlvhost deletion from_preview-cleanup.yml; the reconciler deletes a closed preview's user and vhost once its namespace is gone. Done (Chris's go, 2026-10-16): cluster-gitops#1681 (DRY_RUN: "false") merged at 08:01Z; the first live run (08:02Z) deleted the 8 vhosts below, each HTTP 204, with 0 errors, andrabbitmq-0kept 0 restarts. syrf#4251 then removed the_preview-cleanup.ymlstep. Its last dry run would delete 8 vhosts whose namespaces are gone though their PRs are still open (pr-2224, 2322, 2461, 2469, 2528, 2784, 2786, 2802); Chris approved that, since the API PreSync hook recreates a vhost on redeploy. Until a preview redeploys from a head that includes syrf#4236 (which renders its Secret), the live reconciler provisions nothing for it.preview-sweep.yml"Layer 4a", the last automatedrabbitmqctluse (AC12), was retired by syrf#4274 (Chris's go, 2026-10-17). - Switch the preview workloads, per head. API, PM and Quartz:
syrf#4310 (chart switch
rabbitMq.previewPrincipal.enabled, default true; see §5 Previews). A global ApplicationSet values change is not used: the ApplicationSet renders the charts at each PR's head SHA, and a head older than syrf#4236 has norabbitmq-preview-principalSecret (its pods would fail withCreateContainerConfigError) and, older than syrf#4199, an API PreSync hook that would log in assyrf-pr-Nwithrabbit's password and fail the sync. A preview therefore moves when its branch includes syrf#4310 and redeploys; previews on older heads stay onrabbit(on 2026-10-17 all five live previews,pr-2412,pr-3292,pr-3327,pr-3394andpr-3934, had heads from 2026-09-06 to 2026-10-02). No cluster-gitops change is needed to switch; cluster-gitops adds the ApplicationSet parameterrabbitMq.previewPrincipal.enabled=falseonly to roll back. Notifier: separate. The preview notifier values point at the same Secret, and #1435 is rebased to usesyrf-pr-Ninstead ofrabbit(one manual Argo sync perpr-N-s3-notifier), in its own windowed change; until then the preview notifier keepsrabbit. -
Stop copying
rabbit-mqinto preview namespaces (ClusterExternalSecret selector). Do this last, so a rollback of step 3 still finds the Secret. It waits for both halves of step 3: the notifier onsyrf-pr-N(#1435), and no deployed preview left on a head older than syrf#4310 (any such preview still readsrabbit-mq). -
Verify step 3 (per preview, no broker query): for the first preview redeployed from a head with syrf#4310, its
rabbitmq-preview-principalExternalSecret isSecretSynced; the next reconciler run logs that preview as provisioned witherrors 0; the API, PM and Quartz pods are Ready, readsyrf-pr-N, and log noACCESS_REFUSED; the API app has norabbitmq-setupPreSync Job; previews on older heads are unchanged;rabbitmq-0keeps its start time and 0 restarts. - Verify: a new preview reaches Preview-Ready with no
rabbit-mqSecret in its namespace; a preview reference upload lands (Lambda CloudWatch log shows the publish, the search import progresses); closing a preview removes its user and vhost (the reconciler's logged teardown result; a directGET /api/vhosts/pr-Nonly with Chris's go, as the provisioner). - Rollback: revert step 4 first (the
rabbit-mqcopies come back within a minute), then step 3: for API, PM and Quartz add the ApplicationSet parameterrabbitMq.previewPrincipal.enabled=falsein cluster-gitops (every preview on a head with syrf#4310 re-renders withrabbitand the PreSync hook; older heads ignore the value), and revert #1435 for the notifier (previews go back torabbit, whose permissions the reconciler does not touch, and the PreSync hook runs again).
Phase 3: staging¶
Order (the contract's B1.8): s3-notifier and reconciler (#1433, already written), staging Kubernetes PDF agent (D6), Quartz, PM, API. One commit per workload; verify each before the next.
- Each switch: values
rabbitMq.username: syrf-staging-<w>andrabbitMq.authSecretName: rabbitmq-<w>→ Deployment rolling update (maxSurge 1,maxUnavailable 0at one replica). Hard rule: the same change re-syncs the staging notifier env-vars (s3-notifier-stagingand its reconciler) wherever the credential the notifier holds changes, and the Lambda configuration is checked before the old credential is removed (K3, D9). - Verify each: Argo Healthy; the new pod Ready; logs show the bus started and no
ACCESS_REFUSED; the workload's own start-up log names the new user and thesslstate, which is the evidence for "norabbitconnection from that workload" (a directGET /api/vhosts/syrf-staging/connections?columns=user,sslonly with Chris's go, as the provisioner, one call); one full cycle of the affected scheduled work (session idle/liveness timers, the daily statistics run for Quartz and PM). - Then tighten: replace the interim
.*with the fixture-proven regexes from Phase 0, one principal per commit (applier, no restart). Verify the same way over 24 hours, including a fault path if organic traffic produces one (_errorqueues get created lazily). - Rollback: revert the values commit (back to
rabbit, which keeps its permissions until Phase 5) or revert the regex commit.
Phase 4: production (P) every step¶
Same order and mechanics as staging, each step its own go. Hard rule for every step: if the change
alters a credential the production notifier holds, re-sync s3-notifier-production in the same change and
check the Lambda's RabbitMqUsername before the old credential is removed (K3, D9; the 2026-10-14 incident).
- Notifier: #1434 (AMQPS 5671 and
syrf-production-s3notifier), then a manual sync ofs3-notifier-production. Verify with the first organic production upload (or a Chris-approved upload into a test project): the Lambda's CloudWatch log shows the publish and no authentication error, and the search import job advances. Confirm one ACK resync later (aws lambda get-function-configuration --query Environment.Variables.RabbitMqUsername) that ACK did not revert the variables (risk K3). - Quartz, 3. PM, 4. API: as staging. Recommended window: 05:00–07:00 UTC. Each rollout has no expected downtime (§8).
-
Tighten to the proven regexes, one principal per commit, after 7 clean days.
-
Rollback: revert the values commit; Argo rolls the Deployment back to
rabbit. For the notifier, revert and sync again.rabbitstays valid until Phase 5.
Phase 5: retire rabbit (P)¶
Entry proof (7 days, including a daily-statistics run): every workload connects as a new principal (each
workload's start-up log and the Deployments' and Lambdas' configured usernames; a direct per-vhost
connection read only with Chris's go, as the provisioner, one call per vhost); no
ACCESS_REFUSED in broker or workload logs; the owners of the local and / vhosts are identified or
retired (D12); no Kubernetes object references rabbit-mq outside rabbitmq (kubectl get deploy,sts,job,cronjob -A -o json filtered for secretKeyRef.name == "rabbit-mq").
- Stop distributing
rabbit-mqtosyrf-stagingandsyrf-production(ClusterExternalSecret selector). No restart: no pod reads it any more. - Demote: in the definitions,
rabbitgets tags""and an explicit^$ ^$ ^$entry on each of its four vhosts (/,syrf-production,syrf-staging,local), applied live by the applier. Removingrabbit's entries instead would leave its.*grants in place, because an import never revokes (Phase 0.4, C11). The^$entries still letrabbitopen those vhosts but not touch any resource; step 5 removes the user. They also keep the rollback a plain revert, which restores.*. The broker's probes do not authenticate (Phase 0.4, confirmed), so this cannot affectrabbitmq-0readiness whileauth.usernamestill namesrabbit. From this point the management UI and API refuserabbit. The applier must already run as the provisioner (Phase 1.3). - Rotate: add a new
camarades-rabbit-mqversion in GCP SM. ESO re-renders the definitions and the applier sets the new hash. No workload restarts because none usesrabbit. This replaces #1437/#1438 (merged 2026-10-14, §1). Hard rule: re-sync each environment's notifier env-vars in the same change and verify them before the old password is gone; by this phase no notifier should holdrabbitat all, and the entry proof must show it. - Break-glass: create
rabbitmq-breakglassin GCP SM, sync it intorabbitmq, addsyrf-broker-breakglass(administrator, no permissions) to the definitions, and point the Bitnamiauth.username/existingPasswordSecretat it. This restarts the broker (Phase 0.4, C16): the change alters the StatefulSet's pod template (RABBITMQ_USERNAME, theRABBITMQ_PASSWORDsecretKeyRef and thechecksum/configannotation). It needs a window (a 2 to 5 minute AMQP outage for every environment) or rides with another planned restart. WithloadDefinitionon, the chart never re-creates or re-tagsauth.usernameat start-up, so only the definitions decide the break-glass user's state. This step must land before step 6 deletesrabbit-mq: the StatefulSet readsRABBITMQ_PASSWORDfrom it through a non-optionalsecretKeyRef, so the next broker pod could not start without it. - Delete: remove
rabbitfrom the definitions, then run one approval-gated Job that makes a singleDELETE /api/users/rabbitas the provisioner. The order matters: while the file still listsrabbit, the next import or restart re-creates it. The delete force-closes any connectionrabbitstill holds (CONNECTION_FORCED), which the Phase 5 entry proof must show is none (workload logs and configured usernames; a directGET /api/vhosts/<vhost>/connections?columns=userper vhost only with Chris's go, as the provisioner, one call at a time, as the pre-delete gate). Remove thelocaland/grants (D12). -
Clean up copies: disable every
camarades-rabbit-mqversion and delete the secret after 30 days; delete theRABBITMQ_PASSWORDrepository secrets insyrfandcamarades-infrastructure, and remove the dormant Terraformrabbitmq_*variables and legacy preview-Lambda module; delete the stalerabbitmq-load-definitionSecret that #1431 kept as a rollback target; droprabbit-mqfrom the definitions template's data and delete therabbit-mqClusterExternalSecret. -
Rollback: before step 5, revert the definitions commit (the applier restores
administratorand the grants). After step 5, recovery uses break-glass to recreate a principal, never a shared admin for workloads (contract B2).
Phase 6: close the management UI (P) (independent; step 1 done)¶
Status (2026-10-15): steps 1, 3 and 4 are done; step 2 and the certificate-renewal proof remain.
cluster-gitops#1639 (merged 2026-10-14 04:19Z)
set ingress.enabled: false first; the record kept resolving to 34.13.63.21 because External-DNS runs with
--policy=upsert-only, but had no owning source. Step 1,
cluster-gitops#1662, restored ownership on
2026-10-15.
- Done (cluster-gitops#1662). ingress-nginx
controller Service
ingress-nginx/ingress-nginx-controllercarries the External-DNS hostname annotation forrabbitmq.camarades.net. Verified live: the annotation is present, the record is unchanged at34.13.63.21, andopenssl s_client -connect rabbitmq.camarades.net:5671gives verify 0. - Decide the MCP server's fate (D5) and update
~/.config/mcp-proxy/config.json(local, not in git). - Done via cluster-gitops#1639. Set
ingress.enabled: falsein the broker values. Bitnami 14.6.6 renders the Ingress separately from the StatefulSet, so the broker does not restart (confirm with ahelm templatediff in the PR). -
Done in the Phase 0.4 PR. Fix
docs/platform/masstransit-observability.md(namespace, observer instead of default credentials). -
Verify: AC4.
- Rollback: step 1 reverts cleanly (the record stays under
upsert-only, but loses its owner again). Reverting #1639 brings the Ingress and its certificate back, which reopens the public UI; do that only to restore operator access in an emergency, and prefer port-forward.
8. Production impact per phase¶
| Phase | Broker restart | Workload restarts in production | Expected downtime | Needs Chris's go |
|---|---|---|---|---|
| 0 | none | none | none | no (syrf PRs; normal review) |
| 1 | none with the applier (D1; confirmed, rabbitmq-0 0 restarts after the first import); one window (~2–5 min AMQP outage, all environments) without it |
none | none | yes: it writes users to the shared broker |
| 2 (previews) | none | none | previews only | yes for the reconciler (an administrator writing to the shared broker) and the ClusterExternalSecret change; no for the preview values |
| 3 (staging) | none | none | staging rollouts only | no for values; yes for regex commits, because the applier writes to the shared broker |
| 4 (production) | none | each switch rolls one Deployment (surge first: the new pod is Ready before the old one stops); the notifier step reconfigures the Lambda | none expected; a few seconds of MassTransit reconnect per service, and in-flight scheduled deliveries retry | yes, every step |
| 5 (retire) | one, at step 4: the Bitnami auth.username/existingPasswordSecret change restarts the pod (Phase 0.4) |
none | ~2–5 min AMQP outage at step 4, in a window; none for the other steps | yes, every step |
| 6 (UI) | none (confirm by render diff) | ingress-nginx controller config reload only, no rollout | none for AMQP or web | yes |
9. Acceptance criteria¶
| # | Criterion | Verification |
|---|---|---|
| AC1 | No workload holds an administrator credential. | Every API/PM/Quartz/PDF-agent Deployment's RabbitMqConfig__Username is a principal from §2 (kubectl get deploy -A -o json, value field only); every Lambda's RabbitMqUsername likewise (aws lambda get-function-configuration); the rendered definitions give those users tags ""; kubectl get secret rabbit-mq -A lists only rabbitmq (Phase 5.1) and then nothing (Phase 5.6). |
| AC2 | Each principal is refused outside its vhost and its regexes. | Fixture negative tests in CI (Phase 0.2). Live: the applier's logged 204 for the import of the committed strings, plus the Phase 0.5 parity check, is the default evidence. A per-principal GET /api/users/<name>/permissions shows exactly one vhost and the committed strings, but needs the provisioner and Chris's go for that call (the observer gets 401 there). No live negative probes. |
| AC3 | Production notifications still arrive after an upload. | After Phase 4.1: the first organic (or approved test) production upload shows the Lambda publish in CloudWatch with no auth error, and the search import job advances in the UI. Repeated after Phase 4.5 and Phase 5. |
| AC4 | The management UI is not reachable from the Internet. | From an external host: curl -skS -o /dev/null -w '%{http_code}' https://rabbitmq.camarades.net/ returns the nginx default backend 404, and curl -skS https://rabbitmq.camarades.net/api/overview shows no RabbitMQ response; kubectl get ingress -n rabbitmq is empty; openssl s_client -connect rabbitmq.camarades.net:5671 -verify_return_error still verifies (DNS kept). |
| AC5 | rabbit no longer exists anywhere. |
Git shows rabbit removed from the definitions, the delete Job's logged 204, and a refused login in the workload logs; a direct GET /api/users/rabbit → 404 only with Chris's go, as the provisioner; git grep -n 'rabbit-mq\|username: rabbit' in cluster-gitops origin/main finds no workload reference; camarades-rabbit-mq versions disabled; gh secret list in syrf and camarades-infrastructure shows no RABBITMQ_PASSWORD. |
| AC6 | Exactly two administrators remain, and only in rabbitmq/GCP SM. |
The rendered definitions (checked without printing values) give administrator only to the provisioner and break-glass; a direct GET /api/users?columns=name,tags&disable_stats=true (a short list, not a queue listing) only with Chris's go, as the provisioner (the observer gets 401); kubectl get secret -A shows their Secrets only in rabbitmq. |
| AC7 | Live regexes equal the committed contract. | The Phase 0.5 check passes on cluster-gitops main. |
| AC8 | Each service runs under its own regexes. | Each service's broker-permission test passes (positive and negative) in CI on main. |
| AC9 | The whole stack works under least privilege. | run:e2e-smoke and the Bulk PDF lane pass with every service on its own restricted user. |
| AC10 | Previews are provisioned, repaired and torn down without an admin credential in the preview. | A new preview is Preview-Ready with syrf-pr-N; its namespace has no rabbit-mq Secret; in one preview, deleting rabbitmq-preview-principal (ESO recreates it with a new value), waiting one reconciler interval and rolling the Deployments returns the preview to Ready with no ACCESS_REFUSED; after close, the reconciler's logged teardown result shows the user and vhost deleted (a direct GET /api/vhosts/pr-N → 404 and GET /api/users/syrf-pr-N → 404 only with Chris's go, as the provisioner). |
| AC11 | Phases 1–4 restart no broker (if D1 is accepted). | rabbitmq-0 pod start time and restartCount unchanged across those phases (kubectl, no broker query). Phase 1's first import: start 2026-10-14T04:03:34Z, 0 restarts. |
| AC12 | No automation runs rabbitmqctl against the shared broker. |
git grep -n rabbitmqctl over syrf .github/ and cluster-gitops finds no use against rabbitmq-0. Met 2026-10-17 (syrf#4274): only comments remain. |
| AC13 | Rotation needs no coordinated restart. | One principal is rotated by the new-principal/switch/delete method in staging, with no broker restart and only the switched workload rolling. |
| AC14 (D9 only) | No notifier password in a Function CR. | kubectl get functions.lambda.services.k8s.aws -A -o json \| jq '.items[].spec.environment.variables \| keys' shows no RabbitMqPassword. |
10. Wolverine and the MassTransit 8.5.11 bridge¶
- 8.5.11 (syrf#4147). Merged before Phase 0.2; the broker-permission tests (syrf#4201) run on 8.5.11. Any later MassTransit bump must run them green.
- Wolverine (ADR-021, single cut-over). The principal model, vhosts, secret plumbing, applier, reconciler and break-glass do not change. The regexes do: ADR-021 S1.4 and S2.9 already require them to be regenerated from the Wolverine topology and proved by the same fixture. What changes:
- MassTransit names (
Namespace:Typeexchanges,quartz,Job*,MassTransit.*,_error/_skipped,<host>_<process>_bus_<id>) disappear. Wolverine names its own queues and exchanges, its reply queues and, with durability, its control queues; automatically named reply queues need configure. Pin their names so the regexes stay exact. - Scheduling moves from the Quartz service to Wolverine's database-backed scheduling, so
-quartzand its confused-deputy allowlist go away. The principal is deleted after the cut-over. - Opportunity: Wolverine can run with automatic provisioning off. If PM provisions the topology and the clients (Lambda, PDF agent, API) only send, the clients need write only. That closes the configure-implies-delete residual (D6) and the read-implies-consume residual (D14) for free.
- Because the switch-over is a single cut-over, the regex change ships in the same window: a definitions commit applied live (D1) just before the switch, with the MassTransit regexes kept until rollback is no longer possible.
11. Decisions for Chris¶
Accepted 2026-10-14: all fourteen as recommended (Chris: "all as recommended"). D4's Ingress removal landed through cluster-gitops#1639 and the DNS owner through cluster-gitops#1662 (2026-10-15); see Phase 6. D11 is done (#1663). D13 is moot (see §1).
| ID | Decision | Recommendation |
|---|---|---|
| D1 | How principal changes reach the broker | A live definitions applier (one POST /api/definitions per change) using a standing syrf-broker-provisioner admin in rabbitmq only. It removes about four production broker restarts. Alternative: boot-only definitions with batched restart windows. The Topology Operator (the contract's original D1) would need its admin connection Secret next to every CR, so it either spreads the admin credential to preview namespaces or needs the same cross-namespace plumbing. |
| D2 | Preview principals | Per preview (syrf-pr-N, .* on pr-N), owned by the reconciler, as the contract specifies. Fallback: one shared syrf-preview principal granted on each pr-N (simpler, but previews can read each other). |
| D3 | Where principal passwords come from | Keep the in-cluster generator (status quo since #1430). GCP SM only for break-glass. |
| D4 | Management UI | Remove the Ingress; port-forward only, with DNS ownership on the ingress-nginx Service. The Ingress removal landed through #1639 before the DNS step; the record has an owner again since #1662 (2026-10-15). Remaining: the certificate-renewal proof. |
| D5 | The local rabbitmq MCP server |
Remove it from the shared gateway. Its queue listing tools can make exactly the unfiltered calls that caused the 2026-10-10 OOM, and it runs with mutative tools as rabbit. If it is wanted later: observer credential, port-forward, no mutative tools, vhost-scoped calls only. |
| D6 | Staging Kubernetes PDF agent (Enabled, as rabbit; ADR-015 says no agent runs in Kubernetes) |
Give it its own principal syrf-staging-pdfagent-gke (Enabled set) now so behaviour is unchanged, and retire it under ADR-015 separately. It must not share syrf-staging-pdfagent, which holds the ARRNC slot's Paused set. |
| D7 | One step or two per environment | Two: first the identity switch with .* on the principal's own vhost (removes admin and cross-environment reach), then the proven regexes. Each is a separate revert. |
| D8 | In-cluster AMQPS | Not in this plan. Keep in-cluster clients on 5672 while principals move; switching the transport at the same time doubles failure modes and changes persisted Quartz destination addresses. In-cluster AMQPS (contract B1.0 TlsServerName) and closing 5672 (contract B2.3) stay separate. |
| D9 | Notifier credential delivery | MVP: keep the env-vars job with a documented manual sync per switch. Follow-up: SSM Parameter Store SecureString read at cold start, written by ESO PushSecret (§5). |
| D10 | Secret store granularity | One ClusterSecretStore per environment, restricted to that namespace and to that environment's principal Secret names. This deviates from the contract's one store per principal: the namespace is already the boundary, because any pod that can read Secrets in it can read every Secret there. |
| D11 | syrf-production-pdfagent |
Set it to the Paused set now (it has .* on production and nothing should use it until the ARRNC production cutover). Done: cluster-gitops#1663, live with the 2026-10-15T01:46Z import; no syrf-pdf-agent-production container exists on arrnc-api (confirmed 2026-10-15). |
| D12 | The local and / vhosts (contract D4) |
Retire them in Phase 5 unless an owner is found by then. Developer uploads to the production bucket with virtualhost=local already fail after #1434, as intended. |
| D13 | #1437 and #1438 | Close both as superseded by this plan (§12). Moot: another session merged both on 2026-10-14 around 04:00Z; see the incident. |
| D14 | queue: senders that can consume the queues they send to (-api, -s3notifier) |
Accept for the MVP: the API and the notifier are first-party, and the main gain is no admin and no cross-environment reach. Follow-up: PM-owned ingress exchanges and exchange: sends, as #4138 does for the PDF agent. |
12. Superseded and related PRs¶
- cluster-gitops#1437 and
#1438 were meant to be closed as superseded by
this plan, but another session merged them around 04:00Z on 2026-10-14 (D13 is moot; the predicted failure
happened, see the incident). They rotated the shared
rabbitpassword while every workload still uses it. #1438 restarts the broker and the in-cluster consumers, then re-syncs only the preview notifier jobs. The production and staging notifier Lambdas kept the old password, so production upload notifications stopped untils3-notifier-productionwas re-synced at 09:43Z. Under this plan,rabbitis rotated only after nothing uses it (Phase 5.3), with no workload restarts. - cluster-gitops#1434 (production notifier on
syrf-production-s3notifierover AMQPS) is Phase 4.1 as written. - #1433 is the first step of Phase 3.
#1435 is rebased in Phase 2 to use
syrf-pr-N. - #1436 (close public 5672) belongs to the transport track. Its gate (every Lambda and ARRNC agent on 5671) is met after Phases 2–4.
- syrf#4139 and #4140
(PDF agent ingress exchange) are compatible: the PM draft already allows
.Agent.
13. Risks¶
| # | Risk | Mitigation |
|---|---|---|
| K1 | A regex gap appears only on a rare path (a fault creates _error lazily; a scheduled redelivery fires days later). |
Phase 0.2 forces fault and redelivery paths; D7's .* interim step separates identity from narrowing. Consumers use UseInMemoryOutbox, so a refused publish inside a consumer causes redelivery, not loss. Publishers outside a consumer have no outbox: API controllers and handlers, NotificationHub's SchedulePublish/CancelScheduledPublish, PM's recurring *Schedule services, and the notifier Lambda's Publish, Send and SubmitJob. There a refused operation fails the HTTP request, the hub call or the schedule registration, and for the Lambda it fails the invocation: S3 retries an asynchronous invocation twice, after which a reference-upload notification is lost (bulk-PDF objects are also covered by the reconciler's durable capture in staging). The Phase 0.2 fixtures must therefore drive each of these publisher paths, not only consumers. |
| K2 | The applier imports a broken file (for example after a template error). | ESO keeps the last good render on failure; the applier skips unchanged hashes and fails closed on JSON parse errors. The import is not all-or-nothing (Phase 0.4 refuted it, C13): a request that fails on a later entry returns 400 after applying the earlier ones, while malformed JSON is rejected whole. The applier therefore records the hash only after a 2xx response and retries otherwise; every import is an upsert, so the next good import converges. |
| K3 | ACK reverts the Lambda environment set by the env-vars job, or a credential changes while a notifier still holds the old one. The second form happened on 2026-10-14: the production notifier received the rotated rabbit password only at 09:43Z, and every production upload notification from about 04:00Z failed with "Broker unreachable" (one search import lost). |
Evidence says ACK does not revert: post-creation bulk-PDF keys appear in the staging CRs, so the CRs follow AWS. Phase 3 and 4.1 still confirm the username one resync later. Hard rule (D9): every later credential switch re-syncs each environment's notifier env-vars in the same change and verifies the Lambda configuration before the old credential is removed (Phases 3 to 5). |
| K4 | The reconciler deletes a live preview's vhost (for example if the Kubernetes API list fails), or a recreated preview Secret locks the preview out. | It deletes only when the namespace list call succeeded and the namespace is absent from two consecutive runs; dry-run first; concurrencyPolicy: Forbid. The user's password and permission are re-applied on every run (§5), so the broker side heals within one interval; the preview's Deployments are then rolled (AC10). |
| K5 | The provisioner credential, or the reconciler's Kubernetes identity, leaks. | The provisioner password exists only in rabbitmq (generator, CreatedOnce). It is read by the applier's and reconciler's ServiceAccounts, and it is also rendered into the ESO-built rabbitmq-definitions Secret that the broker pod rabbitmq-0 loads. The reconciler's ClusterRole is broader than one Secret: it may list namespaces and get rabbitmq-preview-principal in every namespace, so its identity can read every preview's password (never a staging or production one, which have different names). That ServiceAccount is used only by the reconciler CronJob in rabbitmq. Rotation by the new-principal method; the provisioner is listed in AC6. |
| K6 | Certificate renewal is unproven without the management Ingress. (The DNS-ownership half is closed: cluster-gitops#1662 gave rabbitmq.camarades.net an owner on 2026-10-15; verified live, IP 34.13.63.21, verify 0 on 5671.) |
Renewal is proved (cmctl renew in a window, or the next natural renewal) before Phase 6 closes. cert-manager's temporary solver Ingress does not need the management Ingress. |
| K7 | Somebody queries the shared broker during a rollout, or verifies with an unfiltered listing. | Chris's standing rule: no direct broker queries; verify from the applier's logged result, ExternalSecret and Job status, and workload logs. The hard rule is repeated in every phase PR's verification section; D5 removes the easiest way to do it. |
14. Follow-ups outside this plan¶
- In-cluster AMQPS and closing 5672 (contract B1.0/B2.3; D8).
- Notifier secret delivery via SSM (D9).
- Ingress exchanges for API and notifier sends (D14).
- Retire the Kubernetes staging PDF agent under ADR-015 (D6).
- Deliver the production ARRNC slot's credential through a recorded channel, not a workflow
-eflag.
Related: broker security contract (B0), implementation plan B0–B2, ADR-015, ADR-021.