Files
clean-architecture-backend-…/docs/mongodb/security-observability.md
T
DongHyeonkaandClaude Opus 5 d57d2f62a0 feat(mongodb): implement the MongoDB document persistence platform
Implements the mongodb-superpowers-package design: Stable Tasks 1-50 and
Advanced Tasks 1-15.

The design assumes 19 Stable + 12 Advanced Gradle projects under
modules/mongodb*. This repository's fail-closed registry declares exactly 19
leaf identities, so those modules become package boundaries inside the
registered leaf :adapter:outbound:persistence-mongo, with the design's module
dependency table enforced by ten ArchUnit rules. The mapping and every
deviation are recorded in docs/mongodb/repository-adaptation.md.

Contract highlights, all enforced by tests rather than convention:

- Transaction body retry and commit retry are separate loops. A new session per
  body attempt; commit-only retry on an unknown commit. The body is never
  replayed after a commit ambiguity, so a failover cannot become a duplicate.
- MongoExecutionOutcome keeps both ambiguous outcomes distinct from success and
  failure, and MongoFailureContext records only the design-permitted fields.
- Failure classification reads server error labels before numeric codes.
- BSON representations come from a pinned manifest, never a library default,
  and a golden type-signature gate fails on any drift.
- Index and validator changes go through the manifest and the admin plane;
  metadata ownership gates every drop.
- Every Advanced capability refuses construction unless its flag is enabled.

Verified against real servers, not only unit tests. Running the lanes for the
first time exposed four defects that a green `check` had hidden:

- Four release lanes passed while executing zero tests; the gate now counts
  executed tests per lane and fails on zero.
- The "single replica set" fixture was a standalone, because Testcontainers 2.x
  needs withReplicaSet(); its test only asserted a connection string.
- The three-node fixture was three independent clusters, so no election could
  occur, and awaitNewPrimary() compared against the post-stop primary.
- The migration lease checked modifiedCount, so a same-millisecond refresh read
  as a lost lease.

scripts/verify-mongodb-platform.sh now reports:
  9 lanes, 0 skipped, 0 failed, every evidence category produced.

scripts/verify-mongodb-advanced.sh reports NOT PROMOTABLE: actual-topology
evidence (real sharded cluster, real KMS, real target deployment) is
unobtainable here, so it is named rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:41:00 +09:00

148 lines
7.2 KiB
Markdown

# Security and Observability
Design §26–§28, decision D-05. The application plane and the admin plane are different credentials on
different clients, and telemetry never becomes an exfiltration path.
## 1. Roles
`MongoPrincipalRole` — one credential per role, least privilege:
| Role | Grants |
|---|---|
| `APP_READ` | `find` on allowlisted collections |
| `APP_WRITE` | `insert`, `update`, `delete` on allowlisted collections |
| `CHANGE_STREAM` | `changeStream`, `find` |
| `MIGRATION` | index and validator management on the target collections |
| `SEARCH_ADMIN` | search index management |
| `SHARD_ADMIN` | shard key operations |
| `ENCRYPTION_ADMIN` | key vault access |
| `DBA` | the human plane; never used by an application |
`MongoSecurityProfileValidator` checks the profile at startup. `forbiddenPrivilegesHeld()` names the
privileges the profile holds and must not — the validator reports *which* one, because "your
credential is over-privileged" without a name is an unactionable finding.
The privileges that must never appear on an application credential: `dropDatabase`,
`dropCollection`, `shutdown`, `killop`, `root`, `__system`, `dbOwner`, `userAdminAnyDatabase`.
## 2. Credentials are references, not values
`MongoCredentialReference` holds a `secret://…` reference plus the role. The reference is resolved at
connection time by the secret provider; the password is never a property value, a log field, or a
constructor argument that could end up in a stack trace.
`MongoCredentialRotationPolicy` states the rotation contract: overlapping validity, a drain window,
and a rotation that never requires a restart. `MongoClientGenerationRegistry` implements the swap —
a new `MongoClientGeneration` starts serving new operations while the previous generation is
`markDraining()` until its in-flight operations finish. Killing the old client immediately fails every
in-flight request, which is why rotation without generations is an outage.
Rotation is a failover scenario in the release gate (`MongoFailoverScenario.CREDENTIAL_ROTATION`),
not a runbook step people hope works.
## 3. TLS and connection policy
`MongoSecurityProfile.production(...)` requires TLS and refuses `tlsAllowInvalidCertificates` /
`tlsAllowInvalidHostnames`. `MongoSecurityProfile.local(...)` exists so a developer does not have to
weaken the production factory to get a container to connect; the startup validator refuses a local
profile on a production runtime profile.
## 4. Admin plane (D4)
`MongoAdminGateway` is the only path to `MongoAdminOperation`, and it runs on the D4 client with the
DBA-scoped credential — not the application's.
- `MongoAdminAuthorization` checks the caller's role against the operation.
- `MongoAdminRuntimeGuard` refuses high-risk operations (`highRisk()`) unless the runtime profile
explicitly permits them; a `dropCollection` reachable from a running application is a data-loss
vector regardless of how well-reviewed the calling code is.
- `MongoAdminAuditRecord` records who ran what, when and against which collection profile — before
execution, so a failed attempt is recorded too.
The native capability gateway (D3) refuses any admin-category command, so there is no path from the
application plane into the admin plane.
## 5. Observability tags
`MongoObservationConvention` allowlists exactly eight tag names:
```
mongoProfile, databaseProfile, collectionProfile, operationName,
operationType, result, failureCategory, consistencyProfile
```
and explicitly forbids:
```
documentId, rawTenantId, tenantId, dynamicCollectionName, queryParameter,
query, fullBson, resumeToken, shardKeyValue, plaintextPII, credential
```
Two reasons, and both matter. Cardinality: a tag whose values are document ids produces one time
series per document, which is how a metrics backend falls over. Confidentiality: a metric label is
stored, shipped and retained by systems with a different access model than the database.
`requireAllowed(tagName)` throws on anything outside the list, so a new tag is a deliberate change to
the convention rather than a line in a service.
`MicrometerMongoOperationObserver` implements the `MongoOperationObserver` port;
`NoOpMongoOperationObserver` is the default so observation is opt-in and never a hard dependency.
## 6. Driver-native listeners
`MongoDriverObservabilityConfiguration` registers three driver listeners, because they answer
questions the application-level timer cannot:
| Listener | Answers |
|---|---|
| `MongoCommandObservationListener` | How long did the *server* take, versus how long the caller waited? |
| `MongoPoolObservationListener` | Was the wait time connection checkout rather than query execution? |
| `MongoSdamObservationListener` | Did the topology change — an election, a node removed — during the window? |
Without pool and SDAM events, every failover looks like "the database got slow", and the difference
between "we need a bigger pool" and "we lost a primary" is invisible.
## 7. Command redaction
`MongoObservationRedactor.describe(commandName)`:
- Authentication and user-management commands (`authenticate`, `saslStart`, `saslContinue`,
`getnonce`, `createUser`, `updateUser`, `copydb*`) render as `<redacted>` — their arguments carry
credentials and key material.
- Structural commands (`ping`, `hello`, `buildInfo`, `listCollections`, `listIndexes`, `collStats`)
render by name; their arguments are not data-bearing.
- Everything else renders as `name(...)`: you get the command, never the filter or the document.
`isAlwaysRedacted(...)` is the assertion hook so a test can prove no logging path can render an auth
command's arguments.
## 8. Startup validation
`MongoStartupValidator` runs at context refresh, before the first request:
1. `MongoTopologyProbe` reports the actual `MongoTopology`.
2. Each declared `MongoTopologyRequirement` is checked against it — a transaction, causal-session or
change-stream requirement fails closed on `STANDALONE`.
3. `MongoSecurityProfileValidator` checks credentials and TLS.
4. `MongoCapabilitySupport` checks declared capabilities against the server version, with
`MongoSupportLevel` distinguishing `STABLE` / `ADVANCED` / `EXPERIMENTAL` / `UNSUPPORTED`.
5. `MongoPlatformHealthIndicator` reports the outcome for the readiness probe.
A misconfiguration found at startup costs a failed deploy. The same misconfiguration found at runtime
costs an incident, and the failing operation is rarely the one that reveals the cause.
## 9. How the security lane proves any of this
```bash
cd src
./gradlew :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest --console=plain
```
The lane runs against `MongoAuthenticatedReplicaSetContainer`, which starts mongod with `--auth` and a
generated keyfile. That detail is the whole lane: Testcontainers' `MongoDBContainer` starts mongod
*without* `--auth`, so users created on it all have every privilege and a least-privilege assertion
passes no matter how wrong the roles are. A security test that cannot fail is not a security test.
What the lane asserts is the refusal: the `read` role's insert is rejected, and the application role's
`dropDatabase` is rejected. Then it checks that `MongoSecurityProfileValidator` names the same
privilege the server just refused.