Repository navigation
Expand file tree
/
Copy pathdocker-compose.prod.yml
More file actions
501 lines (465 loc) · 22.1 KB
/
Copy pathdocker-compose.prod.yml
File metadata and controls
501 lines (465 loc) · 22.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
# AiSOC — production compose.
#
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
#
# This is the file `apps/docs/docs/deployment/docker.md` has pointed at since
# the deployment page was written. It did not exist, which is what discussion
# #629 reported: the documented production command failed on a missing file,
# and the only stack an operator could actually start was the development one,
# where `ENVIRONMENT` defaults to `development` and
# `services/api/app/api/v1/dev_auth.py` resolves an unauthenticated request to
# a demo user whose role is `admin`.
#
# It is an overlay over `docker-compose.yml`, so there is exactly one
# definition of every service and this file holds only what production changes.
# A standalone copy would have drifted from the base within a release, which is
# the failure this repository keeps finding.
#
# It was written with the Compose Spec `include` directive and a single `-f`.
# That does not work: `include` imports a model, and overriding a service it
# imported is rejected with `services.<name> conflicts with imported resource`.
# `infra/compose/docker-compose.dev.yml` had the same shape and has since
# moved to a two-`-f` overlay for the same reason — it could never have
# carried an override, which is why it carried nothing.
# Measured rather than assumed — `-f docker-compose.prod.yml` alone fails on
# Compose v2.29.7 and v2.39.4 and resolves only on 5.1.3, so the documented
# production command worked on almost no installation. Two `-f` flags are the
# ordinary override form and resolve identically on all three.
#
# WHAT THIS FILE GUARANTEES
#
# * The authentication bypass is unreachable. `ENVIRONMENT` and `AISOC_DEV_MODE`
# are fixed values here, not interpolated, so no `.env` can re-enable them.
# * Nothing starts on a default credential. Every secret uses `${VAR:?...}`,
# which makes Compose refuse to start and name the variable rather than
# starting on nothing — and, because `:?` rejects an *empty* variable and
# cannot look at a value, `preflight-secrets` below runs ahead of every
# other service and refuses the literals this repository publishes. Until
# it existed this bullet was false: `${POSTGRES_PASSWORD:?… the
# development default is a published literal}` accepted `aisoc_dev_secret`,
# which is what `.env.example` shipped.
# * No datastore is reachable from the host. Postgres, Redis, Kafka and
# Qdrant publish no ports at all; the console and the ingest endpoint are
# the only things bound, and they bind to loopback unless you set
# AISOC_BIND_ADDR deliberately (put a TLS-terminating proxy in front —
# see docs/runbooks/HARDENING.md section 2).
#
# WHAT IT DOES NOT DO
#
# TLS, secret rotation, network policies, audit-log forwarding and backups
# are not in a compose file. `docs/runbooks/HARDENING.md` is the checklist
# and this file is the starting posture, not the finished one.
services:
# ---------------------------------------------------------------------------
# The value check `${VAR:?}` cannot do.
#
# Compose's `:?` operator rejects an unset or empty variable. It cannot look
# at a value, so `${POSTGRES_PASSWORD:?… the development default is a
# published literal}` accepted `aisoc_dev_secret` — the exact literal its own
# message names, shipped in `.env.example` and defaulted to in
# `docker-compose.yml`. Every guard below had the same hole, so the guarantee
# in this file's header held for none of them.
#
# This runs first and every other service waits for it to exit 0.
# `scripts/check_published_secrets.py` derives the refused values from the
# tree — the `${VAR:-literal}` defaults in the compose files, the non-empty
# values in `.env.example`, and INSECURE_SECRET_KEY_DEFAULTS — so a default
# added later is refused without anyone remembering to list it here.
#
# Every `${VAR:?}` variable in this file is repeated below. That is what
# gets it into the container's environment, and `--check` fails when a guard
# exists that the preflight never sees.
# ---------------------------------------------------------------------------
preflight-secrets:
image: ghcr.io/beenuar/aisoc-core-api:${AISOC_VERSION:-latest}
pull_policy: missing
container_name: aisoc-preflight-secrets
restart: "no"
entrypoint: ["python3", "/repo/scripts/check_published_secrets.py", "--check-env"]
# Read-only, and the whole tree rather than the four files the harvest
# happens to read today: a hand-listed mount is another list that stops
# matching the code, and the failure mode is the check quietly reading
# less than it thinks it does. `AISOC_REPO_ROOT` is how `gate_toolkit`
# is pointed at a tree without shelling out to git, which this image
# does not carry.
volumes:
- .:/repo:ro
environment:
AISOC_REPO_ROOT: /repo
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD — the development default is a published literal}
AISOC_APP_DB_PASSWORD: ${AISOC_APP_DB_PASSWORD:?set AISOC_APP_DB_PASSWORD — the development default is a published literal}
REDIS_PASSWORD: ${REDIS_PASSWORD:?set REDIS_PASSWORD}
SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY — run `make env` to generate it}
AISOC_CREDENTIAL_KEY: ${AISOC_CREDENTIAL_KEY:?set AISOC_CREDENTIAL_KEY — run `make env` to generate it}
AISOC_SERVICE_TOKEN: ${AISOC_SERVICE_TOKEN:?set AISOC_SERVICE_TOKEN — run `make env` to generate it}
AISOC_ACTIONS_SERVICE_TOKEN: ${AISOC_ACTIONS_SERVICE_TOKEN:?set AISOC_ACTIONS_SERVICE_TOKEN — run `make env` to generate it}
AISOC_AGENTS_SERVICE_TOKEN: ${AISOC_AGENTS_SERVICE_TOKEN:?set AISOC_AGENTS_SERVICE_TOKEN — run `make env` to generate it}
AISOC_REALTIME_JWT_SECRET: ${AISOC_REALTIME_JWT_SECRET:?set AISOC_REALTIME_JWT_SECRET — run `make env` to generate it}
REALTIME_INTERNAL_TOKEN: ${REALTIME_INTERNAL_TOKEN:?set REALTIME_INTERNAL_TOKEN — run `make env` to generate it}
METRICS_TOKEN: ${METRICS_TOKEN:?set METRICS_TOKEN — guards /metrics outside development; `openssl rand -hex 32`}
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET — signs SAML/OIDC sessions}
NEO4J_PASSWORD: ${NEO4J_PASSWORD:?set NEO4J_PASSWORD}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?set CLICKHOUSE_PASSWORD}
GRAFANA_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD — required for the monitoring profile}
# ---------------------------------------------------------------------------
# Data plane. No published ports: in the development stack these are bound to
# 127.0.0.1 so a developer can attach a client, which is reasonable there and
# is an attack surface here. `ports: []` overrides the base list rather than
# appending to it.
# ---------------------------------------------------------------------------
postgres:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD — the development default is a published literal}
# The DML-only runtime role the services connect as, so the row-level
# security policies actually apply to them. Separate from the owner
# password above and rotated the same way.
AISOC_APP_DB_PASSWORD: ${AISOC_APP_DB_PASSWORD:?set AISOC_APP_DB_PASSWORD — the development default is a published literal}
redis:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
zookeeper:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
kafka:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
qdrant:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
# ---------------------------------------------------------------------------
# Application services. `ENVIRONMENT` and `AISOC_DEV_MODE` are literals on
# purpose: an interpolated value here would let a stray `.env` put a
# production deployment back into the environment where the auth bypass
# lives, which is the defect this file exists to make impossible.
# ---------------------------------------------------------------------------
api:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
environment:
ENVIRONMENT: production
AISOC_DEV_MODE: 0
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
AISOC_APP_DB_PASSWORD: ${AISOC_APP_DB_PASSWORD:?set AISOC_APP_DB_PASSWORD}
DATABASE_URL: postgresql+asyncpg://aisoc_app:${AISOC_APP_DB_PASSWORD:?set AISOC_APP_DB_PASSWORD}@postgres:5432/aisoc
DATABASE_MIGRATION_URL: postgresql+asyncpg://aisoc:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/aisoc
REDIS_URL: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD}@redis:6379/0
# `make env` generates all of these. They are required rather than
# defaulted because a service that starts without one either serves
# unauthenticated or signs with a key published in this repository —
# both were reported vulnerabilities, fixed in v12.0.0.
SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY — run `make env` to generate it}
AISOC_CREDENTIAL_KEY: ${AISOC_CREDENTIAL_KEY:?set AISOC_CREDENTIAL_KEY — run `make env` to generate it}
AISOC_SERVICE_TOKEN: ${AISOC_SERVICE_TOKEN:?set AISOC_SERVICE_TOKEN — run `make env` to generate it}
AISOC_ACTIONS_SERVICE_TOKEN: ${AISOC_ACTIONS_SERVICE_TOKEN:?set AISOC_ACTIONS_SERVICE_TOKEN — run `make env` to generate it}
# Verified by `alert_writeback.service_token_valid`, which reads this
# and only this. Unset, every internal context route the agents service
# calls refuses it, and triage silently runs without organisation
# memory, tenant skills, dispositions, identity or runbooks.
AISOC_AGENTS_SERVICE_TOKEN: ${AISOC_AGENTS_SERVICE_TOKEN:?set AISOC_AGENTS_SERVICE_TOKEN — run `make env` to generate it}
AISOC_REALTIME_JWT_SECRET: ${AISOC_REALTIME_JWT_SECRET:?set AISOC_REALTIME_JWT_SECRET — run `make env` to generate it}
REALTIME_INTERNAL_TOKEN: ${REALTIME_INTERNAL_TOKEN:?set REALTIME_INTERNAL_TOKEN — run `make env` to generate it}
# The remaining two secrets `enforce_secure_defaults` hard-fails on, and
# the reason this stack could not boot at all: `ENVIRONMENT: production`
# above turns every entry in `warn_if_insecure_defaults` into a refusal,
# and these two were the only ones no service block passed through. The
# API crash-looped on
#
# InsecureProductionDefaultsError: Refusing to boot in production with
# insecure defaults:
# - METRICS_TOKEN is empty in a non-development environment
# - JWT_SECRET is empty or set to the well-known placeholder
#
# while compose reported the stack up. Declaring them here is what makes
# the refusal arrive the way the rest of this file's do — before anything
# starts, naming the variable — rather than as a restart loop behind a
# console that is already listening.
#
# `make env` deliberately does not generate METRICS_TOKEN: a token set
# anywhere overrides the development exemption on /metrics, so writing
# one into every `.env` would close the scrape endpoint on development
# stacks whose Prometheus sends no credential. It is a production secret,
# set deliberately here.
METRICS_TOKEN: ${METRICS_TOKEN:?set METRICS_TOKEN — guards /metrics outside development; `openssl rand -hex 32`}
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET — signs SAML/OIDC sessions; the same value ingest-worker takes below}
ingest-worker:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !override ["${AISOC_BIND_ADDR:-127.0.0.1}:8081:8080"]
restart: unless-stopped
environment:
# Both keys, because `envmode.Current()` reads ENV first and the base
# file's ENV is what shadowed an operator's ENVIRONMENT before this.
ENV: production
ENVIRONMENT: production
# Required outside development by
# services/ingest/internal/config/config.go. This is the service that
# accepts events off the network, so it is the last one that should be
# allowed to start without a signing secret.
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET — ingest refuses to start without it outside development}
AISOC_SERVICE_TOKEN: ${AISOC_SERVICE_TOKEN:?set AISOC_SERVICE_TOKEN}
AISOC_NEO4J_PASSWORD: ${NEO4J_PASSWORD:?set NEO4J_PASSWORD}
fusion:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
environment:
ENVIRONMENT: production
AISOC_DEV_MODE: 0
AISOC_SERVICE_TOKEN: ${AISOC_SERVICE_TOKEN:?set AISOC_SERVICE_TOKEN}
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?set CLICKHOUSE_PASSWORD}
agents:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
environment:
ENVIRONMENT: production
AISOC_DEV_MODE: 0
AISOC_SERVICE_TOKEN: ${AISOC_SERVICE_TOKEN:?set AISOC_SERVICE_TOKEN}
AISOC_ACTIONS_SERVICE_TOKEN: ${AISOC_ACTIONS_SERVICE_TOKEN:?set AISOC_ACTIONS_SERVICE_TOKEN}
# The same value the api service declares above. Eight modules here
# present it; the API verifies it without falling back.
AISOC_AGENTS_SERVICE_TOKEN: ${AISOC_AGENTS_SERVICE_TOKEN:?set AISOC_AGENTS_SERVICE_TOKEN}
NEO4J_PASSWORD: ${NEO4J_PASSWORD:?set NEO4J_PASSWORD}
actions:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
environment:
ENVIRONMENT: production
AISOC_DEV_MODE: 0
# Without this the service answers 503 on every mutating route, which is
# the intended failure introduced in v12.0.0 — it previously served
# isolate_host and run_script to anything that could reach the port.
AISOC_ACTIONS_SERVICE_TOKEN: ${AISOC_ACTIONS_SERVICE_TOKEN:?set AISOC_ACTIONS_SERVICE_TOKEN}
connectors:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
environment:
ENVIRONMENT: production
AISOC_DEV_MODE: 0
AISOC_CREDENTIAL_KEY: ${AISOC_CREDENTIAL_KEY:?set AISOC_CREDENTIAL_KEY}
AISOC_SERVICE_TOKEN: ${AISOC_SERVICE_TOKEN:?set AISOC_SERVICE_TOKEN}
SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY}
threatintel:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
environment:
ENVIRONMENT: production
AISOC_DEV_MODE: 0
NEO4J_PASSWORD: ${NEO4J_PASSWORD:?set NEO4J_PASSWORD}
realtime:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
environment:
# Unset, the edge rejects every connection and /internal/* answers 503.
# Both used to fall back: the ticket verifier to a constant committed to
# this repository, and the internal guard to "authorized".
AISOC_REALTIME_JWT_SECRET: ${AISOC_REALTIME_JWT_SECRET:?set AISOC_REALTIME_JWT_SECRET — run `make env` to generate it}
REALTIME_INTERNAL_TOKEN: ${REALTIME_INTERNAL_TOKEN:?set REALTIME_INTERNAL_TOKEN — run `make env` to generate it}
litellm:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
ollama:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
# Declared here only to take the preflight dependency. It is the one service
# the base file defines that this overlay otherwise has nothing to say about,
# and a single service that starts ahead of the credential check is the hole
# the check exists to close.
ollama-pull:
depends_on:
preflight-secrets:
condition: service_completed_successfully
# The MCP server (depth 9.1), a `--profile mcp` opt-in that is off by
# default. It publishes 8096 in the base file for local clients; in
# production it is unpublished like every other upstream, because the
# console does not proxy it and an operator who wants an external MCP
# endpoint exposes it deliberately. It authorizes every call from the
# credential in the request (no static key — `check_mcp_over_http.py`
# enforces that), so the credential check still gates its start.
mcp:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
# The console. Demo mode is inlined at build time by Next, so this asserts
# the runtime side only; pull the `latest` tag rather than a `-demo` one.
web:
depends_on:
preflight-secrets:
condition: service_completed_successfully
restart: unless-stopped
environment:
NODE_ENV: production
AISOC_DEMO_MODE: "false"
# ---------------------------------------------------------------------------
# `full`-profile stores. They do not run in a CORE deployment, but the base
# file gives every service a default that points at the published dev
# credential, so `--profile full` would start Neo4j and ClickHouse on a
# password anyone can read. Required here rather than defaulted, and required
# on the services that dial them so the two cannot disagree.
# ---------------------------------------------------------------------------
neo4j:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
environment:
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:?set NEO4J_PASSWORD — required for the full profile}
clickhouse:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
environment:
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?set CLICKHOUSE_PASSWORD — required for the full profile}
opensearch:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
# Kafka UI browses every topic with no authentication of its own. It is
# useful while developing and is not something to run beside production data,
# so it is moved onto a profile nothing here activates: `--profile full` no
# longer starts it. An operator who wants it can still ask for it by name.
kafka-ui:
depends_on:
preflight-secrets:
condition: service_completed_successfully
profiles: !override ["debug-ui"]
ports: !reset []
# `no`, not `unless-stopped`: this is a tool you bring up to look at
# something and shut down again. Restarting it on boot would quietly leave
# an unauthenticated topic browser running next to production data.
restart: "no"
# ---------------------------------------------------------------------------
# HARDENING.md section 2 — transport and network surface.
#
# Profiled services that bind a host port in the development file. The
# console and the ingest endpoint are the only things that need to be
# reachable: apps/web/next.config.js proxies every upstream server-side, so
# a browser only ever talks to `web`. Reach these with `docker compose exec`
# for debugging, or publish one deliberately in a local override.
# ---------------------------------------------------------------------------
alertmanager:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
enrichment:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
honeytokens:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
osquery-tls:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
otel-collector:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
prometheus:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
purple-team:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
slack-bot:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
teams-bot:
depends_on:
preflight-secrets:
condition: service_completed_successfully
# Same treatment as its sibling: the base file publishes a port for
# local work, and in production only the console and ingest are
# reachable from the host. `!reset` rather than an empty list, because
# a plain override appends.
ports: !reset []
tempo:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
ueba:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
# HARDENING.md section 5 — observability and audit.
#
# Grafana defaults to admin/admin in the development file. Required here, so
# `--profile monitoring` cannot start a dashboard over production telemetry
# on a password printed in its own documentation.
grafana:
depends_on:
preflight-secrets:
condition: service_completed_successfully
ports: !reset []
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD — required for the monitoring profile}