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

7.2 KiB

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

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.