diff --git a/DOCKER.md b/DOCKER.md index 575a3354..27eb20e1 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -234,6 +234,14 @@ The detailed explanation for the environment variables can be found on the [GitH | `CB_MCP_OAUTH_MCP_BASE_URL` | Public base URL of this server. When set, publishes RFC 9728 Protected Resource Metadata for PRM-aware clients | None | | `CB_MCP_OAUTH_SCOPE_READ_LABEL` | Override the OAuth scope label treated as 'read' access (advertised in PRM and matched against the token `scope`/`scp` claim). Use when your IdP can't emit the canonical form | `couchbase-mcp:read` | | `CB_MCP_OAUTH_SCOPE_WRITE_LABEL` | Override the OAuth scope label treated as 'write' access; same semantics as the read label | `couchbase-mcp:write` | +| `CB_MCP_AUDIT_LOG_ENABLED` | Enable audit logging — a separate sink from the `CB_MCP_LOG_*` operational logs, with a stable schema and its own retention (see [Auditing](#auditing)) | `false` | +| `CB_MCP_AUDIT_LOG_SINKS` | Comma-separated audit destinations: `console`, `file`, or both. `console` writes to **stderr** — never stdout, which carries the JSON-RPC protocol on stdio | `console` | +| `CB_MCP_AUDIT_LOG_FILE_PATH` | Path to the audit log file. Required when `file` is in the sinks; the hostname and process id are inserted before the extension (`audit.log` → `audit...log`), so containers sharing a volume never share a file. Mount a volume at this path to keep records after the container stops | None | +| `CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB` | Maximum size **in MB** the live audit file may reach before it rotates. `0` turns size-based rotation **off** | `10` (10 MB) | +| `CB_MCP_AUDIT_LOG_ROTATION_INTERVAL` | Age the live audit file may reach before it rotates, as `` where the unit is `d` (24 hours) or `w` (7 days) — e.g. `1d`, `30d`, `8w`. `0` turns interval-based rotation **off** | `1d` | +| `CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS` | Rotated backups retained per audit file, excluding the live file; they are gzipped. Bounds one file, not the directory: each container start creates a new file with its own backups | `10` | +| `CB_MCP_AUDIT_LOG_TOOL_ARGS` | Record tool argument values. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | +| `CB_MCP_AUDIT_LOG_DISABLED_EVENTS` | Audit events to suppress: comma-separated numeric event ids (e.g. `61490,61491`), or a file with one id per line. Only filterable events can be suppressed | None | | `EMBEDDING_PROVIDER` | Embedding provider for `run_vector_query` / `run_vector_search`: one of `couchbase`, `openai`, `cohere`, `voyage`, `bedrock`. Unset disables both tools' embedding step until configured. | None | | `EMBEDDING_MODEL` | Model name/ID for the configured embedding provider | None | | `EMBEDDING_API_KEY` | API key for the configured provider. Not used by `bedrock` (uses the AWS credential chain / `EMBEDDING_AWS_*` instead) | None | @@ -420,6 +428,36 @@ The server logs to `stderr` by default. Logging is configured with the `CB_MCP_L For more details, see the [documentation](https://docs.couchbase.com/mcp-server/configuration/logging.html). +### Auditing + +Audit logging records **who did what, and whether it was allowed** — a different question from the operational logs, which record what went wrong. It is off by default and enabled with the `CB_MCP_AUDIT_*` variables in the [Environment Variables](#environment-variables) table. + +Records are JSON Lines, one immutable record per line, in the same field vocabulary as Couchbase Server and Sync Gateway. + +- **`CB_MCP_AUDIT_LOG_ENABLED` / `CB_MCP_AUDIT_LOG_SINKS` / `CB_MCP_AUDIT_LOG_FILE_PATH`** — enabling auditing alone writes to the console (stderr). A container that keeps records needs `CB_MCP_AUDIT_LOG_SINKS=file` and a path. Selecting the file sink without a path leaves the server running, with the console sink if one was also selected, and an error in the log. +- **Mount a volume at the audit path.** The audit file is written inside the container like any other file, so without a volume the records are destroyed when the container is removed — which defeats the purpose of keeping them. This matters more than it does for logs: the records are meant to outlive the process. +- **One file per container.** The container's hostname and process id are inserted before the extension (`audit.log` → `audit.3f2a91c4b7de.1.log`). The hostname is what makes this safe: this image's `ENTRYPOINT` is exec form, so the server is **PID 1 in every container**, and a name built from the pid alone would give every container sharing a volume the same `audit.1.log` — interleaved lines and rotation races that silently destroy records. Docker assigns each container a distinct hostname by default. If you override `--hostname` to the same value on two containers **and** share one volume between them, give each its own subdirectory instead. Consolidating the files is the operator's job. +- **Retention is per file, not per deployment.** `CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS` bounds the backups of *one* audit file. Each new container is a new hostname and so a new file with its own backups, and nothing prunes the files of containers that have exited. On a long-lived mounted volume, total usage grows with the number of container starts — size it accordingly, or sweep old `audit.*.log*` files yourself. +- **Rotation and compression.** Size and age are independent triggers and either can be switched off with `0`: `CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB` caps the live file's size, `CB_MCP_AUDIT_LOG_ROTATION_INTERVAL` caps its age, and whichever falls due first rotates it. Rotated files are named with the UTC rotation time and **gzipped** (`audit.3f2a91c4b7de.1.log.20261006T091422481503Z.gz`), which is what keeps a months-long retention window affordable on a mounted volume; the live file stays plain text so `docker exec ... tail -f` still works. +- **Filtering** — `CB_MCP_AUDIT_LOG_DISABLED_EVENTS` turns read noise down, taking numeric event ids (e.g. `61490,61491`). Write and security events cannot be disabled; an attempt to do so is refused with a warning. +- **`CB_MCP_AUDIT_LOG_TOOL_ARGS`** — off by default, and warns at startup when enabled. There is no redaction in this release, so arguments are written verbatim, including full document bodies. +- **Fail-open** — if the audit file cannot be written, the server keeps serving and records are dropped and counted. `get_server_configuration_status` reports the live `written` / `dropped` / `write_errors` counters. + +Auditing applies to **both** servers. Each has its own Tier-2 event block, so an id names the server that emitted it: operational uses `0xF000` (61440), `operational-insights` `0x10000` (65536). The two share the core block `0xE000` (57344) for lifecycle, session, authorization and guardrail events. + +```bash +docker run --rm \ + -e CB_CONNECTION_STRING=couchbases://your-cluster \ + -e CB_USERNAME=user -e CB_PASSWORD=password \ + -e CB_MCP_AUDIT_LOG_ENABLED=true \ + -e CB_MCP_AUDIT_LOG_SINKS=file \ + -e CB_MCP_AUDIT_LOG_FILE_PATH=/audit/audit.log \ + -e CB_MCP_AUDIT_LOG_ROTATION_INTERVAL=1d \ + -e CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS=90 \ + -v "$(pwd)/audit:/audit" \ + couchbase/mcp-server-couchbase:latest +``` + ### OAuth 2.1 Authorization When running with `CB_MCP_TRANSPORT=http`, the server can act as an **OAuth 2.1 resource server**: it validates incoming bearer JWTs against your identity provider's JWKS. It is provider-agnostic (any OAuth 2.1 / OIDC provider that publishes a JWKS — Auth0, Okta, Keycloak, AWS Cognito, Microsoft Entra, etc.) and does **not** issue tokens or manage users. OAuth settings are ignored on `stdio`. diff --git a/README.md b/README.md index 75f7c64f..a7424a5e 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,14 @@ The server can be configured using environment variables or command line argumen | `CB_MCP_OAUTH_MCP_BASE_URL` | `--oauth-mcp-base-url` | Public base URL of this server. When set, publishes RFC 9728 Protected Resource Metadata so PRM-aware clients can discover the IdP | None | | `CB_MCP_OAUTH_SCOPE_READ_LABEL` | `--oauth-scope-read-label` | Override the OAuth scope label treated as 'read' access (advertised in PRM and matched against the token's `scope`/`scp` claim). Use when your IdP can't emit the canonical form | `couchbase-mcp:read` | | `CB_MCP_OAUTH_SCOPE_WRITE_LABEL` | `--oauth-scope-write-label` | Override the OAuth scope label treated as 'write' access; same semantics as the read label | `couchbase-mcp:write` | +| `CB_MCP_AUDIT_LOG_ENABLED` | `--audit-log-enabled` | Enable audit logging — a separate sink from the `CB_MCP_LOG_*` operational logs, with a stable schema and its own retention (see [Auditing](#auditing)) | `false` | +| `CB_MCP_AUDIT_LOG_SINKS` | `--audit-log-sinks` | Comma-separated audit destinations: `console`, `file`, or both. `console` writes to **stderr** — never stdout, which carries the JSON-RPC protocol on stdio | `console` | +| `CB_MCP_AUDIT_LOG_FILE_PATH` | `--audit-log-file-path` | Path to the audit log file. Required when `file` is in the sinks; the hostname and process id are inserted before the extension (`audit.log` → `audit...log`) | None | +| `CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB` | `--audit-log-rotation-max-size-mb` | Maximum size **in MB** the live audit file may reach before it rotates. `0` turns size-based rotation **off** | `10` (10 MB) | +| `CB_MCP_AUDIT_LOG_ROTATION_INTERVAL` | `--audit-log-rotation-interval` | Age the live audit file may reach before it rotates, as `` where the unit is `d` (24 hours) or `w` (7 days) — e.g. `1d`, `30d`, `8w`. `0` turns interval-based rotation **off** | `1d` | +| `CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS` | `--audit-log-retention-max-backups` | Rotated backups retained **per audit file**, excluding the live file. Backups are gzipped. `0` keeps none. This bounds one file, not the directory — see [Auditing](#auditing) | `10` | +| `CB_MCP_AUDIT_LOG_TOOL_ARGS` | `--audit-log-tool-args` | Record tool argument values in the audit log. **Off by default** — there is no redaction in this release, so enabling it writes arguments verbatim, including full document bodies | `false` | +| `CB_MCP_AUDIT_LOG_DISABLED_EVENTS` | `--audit-log-disabled-events` | Audit events to suppress: comma-separated numeric event ids (e.g. `61490,61491`), or a file with one id per line. Only filterable events can be suppressed | None | | `EMBEDDING_PROVIDER` | `--embedding-provider` | Embedding provider for `run_vector_query` / `run_vector_search`: one of `couchbase`, `openai`, `cohere`, `voyage`, `bedrock`. Unset disables both tools' embedding step until configured — see [Embedding Provider Configuration](#embedding-provider-configuration) | None | | `EMBEDDING_MODEL` | `--embedding-model` | Model name/ID for the configured embedding provider | None | | `EMBEDDING_API_KEY` | `--embedding-api-key` | API key for the configured provider. Not used by `bedrock` (uses the AWS credential chain / `EMBEDDING_AWS_*` instead) | None | @@ -542,6 +550,65 @@ uvx couchbase-mcp-server --log-level=debug --log-sinks=file \ For more details, see the [documentation](https://docs.couchbase.com/mcp-server/configuration/logging.html). +### Auditing + +Audit logging answers a different question from the operational logs. Logs tell you **what went wrong**; the audit log tells you **who did what, and whether it was allowed**. It exists because the MCP server is the only layer that can answer either question about an agent: every request reaches the cluster as the single `CB_USERNAME` the server connects with, so Couchbase Server's own audit log cannot tell two callers apart — and decisions such as a scope denial, a read-only block or a declined confirmation never reach the cluster at all. + +Auditing is **off by default**. `CB_MCP_AUDIT_LOG_ENABLED=true` turns it on, writing to the console (stderr) unless `CB_MCP_AUDIT_LOG_SINKS` says otherwise. A deployment that keeps records sets `CB_MCP_AUDIT_LOG_SINKS=file` and a path in `CB_MCP_AUDIT_LOG_FILE_PATH`; selecting the file sink without a path leaves the server running — with the console sink if one was also selected, otherwise with auditing off — and an error in the log. + +Records are **JSON Lines** — one immutable record per line — using the same field vocabulary as Couchbase Server and Sync Gateway (`id`, `name`, `description`, `timestamp`, `real_userid`, `server`, `cid`, `outcome`, `reason`), so an existing audit pipeline gets one format across the estate. + +```json +{"id":61522,"name":"document write","description":"A document write operation was requested through a tool","timestamp":"2026-09-29T09:14:22.418Z","real_userid":{"domain":"oauth","user":"agent-svc-7"},"server":{"version":"1.0.2","host":"mcp-01"},"cid":"9f2c...","outcome":"success","tool_name":"upsert_document_by_id","required_scope":"write","ks":"travel-sample.inventory.airline","cb_userid":"mcp_service"} +``` + +- **Identity** — `real_userid` records the *caller*, in one of three domains: `oauth` (the bearer token's subject), `local` (the OS process owner, on stdio), or `anonymous` (HTTP with OAuth disabled). Enabling auditing on HTTP without OAuth warns at startup, because every record then resolves to `anonymous`. +- **Correlation** — `cid` groups every record produced by one request, so an authorization denial, a guardrail refusal and the eventual tool result join without matching on timestamps. There is deliberately **no session identifier**: MCP is moving to a stateless model in which connection identity must not be read as session continuity. For SQL++, the same `cid` is sent as the query's `client_context_id`, which Couchbase Server records as `clientContextId` — an exact join between the two logs. +- **Events** — a fixed catalogue on numbered ID blocks: server lifecycle and configuration, session initialize, token rejected, scope check denied, read-only write blocked, confirmation declined or skipped, and one read/write pair per tool category (cluster, schema, document, query, index, performance, search, config). Adding a tool needs no new ID and no change to a SIEM rule. The full catalogue ships as `descriptor.json` inside the package. +- **Filtering** — `CB_MCP_AUDIT_LOG_DISABLED_EVENTS` turns *read* noise down, taking numeric event ids (the id is the wire contract; event names are prose and may be reworded, so they are not accepted). Write and security events are **not filterable**: an attempt to disable one is refused with a warning, so the record of who changed what cannot be switched off by configuration. +- **Rotation** — two independent triggers, and either may be switched off with `0`. `CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB` rotates the live file when it grows past a size; `CB_MCP_AUDIT_LOG_ROTATION_INTERVAL` rotates it when it grows older than an age (`1d`, `30d`, `1w`, `8w`). Whichever falls due first rotates the file, and with both off the file simply keeps growing. The interval clock is anchored to the live file, not to startup, so a server that was down over the weekend rolls the stale file away rather than appending to it. +- **Retention and compression** — `CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS` keeps that many rotated files beside the live one; `0` keeps none, truncating the live file at each rotation. Backups are named with the UTC rotation time (`audit...log.20261006T091422481503Z.gz`) and **gzipped**, which matters because audit retention is measured in months — JSON Lines compresses roughly ten to one. The live file is deliberately left uncompressed so `tail -f` and `grep` still work during an incident. +- **Per-writer files** — the hostname and process id are inserted before the extension (`audit.log` → `audit...log`), because a stdio deployment runs one server process per client and a shared rotating file would interleave and corrupt records. The hostname matters in containers: the published image's `ENTRYPOINT` is exec form, so the server is PID 1 in every container and a pid-only name would collide across containers sharing a volume. +- **Retention is per file** — the backup count bounds the rotated copies of *one* audit file. Every process, and so every stdio session and every restart, writes a new file with its own backups, and nothing prunes the files of processes that have exited. Total disk use therefore grows with the number of server starts; size the volume accordingly or sweep old `audit.*.log*` files. +- **Fail-open** — if the audit file cannot be written, the server keeps serving and the records are dropped and counted rather than turning an audit outage into a server outage. `get_server_configuration_status` reports the live `written` / `dropped` / `write_errors` counters, and `dropped` is the only way to discover that records were lost. +- **Tool arguments** — recorded only when `CB_MCP_AUDIT_LOG_TOOL_ARGS` is enabled, which warns at startup. There is no redaction in this release, so arguments are written verbatim, including the full document bodies passed to document-write tools. + +Auditing applies to **both** servers. Each has its own Tier-2 event block, so an id names the server that emitted it: operational uses `0xF000` (61440), Operational Insights `0x10000` (65536). The two share the core block `0xE000` (57344) for lifecycle, session, authorization and guardrail events. + +```bash +# Audit to the console (stderr) — no path needed +uvx couchbase-mcp-server --audit-log-enabled=true + +# Audit to a file: daily rotation, 10 gzipped backups, both by default +uvx couchbase-mcp-server --audit-log-enabled=true \ + --audit-log-sinks=file --audit-log-file-path=/var/log/cb-mcp/audit.log + +# 90 days of daily files, with no size limit on any of them +uvx couchbase-mcp-server --audit-log-enabled=true \ + --audit-log-sinks=file --audit-log-file-path=/var/log/cb-mcp/audit.log \ + --audit-log-rotation-max-size-mb=0 \ + --audit-log-rotation-interval=1d \ + --audit-log-retention-max-backups=90 + +# ~100 MB of records, ~20 MB on disk once gzipped +uvx couchbase-mcp-server --audit-log-enabled=true \ + --audit-log-sinks=file --audit-log-file-path=/var/log/cb-mcp/audit.log \ + --audit-log-rotation-max-size-mb=10 \ + --audit-log-rotation-interval=0 \ + --audit-log-retention-max-backups=9 + +# One file, never rotated — keep everything +uvx couchbase-mcp-server --audit-log-enabled=true \ + --audit-log-sinks=file --audit-log-file-path=/var/log/cb-mcp/audit.log \ + --audit-log-rotation-max-size-mb=0 --audit-log-rotation-interval=0 \ + --audit-log-retention-max-backups=0 + +# Turn down read noise, keeping every write and security event +uvx couchbase-mcp-server --audit-log-enabled=true \ + --audit-log-sinks=file --audit-log-file-path=/var/log/cb-mcp/audit.log \ + --audit-log-disabled-events="61490,61491" # document read, query read +``` + ### Client Specific Configuration
diff --git a/server.json b/server.json index 907570fa..9c4fe46a 100644 --- a/server.json +++ b/server.json @@ -248,6 +248,20 @@ "format": "string", "isSecret": false }, + { + "name": "CB_MCP_AUDIT_LOG_ENABLED", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_SINKS", + "description": "Comma-separated audit destinations: console, file, or both. 'console' writes to stderr, never stdout, because stdout carries the JSON-RPC protocol under the stdio transport. Default is console.", + "isRequired": false, + "format": "string", + "isSecret": false + }, { "name": "EMBEDDING_PROVIDER", "description": "Embedding provider for run_vector_query / run_vector_search: one of couchbase, openai, cohere, voyage, bedrock. Unset disables both tools' embedding step until configured.", @@ -255,6 +269,27 @@ "format": "string", "isSecret": false }, + { + "name": "CB_MCP_AUDIT_LOG_FILE_PATH", + "description": "Path to the audit log file, required when 'file' is in CB_MCP_AUDIT_LOG_SINKS. Each server process writes its own file with the host and process id inserted before the extension (audit.log becomes audit.myhost.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with the file sink dropped and an error recorded.", + "isRequired": false, + "format": "filepath", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB", + "description": "Maximum size in MB the live audit file may reach before it rotates. 0 turns size-based rotation off. Default is 10 MB.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ROTATION_INTERVAL", + "description": "Age the live audit file may reach before it rotates, as where the unit is 'd' (24 hours) or 'w' (7 days) - for example 1d, 30d, 1w, 8w. 0 turns interval-based rotation off. Size and interval are independent: whichever falls due first rotates the file. Default is 1d.", + "isRequired": false, + "format": "string", + "isSecret": false + }, { "name": "EMBEDDING_MODEL", "description": "Model name/ID for the configured embedding provider.", @@ -262,6 +297,27 @@ "format": "string", "isSecret": false }, + { + "name": "CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS", + "description": "Number of rotated audit files retained, excluding the live file. Rotated files are gzipped and named with the UTC rotation time. Set to 0 to keep no backups: the live file is truncated at each rotation. Default is 10.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_TOOL_ARGS", + "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_DISABLED_EVENTS", + "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "isRequired": false, + "format": "string", + "isSecret": false + }, { "name": "EMBEDDING_API_KEY", "description": "API key for the configured embedding provider. Not used by bedrock (uses the AWS credential chain / EMBEDDING_AWS_* instead).", @@ -625,6 +681,70 @@ "isRequired": false, "format": "string", "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-enabled", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-sinks", + "description": "Comma-separated audit destinations: console, file, or both. 'console' writes to stderr, never stdout, because stdout carries the JSON-RPC protocol under the stdio transport. Default is console.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-file-path", + "description": "Path to the audit log file, required when 'file' is in CB_MCP_AUDIT_LOG_SINKS. Each server process writes its own file with the host and process id inserted before the extension (audit.log becomes audit.myhost.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with the file sink dropped and an error recorded.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-rotation-max-size-mb", + "description": "Maximum size in MB the live audit file may reach before it rotates. 0 turns size-based rotation off. Default is 10 MB.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-rotation-interval", + "description": "Age the live audit file may reach before it rotates, as where the unit is 'd' (24 hours) or 'w' (7 days) - for example 1d, 30d, 1w, 8w. 0 turns interval-based rotation off. Size and interval are independent: whichever falls due first rotates the file. Default is 1d.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-retention-max-backups", + "description": "Number of rotated audit files retained, excluding the live file. Rotated files are gzipped and named with the UTC rotation time. Set to 0 to keep no backups: the live file is truncated at each rotation. Default is 10.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-tool-args", + "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-disabled-events", + "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "isRequired": false, + "format": "boolean", + "isSecret": false } ] }, @@ -825,6 +945,62 @@ "format": "number", "isSecret": false }, + { + "name": "CB_MCP_AUDIT_LOG_ENABLED", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false. This server emits audit records under its own event block, 0x10000 (65536), so an event id names the server that produced it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_SINKS", + "description": "Comma-separated audit destinations: console, file, or both. 'console' writes to stderr, never stdout, because stdout carries the JSON-RPC protocol under the stdio transport. Default is console.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_FILE_PATH", + "description": "Path to the audit log file, required when 'file' is in CB_MCP_AUDIT_LOG_SINKS. Each server process writes its own file with the host and process id inserted before the extension (audit.log becomes audit.myhost.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with the file sink dropped and an error recorded.", + "isRequired": false, + "format": "filepath", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB", + "description": "Maximum size in MB the live audit file may reach before it rotates. 0 turns size-based rotation off. Default is 10 MB.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ROTATION_INTERVAL", + "description": "Age the live audit file may reach before it rotates, as where the unit is 'd' (24 hours) or 'w' (7 days) - for example 1d, 30d, 1w, 8w. 0 turns interval-based rotation off. Size and interval are independent: whichever falls due first rotates the file. Default is 1d.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS", + "description": "Number of rotated audit files retained, excluding the live file. Rotated files are gzipped and named with the UTC rotation time. Set to 0 to keep no backups: the live file is truncated at each rotation. Default is 10.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_TOOL_ARGS", + "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_DISABLED_EVENTS", + "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "isRequired": false, + "format": "string", + "isSecret": false + }, { "name": "CB_MCP_OAUTH_JWT_JWKS_URI", "description": "JWKS endpoint of the identity provider used to verify bearer JWT signatures. Required to enable OAuth, along with CB_MCP_OAUTH_JWT_ISSUER and CB_MCP_OAUTH_JWT_AUDIENCE. Only honored when CB_MCP_TRANSPORT=http.", @@ -1154,6 +1330,70 @@ "isRequired": false, "format": "string", "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-enabled", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false. This server emits audit records under its own event block, 0x10000 (65536), so an event id names the server that produced it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-sinks", + "description": "Comma-separated audit destinations: console, file, or both. 'console' writes to stderr, never stdout, because stdout carries the JSON-RPC protocol under the stdio transport. Default is console.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-file-path", + "description": "Path to the audit log file, required when 'file' is in CB_MCP_AUDIT_LOG_SINKS. Each server process writes its own file with the host and process id inserted before the extension (audit.log becomes audit.myhost.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with the file sink dropped and an error recorded.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-rotation-max-size-mb", + "description": "Maximum size in MB the live audit file may reach before it rotates. 0 turns size-based rotation off. Default is 10 MB.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-rotation-interval", + "description": "Age the live audit file may reach before it rotates, as where the unit is 'd' (24 hours) or 'w' (7 days) - for example 1d, 30d, 1w, 8w. 0 turns interval-based rotation off. Size and interval are independent: whichever falls due first rotates the file. Default is 1d.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-retention-max-backups", + "description": "Number of rotated audit files retained, excluding the live file. Rotated files are gzipped and named with the UTC rotation time. Set to 0 to keep no backups: the live file is truncated at each rotation. Default is 10.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-tool-args", + "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "type": "named", + "name": "--audit-log-disabled-events", + "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "isRequired": false, + "format": "boolean", + "isSecret": false } ] }, @@ -1404,6 +1644,20 @@ "format": "string", "isSecret": false }, + { + "name": "CB_MCP_AUDIT_LOG_ENABLED", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_SINKS", + "description": "Comma-separated audit destinations: console, file, or both. 'console' writes to stderr, never stdout, because stdout carries the JSON-RPC protocol under the stdio transport. Default is console.", + "isRequired": false, + "format": "string", + "isSecret": false + }, { "name": "EMBEDDING_PROVIDER", "description": "Embedding provider for run_vector_query / run_vector_search: one of couchbase, openai, cohere, voyage, bedrock. Unset disables both tools' embedding step until configured.", @@ -1411,6 +1665,27 @@ "format": "string", "isSecret": false }, + { + "name": "CB_MCP_AUDIT_LOG_FILE_PATH", + "description": "Path to the audit log file, required when 'file' is in CB_MCP_AUDIT_LOG_SINKS. Each server process writes its own file with the host and process id inserted before the extension (audit.log becomes audit.myhost.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with the file sink dropped and an error recorded.", + "isRequired": false, + "format": "filepath", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB", + "description": "Maximum size in MB the live audit file may reach before it rotates. 0 turns size-based rotation off. Default is 10 MB.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ROTATION_INTERVAL", + "description": "Age the live audit file may reach before it rotates, as where the unit is 'd' (24 hours) or 'w' (7 days) - for example 1d, 30d, 1w, 8w. 0 turns interval-based rotation off. Size and interval are independent: whichever falls due first rotates the file. Default is 1d.", + "isRequired": false, + "format": "string", + "isSecret": false + }, { "name": "EMBEDDING_MODEL", "description": "Model name/ID for the configured embedding provider.", @@ -1418,6 +1693,27 @@ "format": "string", "isSecret": false }, + { + "name": "CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS", + "description": "Number of rotated audit files retained, excluding the live file. Rotated files are gzipped and named with the UTC rotation time. Set to 0 to keep no backups: the live file is truncated at each rotation. Default is 10.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_TOOL_ARGS", + "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_DISABLED_EVENTS", + "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "isRequired": false, + "format": "string", + "isSecret": false + }, { "name": "EMBEDDING_API_KEY", "description": "API key for the configured embedding provider. Not used by bedrock (uses the AWS credential chain / EMBEDDING_AWS_* instead).", @@ -1660,6 +1956,62 @@ "format": "number", "isSecret": false }, + { + "name": "CB_MCP_AUDIT_LOG_ENABLED", + "description": "Enable audit logging. Audit output is a separate sink from the CB_MCP_LOG_* operational logs, with a stable schema, its own retention, and events that cannot be filtered out. Default is false. This server emits audit records under its own event block, 0x10000 (65536), so an event id names the server that produced it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_SINKS", + "description": "Comma-separated audit destinations: console, file, or both. 'console' writes to stderr, never stdout, because stdout carries the JSON-RPC protocol under the stdio transport. Default is console.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_FILE_PATH", + "description": "Path to the audit log file, required when 'file' is in CB_MCP_AUDIT_LOG_SINKS. Each server process writes its own file with the host and process id inserted before the extension (audit.log becomes audit.myhost.1234.log), because a stdio deployment runs one process per client. If the path is missing the server still starts, with the file sink dropped and an error recorded.", + "isRequired": false, + "format": "filepath", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB", + "description": "Maximum size in MB the live audit file may reach before it rotates. 0 turns size-based rotation off. Default is 10 MB.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_ROTATION_INTERVAL", + "description": "Age the live audit file may reach before it rotates, as where the unit is 'd' (24 hours) or 'w' (7 days) - for example 1d, 30d, 1w, 8w. 0 turns interval-based rotation off. Size and interval are independent: whichever falls due first rotates the file. Default is 1d.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS", + "description": "Number of rotated audit files retained, excluding the live file. Rotated files are gzipped and named with the UTC rotation time. Set to 0 to keep no backups: the live file is truncated at each rotation. Default is 10.", + "isRequired": false, + "format": "number", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_TOOL_ARGS", + "description": "Record tool argument values in the audit log. Off by default: there is no redaction in this release, so enabling this writes arguments verbatim, including the full document bodies passed to document-write tools. Review the audit file's retention and access controls before enabling it.", + "isRequired": false, + "format": "boolean", + "isSecret": false + }, + { + "name": "CB_MCP_AUDIT_LOG_DISABLED_EVENTS", + "description": "Audit events to suppress, as comma-separated numeric event ids (for example '61490,61491'), or a path to a file with one id per line. Ids are listed in the audit descriptor shipped with the package. Only filterable events can be suppressed: write and security events are always recorded, and an attempt to disable one is refused with a warning.", + "isRequired": false, + "format": "string", + "isSecret": false + }, { "name": "CB_MCP_OAUTH_JWT_JWKS_URI", "description": "JWKS endpoint of the identity provider used to verify bearer JWT signatures. Required to enable OAuth, along with CB_MCP_OAUTH_JWT_ISSUER and CB_MCP_OAUTH_JWT_AUDIENCE. Only honored when CB_MCP_TRANSPORT=http.", diff --git a/src/cb_mcp/audit/__init__.py b/src/cb_mcp/audit/__init__.py new file mode 100644 index 00000000..6fcb1c10 --- /dev/null +++ b/src/cb_mcp/audit/__init__.py @@ -0,0 +1,103 @@ +"""Audit logging for the Couchbase MCP Server. + +The MCP server is the only place that knows *who* a caller is. Every request +reaches the cluster as the single ``CB_USERNAME`` the server connects with, so +Couchbase Server's own ``audit.log`` cannot tell two callers apart; and a whole +class of decisions — scope denials, read-only blocks, declined or skipped +confirmations — happens entirely inside this server and never reaches the +cluster at all. Those records can only be produced here. + +Records are Couchbase-format JSON Lines, matching Couchbase Server and Sync +Gateway: the same field vocabulary, a descriptor-driven event catalogue on +numbered ID blocks, and per-event filtering as configuration rather than code. + +Audit output is a separate sink from the ``CB_MCP_LOG_*`` operational logs. It +guarantees a stable schema, completeness for non-filterable events, and is +sensitive by default. + +Layout: + +* :mod:`~cb_mcp.audit.catalog` — event ids, names, descriptors +* :mod:`~cb_mcp.audit.classification` — ``tool_name -> (category, class)`` +* :mod:`~cb_mcp.audit.config` — CLI/env resolution +* :mod:`~cb_mcp.audit.emitter` — the process-wide logger +* :mod:`~cb_mcp.audit.exceptions` — typed, auditable refusals +* :mod:`~cb_mcp.audit.identity` — ``real_userid`` resolution +* :mod:`~cb_mcp.audit.middleware` — the FastMCP middleware +* :mod:`~cb_mcp.audit.record` — the record and its serialisation +* :mod:`~cb_mcp.audit.sink` — the per-process file writer and the console sink +* :mod:`~cb_mcp.audit.state` — per-request refusal channel +""" + +from .catalog import AuditEvent, build_descriptor +from .classification import ( + TOOL_CLASSIFICATION, + classify_tool, + resolve_tool_call_event, + unclassified_tool_names, +) +from .config import ( + ResolvedAuditConfig, + parse_audit_sinks, + parse_rotation_interval, + resolve_audit_config, +) +from .emitter import AuditLogger, get_audit_logger, init_audit, shutdown_audit +from .exceptions import ( + AuditableRefusalError, + ConfirmationDeclinedError, + ReadOnlyWriteBlockedError, + ScopeDeniedError, + StatementScopeDeniedError, +) +from .identity import resolve_real_userid, warn_on_unauthenticated_http +from .middleware import AuditMiddleware +from .record import ( + OUTCOME_BLOCKED, + OUTCOME_DENIED, + OUTCOME_ERROR, + OUTCOME_SUCCESS, + AuditRecord, +) +from .sink import ( + AuditSink, + AuditSinkProtocol, + CompositeAuditSink, + ConsoleAuditSink, + process_scoped_path, +) + +__all__ = [ + "OUTCOME_BLOCKED", + "OUTCOME_DENIED", + "OUTCOME_ERROR", + "OUTCOME_SUCCESS", + "TOOL_CLASSIFICATION", + "AuditEvent", + "AuditLogger", + "AuditMiddleware", + "AuditRecord", + "AuditSink", + "AuditSinkProtocol", + "AuditableRefusalError", + "CompositeAuditSink", + "ConfirmationDeclinedError", + "ConsoleAuditSink", + "ReadOnlyWriteBlockedError", + "ResolvedAuditConfig", + "ScopeDeniedError", + "StatementScopeDeniedError", + "build_descriptor", + "classify_tool", + "get_audit_logger", + "init_audit", + "parse_audit_sinks", + "parse_rotation_interval", + "process_scoped_path", + "resolve_audit_config", + "resolve_real_userid", + "resolve_tool_call_event", + "shutdown_audit", + "unclassified_tool_names", + "warn_on_unauthenticated_http", +] diff --git a/src/cb_mcp/audit/catalog.py b/src/cb_mcp/audit/catalog.py new file mode 100644 index 00000000..c5739797 --- /dev/null +++ b/src/cb_mcp/audit/catalog.py @@ -0,0 +1,492 @@ +"""Audit event catalogue. + +Two tiers, following the PRD and Couchbase Server / Sync Gateway precedent: + +* **Tier 1 — core block ``0xE000`` (57344).** Lifecycle, session, authorization + and guardrail events. Identical regardless of which service sub-package is + loaded: a denied scope check is the same event whether it gated a KV tool or + a query tool. +* **Tier 2 — one block per service package.** The operational package owns + ``0xF000`` (61440). Within a Tier-2 block the layout is a reusable template: + reads at ``base + 48 + n`` and writes at ``read + 32`` for category index + ``n``, so the same relative offset means the same category in every package. + +The ID space is treated as frozen. Adding a tool requires only a new entry in +:mod:`cb_mcp.audit.classification` — no new event ID, and no change to a SIEM +rule. + +Events defined in code are the source of truth for the server. ``descriptor.json`` +ships the same catalogue for operators and SIEM authors; +``tests/unit/test_audit_catalog.py`` asserts the two never drift. + +Two catalogue entries named in the PRD are deliberately absent: + +* *tool invocation blocked (disabled)* — withheld and disabled tools are never + registered with FastMCP, so the model is never told they exist and there is + no refusal to record. +* *session terminated* — FastMCP 3.x exposes no session-end hook and a stdio + client simply closes the pipe, so the event cannot be emitted reliably. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +# --------------------------------------------------------------------------- +# Block layout +# --------------------------------------------------------------------------- + +#: Tier-1 shared core block. +CORE_BLOCK = 0xE000 # 57344 + +#: Tier-2 block for the operational service package (kv, query, index, ...). +OPERATIONAL_BLOCK = 0xF000 # 61440 + +#: Tier-2 block for the Operational Insights (analytics) service package. +OPERATIONAL_INSIGHTS_BLOCK = 0x10000 # 65536 + +#: ``service package -> Tier-2 block base``. Blocks are 0x1000 apart. Adding a +#: package means an entry here, an entry in :data:`CATEGORY_SLOTS`, a +#: classification table in :mod:`cb_mcp.audit.classification`, and +#: ``audit_package`` on its ``ServerSpec`` — and renumbers nothing that already +#: exists. +#: +#: The AI Data Plane block is deliberately **not** listed: a package with no +#: tools would otherwise look audited to :func:`tool_call_event`, and a block +#: present here but absent from :data:`CATEGORY_SLOTS` is a half-registered +#: package waiting to raise at runtime. Its number is reserved by the constant +#: above instead, which is a comment on paper rather than a promise in code. +SERVICE_PACKAGE_BLOCKS: dict[str, int] = { + "operational": OPERATIONAL_BLOCK, + "operational_insights": OPERATIONAL_INSIGHTS_BLOCK, +} + +#: Relative offset of the first read event inside a Tier-2 block. +READ_OFFSET = 48 + +#: A category's write event sits this far above its read event. +WRITE_DELTA = 32 + +#: Highest slot the intra-block template reserves for categories: reads occupy +#: ``base + 48 + n`` for ``n`` in 0..15, writes ``base + 80 + n``. +MAX_SLOT = 15 + +#: Explicit slot allocation for Tier-2 categories — ``category -> n``, where a +#: read event id is ``base + READ_OFFSET + n``. +#: +#: Deliberately a table keyed by name rather than the position of a name in a +#: list. Positional indexing reads more tidily but means retiring one category +#: silently renumbers every category after it, and **a shipped audit event id +#: can never be renumbered**: a SIEM rule written against the old number would +#: then match a different event. With an explicit table a slot keeps its number +#: for as long as the catalogue exists, whatever happens around it. +#: +#: Add a category by giving it the next free slot (:func:`next_free_slot`). +#: Never change a number already in this table. +#: Every package instantiates the *same* axis, so a slot means the same +#: category in every block and an id decodes to package-by-block and +#: category-by-offset. A package omits the slots it has no tools for rather +#: than renumbering: Operational Insights has no kv or search surface, so those +#: slots simply stay empty in its block and keep their meaning. +CATEGORY_SLOTS: dict[str, dict[str, int]] = { + "operational": { + "cluster": 0, + "schema": 1, + "kv": 2, + "query": 3, + "index": 4, + "performance": 5, + # Contiguous across 0-7, with 8-15 free for new categories. A number + # here is a wire contract: once a category has emitted records, its + # slot can never mean anything else. + "search": 6, + "config": 7, + }, + # Operational Insights. Same axis, its own block: the two servers collide + # on tool names — `get_server_configuration_status`, `create_index`, + # `list_indexes`, `get_collections_in_scope`, `get_schema_for_collection` — + # so a shared table could not represent both, and booking one server's call + # against the other's block would put records under ids that mean a + # different server. + "operational_insights": { + # `cluster` carries no Operational Insights tool today. It is allocated + # anyway because it is the fail-closed category: an unclassified tool + # resolves to (cluster, write), and a package without the slot made + # that resolution raise. The raise was swallowed by the middleware's + # "auditing must never break a call" guard, so the call succeeded and + # *nothing was recorded* -- silence in the one case fail-closed exists + # to make loud. See FAILSAFE_SLOT_REQUIRED below. + "cluster": 0, + "schema": 1, + "query": 3, + "index": 4, + "config": 7, + }, +} + +#: The category every package must allocate, whatever its tool surface. +#: +#: Kept here rather than in :mod:`cb_mcp.audit.classification` because this is +#: the module that owns slot allocation, and the check below has to run where +#: the tables are defined. classification.FAILSAFE_CATEGORY must equal it; a +#: test asserts the two agree. +FAILSAFE_SLOT_REQUIRED = "cluster" + +for _package, _slots in CATEGORY_SLOTS.items(): + if FAILSAFE_SLOT_REQUIRED not in _slots: # pragma: no cover - import guard + raise RuntimeError( + f"Service package {_package!r} does not allocate the fail-closed " + f"category {FAILSAFE_SLOT_REQUIRED!r}. An unclassified tool in that " + "package would raise inside the audit middleware, which swallows the " + "error, and the call would go unrecorded entirely." + ) + +#: Slots withdrawn from a released catalogue — ``package -> {category -> n}``, +#: under the number the category held. +#: +#: Empty, because nothing has been withdrawn from a released catalogue. It +#: exists so the first withdrawal has somewhere to go, and so +#: :func:`next_free_slot` has something to consult. +#: +#: A withdrawn slot may be given to a new category **only** when no record was +#: ever emitted against it. A slot that has emitted must never be reused: +#: reallocating it would make an existing SIEM rule silently match a new, +#: unrelated event. :func:`next_free_slot` never offers a retired slot, so the +#: reuse has to be written into :data:`CATEGORY_SLOTS` by hand, with that fact +#: established. +RETIRED_SLOTS: dict[str, dict[str, int]] = {} + + +def categories_for(package: str) -> tuple[str, ...]: + """Category axis for ``package``'s Tier-2 block, ordered by slot. + + Derived from :data:`CATEGORY_SLOTS` so the two can never disagree. + """ + return tuple( + name + for name, _ in sorted(CATEGORY_SLOTS[package].items(), key=lambda item: item[1]) + ) + + +def category_index(category: str, package: str) -> int: + """Return the slot ``n`` for ``category`` within ``package``'s block.""" + try: + return CATEGORY_SLOTS[package][category] + except KeyError as exc: # pragma: no cover - guarded by classification + raise ValueError( + f"Unknown audit category {category!r} for service package " + f"{package!r}; expected one of {categories_for(package)}." + ) from exc + + +def next_free_slot(package: str) -> int: + """Return the slot a newly added category should take. + + Returns the lowest **never-allocated** slot. Retired slots are never + offered, even the ones :data:`RETIRED_SLOTS` marks as safe to reuse. + + Reuse is permissible when the slot has never emitted a record, but it is + never automatic: it has to be chosen with that fact established, because + handing back a slot that *has* emitted would make an existing SIEM rule + silently match an unrelated event. + + Raises: + ValueError: when the block's category template is exhausted. A new + service package should then take a fresh 0x1000 block rather than + crowding this one. + """ + taken = set(CATEGORY_SLOTS[package].values()) | set( + RETIRED_SLOTS.get(package, {}).values() + ) + for slot in range(MAX_SLOT + 1): + if slot not in taken: + return slot + raise ValueError( + f"All {MAX_SLOT + 1} category slots in the {package!r} block are " + "allocated or retired. A new service package should request its own " + "block; reusing a retired slot is possible but must be decided " + "explicitly — see RETIRED_SLOTS." + ) + + +def tool_call_event_id(category: str, operation_class: str, package: str) -> int: + """Resolve the Tier-2 event ID for a tool call. + + Args: + category: A category allocated in ``package``'s block. + operation_class: ``"read"`` or ``"write"``. + """ + if operation_class not in ("read", "write"): + raise ValueError( + f"operation_class must be 'read' or 'write', got {operation_class!r}." + ) + base = ( + SERVICE_PACKAGE_BLOCKS[package] + + READ_OFFSET + + category_index(category, package) + ) + return base + WRITE_DELTA if operation_class == "write" else base + + +class AuditEvent(Enum): + """Catalogue entry. + + Value is the numeric event ``id``; the remaining members carry the + descriptor metadata that ships to operators. + """ + + # -- Core: lifecycle (57344-57359) ------------------------------------ + SERVER_STARTED = ( + CORE_BLOCK + 0, + "server started", + "MCP server started; carries the static server context", + "lifecycle", + False, + ) + SERVER_STOPPED = ( + CORE_BLOCK + 1, + "server stopped", + "MCP server shut down cleanly", + "lifecycle", + False, + ) + SERVER_CONFIGURATION = ( + CORE_BLOCK + 2, + "server configuration", + ( + "Resolved server configuration recorded at startup, including " + "read-only mode and the withheld and disabled tool sets" + ), + "lifecycle", + False, + ) + + # -- Core: session (57360-57375) -------------------------------------- + SESSION_INITIALIZED = ( + CORE_BLOCK + 16, + "session initialized", + "MCP initialize handshake completed", + "session", + True, + ) + + # -- Core: authorization (57376-57391) -------------------------------- + TOKEN_REJECTED = ( + CORE_BLOCK + 32, + "token rejected", + "Bearer JWT rejected during verification", + "authorization", + False, + ) + SCOPE_CHECK_DENIED = ( + CORE_BLOCK + 33, + "scope check denied", + "A required OAuth scope was not granted by the presented token", + "authorization", + False, + ) + + # -- Core: guardrail (57488-57503) ------------------------------------ + WRITE_BLOCKED_READ_ONLY = ( + CORE_BLOCK + 144, + "write blocked (read-only mode)", + "A SQL++ statement modifying data or structure was refused because " + "CB_MCP_READ_ONLY_MODE is enabled", + "guardrail", + False, + ) + CONFIRMATION_DECLINED = ( + CORE_BLOCK + 146, + "confirmation declined", + "The user rejected an elicitation for a confirmation-required tool", + "guardrail", + False, + ) + CONFIRMATION_SKIPPED = ( + CORE_BLOCK + 147, + "confirmation skipped", + "A confirmation-required tool executed without confirmation because " + "the client does not support elicitation", + "guardrail", + False, + ) + + def __init__( + self, + event_id: int, + event_name: str, + description: str, + category: str, + filterable: bool, + ) -> None: + self.id = event_id + self.event_name = event_name + self.description = description + self.category = category + self.filterable = filterable + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return f"" + + +@dataclass(frozen=True) +class ToolCallEvent: + """A resolved Tier-2 tool-call catalogue entry. + + Built on demand from a category and operation class rather than enumerated, + because the specific tool lives in ``tool_name`` and one ID serves a whole + category. + """ + + id: int + event_name: str + description: str + category: str + operation_class: str + + @property + def filterable(self) -> bool: + """Reads are filterable per category; writes never are. + + An operator can turn read noise down but cannot switch off the record + of who changed what. + """ + return self.operation_class == "read" + + +#: Human-readable names for the tool-call categories, used to build the +#: ``name`` field. Keys must cover every category in :data:`CATEGORY_SLOTS`. +_CATEGORY_LABELS: dict[str, str] = { + "cluster": "cluster", + "schema": "schema", + "kv": "document", + "query": "query", + "index": "index", + "performance": "performance", + # Labels are shared across packages, so a slot reads the same wherever it + # appears. "config" covers the server's own status and configuration + # surface, which is MCP-level rather than cluster-level. + "config": "config", + "search": "search", +} + + +def tool_call_event(category: str, operation_class: str, package: str) -> ToolCallEvent: + """Build the :class:`ToolCallEvent` for a category and operation class.""" + label = _CATEGORY_LABELS[category] + verb = "read" if operation_class == "read" else "write" + article = "An" if label[0].lower() in "aeiou" else "A" + return ToolCallEvent( + id=tool_call_event_id(category, operation_class, package), + event_name=f"{label} {verb}", + description=f"{article} {label} {verb} operation was requested through a tool", + category=category, + operation_class=operation_class, + ) + + +def all_tool_call_events( + package: str, +) -> list[ToolCallEvent]: + """Every Tier-2 tool-call entry for ``package``, for descriptor generation.""" + return [ + tool_call_event(category, operation_class, package) + for category in categories_for(package) + for operation_class in ("read", "write") + ] + + +def build_descriptor() -> dict: + """Build the descriptor document describing the whole catalogue.""" + entries = [ + { + "id": event.id, + "name": event.event_name, + "description": event.description, + "category": event.category, + "default_enabled": True, + "filterable": event.filterable, + } + for event in AuditEvent + ] + # Every package's Tier-2 events, not just the default one. The descriptor + # is what a SIEM author reads to know which ids this build can emit, and a + # deployment commonly runs both servers — shipping only one block's ids + # would make the other server's records look unknown. + # + # ``service_package`` is carried on each entry because the tool-call names + # repeat across blocks: "query read" is 61491 in operational and 65587 in + # operational_insights, and the name alone cannot tell them apart. + entries += [ + { + "id": event.id, + "name": event.event_name, + "description": event.description, + "category": "tool_call", + "service_package": package, + "default_enabled": True, + "filterable": event.filterable, + } + for package in SERVICE_PACKAGE_BLOCKS + for event in all_tool_call_events(package) + ] + entries.sort(key=lambda entry: entry["id"]) + return { + # No single `service_package`: the descriptor covers every registered + # package, and naming one at the top of the document would be wrong + # for every server running another. Each tool-call entry names its own. + "service_packages": sorted(SERVICE_PACKAGE_BLOCKS), + "blocks": {"core": CORE_BLOCK, **SERVICE_PACKAGE_BLOCKS}, + "events": entries, + } + + +#: Ids of events an operator is permitted to suppress via configuration. +#: +#: Spans every registered package. A process runs one package, but an operator +#: writing CB_MCP_AUDIT_LOG_DISABLED_EVENTS should not have an id rejected as +#: unknown merely because this process happens to serve a different package — +#: the same configuration is routinely shared across both servers. +FILTERABLE_IDS: frozenset[int] = frozenset( + [event.id for event in AuditEvent if event.filterable] + + [ + event.id + for package in SERVICE_PACKAGE_BLOCKS + for event in all_tool_call_events(package) + if event.filterable + ] +) + +#: Every known event id, across every registered package. +ALL_IDS: frozenset[int] = frozenset( + [event.id for event in AuditEvent] + + [ + event.id + for package in SERVICE_PACKAGE_BLOCKS + for event in all_tool_call_events(package) + ] +) + + +__all__ = [ + "ALL_IDS", + "CATEGORY_SLOTS", + "SERVICE_PACKAGE_BLOCKS", + "categories_for", + "MAX_SLOT", + "RETIRED_SLOTS", + "CORE_BLOCK", + "FILTERABLE_IDS", + "OPERATIONAL_BLOCK", + "READ_OFFSET", + "WRITE_DELTA", + "AuditEvent", + "ToolCallEvent", + "all_tool_call_events", + "build_descriptor", + "category_index", + "next_free_slot", + "tool_call_event", + "tool_call_event_id", +] diff --git a/src/cb_mcp/audit/classification.py b/src/cb_mcp/audit/classification.py new file mode 100644 index 00000000..616669dd --- /dev/null +++ b/src/cb_mcp/audit/classification.py @@ -0,0 +1,329 @@ +"""Tool classification — the catalogue's extension point. + +The audit ID space is frozen, so adding a tool requires only a new entry in +:data:`TOOL_CLASSIFICATION`. No new event ID, no registry allocation, no +SIEM-rule change. + +``operation_class`` is authoritative at the MCP layer: a tool is classified by +what it is allowed to do. That single classification drives both the event ID +(read versus write range within the package block) and ``required_scope``, +keeping the two consistent by construction. + +A tool with no entry **fails closed** — recorded against a write ID, which is +never filterable — so a newly added tool can never be silently under-audited. +Such a record also carries ``unclassified: true`` so a reviewer is never misled +about the category, and :func:`unclassified_tool_names` lets startup report the +gap rather than leaving it to be discovered from an audit file months later. + +Every registered tool is classified here, whether or not the PRD's +example-tool tables happen to name it. The PRD fixes the *id space* — the +categories and their read/write ids — and names a few tools per category by way +of illustration; it was never meant to be the tool inventory, and a tool list in +a document would be stale the week after it was written. The authority for an +individual tool is the repository itself: the ``read``/``write`` split in +:mod:`cb_mcp.tools` and the ``readOnlyHint`` annotations, which are also what +decide ``required_scope``. Classifying from those is what keeps the audit id and +the scope check from ever disagreeing. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from .catalog import ToolCallEvent, tool_call_event + +#: ``service package -> {tool_name -> (category, operation_class)}``. +#: +#: Keyed by service package, not by bare tool name. The repository ships two +#: servers and they genuinely collide on five names — ``create_index``, +#: ``list_indexes``, ``get_collections_in_scope``, ``get_schema_for_collection`` +#: and ``get_server_configuration_status``. A flat table cannot represent both, +#: and booking one server's tool against another's block would put records in a +#: block whose ids mean something else. +#: +#: Both shipped servers have a table. Adding a further package means a table +#: here and a block in :data:`~cb_mcp.audit.catalog.SERVICE_PACKAGE_BLOCKS`, +#: and renumbers nothing. Every package must allocate the fail-closed +#: category -- see :data:`~cb_mcp.audit.catalog.FAILSAFE_SLOT_REQUIRED`. +#: +#: Within a package, ordered by category to match the catalogue layout rather +#: than alphabetically, so a reviewer can check a whole block in one pass. +TOOL_CLASSIFICATION: dict[str, dict[str, tuple[str, str]]] = { + "operational": { + # -- config (MCP server status and configuration) ------------------- + # The server's own status and configuration, not the cluster's. It + # reports what this process was started with — transport, read-only + # mode, the withheld and disabled tool sets — so an operator auditing + # "who inspected the server" must not have it mixed in with cluster + # health checks. + "get_server_configuration_status": ("config", "read"), + # Reads bundled reference data shipped with the server and touches no + # cluster at all, so it belongs with the server's own surface rather + # than with Cluster Setup & Health. + "discover_tool_input_values": ("config", "read"), + # -- cluster (Cluster Setup & Health) ------------------------------ + "test_cluster_connection": ("cluster", "read"), + "get_cluster_health_and_services": ("cluster", "read"), + "get_client_connectivity_report": ("cluster", "read"), + "get_cluster_metrics": ("cluster", "read"), + # Both read the cluster's own REST endpoints (/pools/default/tasks and + # the node topology) and need only ro_admin, so they are cluster reads + # like their neighbours above. + "get_cluster_tasks": ("cluster", "read"), + "get_cluster_health_snapshot": ("cluster", "read"), + # The cluster's own event log via GET /events — a server-level read, + # like its neighbours above. + "get_cluster_system_events": ("cluster", "read"), + # Query-engine vitals (/admin/vitals) and GSI indexer stats. Both + # describe how a *service* in the cluster is holding up rather than + # what a caller queried or which indexes exist, which is what this + # category covers. + "get_cluster_query_vitals": ("cluster", "read"), + "get_index_stats": ("cluster", "read"), + # -- schema / discovery -------------------------------------------- + "get_buckets_in_cluster": ("schema", "read"), + "get_scopes_in_bucket": ("schema", "read"), + "get_collections_in_scope": ("schema", "read"), + "get_scopes_and_collections_in_bucket": ("schema", "read"), + "get_schema_for_collection": ("schema", "read"), + "create_scope": ("schema", "write"), + "create_collection": ("schema", "write"), + "delete_scope": ("schema", "write"), + "delete_collection": ("schema", "write"), + # -- kv (document) -------------------------------------------------- + "get_document_by_id": ("kv", "read"), + "lookup_subdocument": ("kv", "read"), + "upsert_document_by_id": ("kv", "write"), + "insert_document_by_id": ("kv", "write"), + "replace_document_by_id": ("kv", "write"), + "delete_document_by_id": ("kv", "write"), + "mutate_subdocument": ("kv", "write"), + # -- query (SQL++) --------------------------------------------------- + # run_sql_plus_plus_query is re-classified per statement at invocation; + # the entry here is the class used when the statement was not inspected. + "run_sql_plus_plus_query": ("query", "read"), + "explain_sql_plus_plus_query": ("query", "read"), + # Embeds the query text, then runs a SELECT with APPROX_VECTOR_DISTANCE + # against a GSI vector index: a Query-service read like its neighbours. + "run_vector_query": ("query", "read"), + # -- index ----------------------------------------------------------- + "list_indexes": ("index", "read"), + "get_index_advisor_recommendations": ("index", "read"), + "create_index": ("index", "write"), + # The replacement for the deprecated create_index, creating scalar + # *and* vector GSI indexes through SQL++. It takes the same id as the + # tool it supersedes: both create a GSI index, and an id that moved + # would silently break every SIEM rule watching for index creation. + # Vector index creation is an index write, not a category of its own. + "create_query_index": ("index", "write"), + "build_index": ("index", "write"), + "drop_index": ("index", "write"), + # -- performance ----------------------------------------------------- + "get_longest_running_queries": ("performance", "read"), + "get_most_frequent_queries": ("performance", "read"), + "get_queries_with_largest_response_sizes": ("performance", "read"), + "get_queries_with_large_result_count": ("performance", "read"), + "get_queries_using_primary_index": ("performance", "read"), + "get_queries_not_using_covering_index": ("performance", "read"), + "get_queries_not_selective": ("performance", "read"), + # The N1QL Admin REST API's active-request surface. Booked as + # performance rather than query: `query read` means "a caller ran a + # statement", and a reviewer reconstructing what was executed must not + # find listings of other callers' in-flight requests mixed into it. + # The vitals half of that API is a cluster read instead: it describes + # service health rather than the query workload. + "get_active_queries": ("performance", "read"), + # The first performance *write*, taking the id (61525) the catalogue + # reserved when the category was allocated — a map entry, no new id and + # no SIEM-rule change. It is also the only tool in the server that + # terminates another caller's work, so it must be non-filterable, which + # the write class makes it by construction. + "delete_active_query": ("performance", "write"), + # -- search (FTS) ------------------------------------------------------ + # Its own category rather than folded into index: run_search is a + # query, so a reviewer filtering on `index read` should not get search + # traffic mixed in. Index management for FTS takes the search *write* + # id (61526) the catalogue reserves — the extension point working as + # designed: a new tool is a map entry, not a new id and not a SIEM + # rule change. + "list_search_indexes": ("search", "read"), + "get_search_index_definition": ("search", "read"), + "run_search": ("search", "read"), + # Goes through the Search service, so it books against search rather + # than query even though run_vector_query is the GSI counterpart. + "run_vector_search": ("search", "read"), + "upsert_search_index": ("search", "write"), + "drop_search_index": ("search", "write"), + }, + # -- Operational Insights (analytics) ------------------------------------ + # + # Its own block (0x10000) on the *same* category axis, so a slot means the + # same thing in both and an id decodes to package-by-block and + # category-by-offset. The roadmap's own grouping maps onto that axis + # rather than replacing it: + # + # Data Model & Schema Discovery, Metadata Introspection -> schema + # Query and Index, Async Request API, Query Execution -> query / index + # Scope & Collection Management -> schema write + # Query Monitoring -> performance + # + # Only the tools this server actually registers are listed. The roadmap + # sheet also names 1.2 tools that do not exist yet, and spells the shipped + # ones `operational_insights_*` where the server registers them `oi_*`; + # classifying a name that is never registered would be dead weight that + # the parity test cannot check. + "operational_insights": { + # -- config (MCP server status and configuration) ------------------- + # Same tool name as the operational server's, which is exactly why + # this table is keyed by package: the two must resolve to different + # ids (61495 and 65591), and a flat name map could not express that. + "get_server_configuration_status": ("config", "read"), + # -- schema / discovery --------------------------------------------- + # Analytics names its containers database/scope/collection; they are + # the same discovery surface as the operational server's bucket/scope/ + # collection reads, so they take the same slot. + "operational_insights_get_databases_in_cluster": ("schema", "read"), + "operational_insights_get_scopes_in_database": ("schema", "read"), + "operational_insights_get_collections_in_scope": ("schema", "read"), + "operational_insights_get_schema_for_collection": ("schema", "read"), + # -- query (SQL++ over Analytics) ----------------------------------- + # Both accept SELECT, DML and DDL, so they are statement-classified + # (see STATEMENT_CLASSIFIED_TOOLS). The entry here is the fallback used + # when no class was recorded, and it is `write` deliberately: a read id + # is filterable, so guessing read would let a mutation be configured + # out of the audit log. + "operational_insights_run_query_sync": ("query", "write"), + "operational_insights_run_query_async": ("query", "write"), + "operational_insights_explain_query": ("query", "read"), + # The async request lifecycle. Retrieving, discarding and cancelling + # all act on a handle for a query this caller submitted, so they stay + # with the query that produced it rather than forming a category of + # their own — "Async Request API" is a shape of the API, not a + # different resource. + "operational_insights_get_async_query_results": ("query", "read"), + "operational_insights_discard_async_query_results": ("query", "read"), + "operational_insights_cancel_async_query": ("query", "read"), + # -- index ----------------------------------------------------------- + "operational_insights_list_indexes": ("index", "read"), + "operational_insights_create_index": ("index", "write"), + }, +} + +#: Tools whose read/write class depends on the statement they are given, as +#: ``package -> {tool names}``. +#: +#: Each of these accepts SELECT, DML and DDL through one entry point, so a +#: fixed class would be wrong half the time -- and wrong in the dangerous +#: direction: read ids are filterable, so a mutation booked as a read could be +#: suppressed from the audit log by configuration. The tool records its own +#: class into :mod:`cb_mcp.audit.state` at invocation and the middleware reads +#: it back. +#: +#: A tool listed here that fails to record a class falls back to its table +#: entry below, which is why those entries are ``write``: the failure mode is +#: a filterable read, so the static answer has to be the non-filterable one. +STATEMENT_CLASSIFIED_TOOLS: dict[str, frozenset[str]] = { + "operational": frozenset({"run_sql_plus_plus_query"}), + "operational_insights": frozenset( + { + "operational_insights_run_query_sync", + "operational_insights_run_query_async", + } + ), +} + + +def is_statement_classified(tool_name: str, package: str) -> bool: + """True when ``tool_name`` reports its own read/write class per invocation.""" + return tool_name in STATEMENT_CLASSIFIED_TOOLS.get(package, frozenset()) + + +#: Fail-closed operation class for a tool with no entry. Write IDs are never +#: filterable, so an unknown tool is always recorded. +FAILSAFE_OPERATION_CLASS = "write" + +#: Category used for the ID of an otherwise unknown tool. The record also +#: carries ``unclassified: true`` so this placeholder is never mistaken for a +#: real categorisation. +FAILSAFE_CATEGORY = "cluster" + + +def classification_for( + package: str, +) -> dict[str, tuple[str, str]]: + """Return ``package``'s classification table, or an empty one if unaudited.""" + return TOOL_CLASSIFICATION.get(package, {}) + + +def is_classified(tool_name: str, package: str) -> bool: + """True when ``tool_name`` has an explicit entry in ``package``'s table.""" + return tool_name in classification_for(package) + + +def classify_tool( + tool_name: str, + *, + package: str, + operation_class_override: str | None = None, +) -> tuple[str, str]: + """Return ``(category, operation_class)`` for ``tool_name``. + + Args: + tool_name: The MCP tool name as registered. + operation_class_override: Supplied for statement-classified tools once + the statement has been inspected, so a SQL++ DML call is booked + against the query *write* ID rather than the read ID. + + Unknown tools fail closed to a write class. + """ + category, operation_class = classification_for(package).get( + tool_name, (FAILSAFE_CATEGORY, FAILSAFE_OPERATION_CLASS) + ) + if operation_class_override is not None: + operation_class = operation_class_override + return category, operation_class + + +def resolve_tool_call_event( + tool_name: str, + *, + package: str, + operation_class_override: str | None = None, +) -> ToolCallEvent: + """Resolve the catalogue entry for a tool invocation in ``package``.""" + category, operation_class = classify_tool( + tool_name, + package=package, + operation_class_override=operation_class_override, + ) + return tool_call_event(category, operation_class, package) + + +def required_scope_for(operation_class: str) -> str: + """Map an operation class onto the scope label recorded in the audit line. + + Deliberately the short form (``read`` / ``write``) rather than the full + ``couchbase-mcp:write`` scope string, matching the PRD's sample records. + """ + return "write" if operation_class == "write" else "read" + + +def unclassified_tool_names(tool_names: Iterable[str], package: str) -> list[str]: + """Return the subset of ``tool_names`` with no entry in ``package``'s table.""" + table = classification_for(package) + return sorted(name for name in tool_names if name not in table) + + +__all__ = [ + "FAILSAFE_CATEGORY", + "classification_for", + "FAILSAFE_OPERATION_CLASS", + "STATEMENT_CLASSIFIED_TOOLS", + "is_statement_classified", + "TOOL_CLASSIFICATION", + "classify_tool", + "is_classified", + "required_scope_for", + "resolve_tool_call_event", + "unclassified_tool_names", +] diff --git a/src/cb_mcp/audit/config.py b/src/cb_mcp/audit/config.py new file mode 100644 index 00000000..00160b64 --- /dev/null +++ b/src/cb_mcp/audit/config.py @@ -0,0 +1,414 @@ +"""Resolution of the audit configuration from CLI / environment values. + +Mirrors the shape of :mod:`cb_mcp.utils.logging`'s resolution: parse, fall back +loudly on unusable input, and expose an immutable snapshot that both +``get_server_configuration_status`` and the startup records can report, so the +tool output and the audit file always agree on what is running. + + +**Zero means off, not invalid.** Both rotation triggers accept ``0`` as a +first-class value: ``CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB=0`` turns size-based +rotation off and ``CB_MCP_AUDIT_LOG_ROTATION_INTERVAL=0`` turns interval-based +rotation off. Setting both gives one unbounded file, which is the PRD's Case 2 +("audit logs are important for me and I want to store them all"). Only a +*negative* or unparseable value is a configuration error, and that falls back to +the default with a warning rather than aborting. + +**A broken file sink degrades, it does not abort.** Selecting the file sink +without naming a path is reported and the file sink is dropped; if console was +also selected the server keeps auditing to console, and if it was not, auditing +is off. The server starts either way. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..utils.constants import ( + ALLOWED_AUDIT_SINKS, + AUDIT_INTERVAL_UNIT_SECONDS, + BYTES_PER_MB, + DEFAULT_AUDIT_ENABLED, + DEFAULT_AUDIT_MAX_BACKUPS, + DEFAULT_AUDIT_ROTATION_INTERVAL, + DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB, + DEFAULT_AUDIT_SINKS, + DEFAULT_AUDIT_TOOL_ARGS, + LOGGER_NAMESPACE, +) +from .catalog import ALL_IDS, FILTERABLE_IDS +from .sink import process_scoped_path + +logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.config") + +SINK_CONSOLE = "console" +SINK_FILE = "file" + +#: ````: one or more digits followed by a single unit letter. +#: Anchored, so "1d2h" and "d1" are rejected rather than half-read. +_INTERVAL_PATTERN = re.compile(r"^(\d+)([a-zA-Z])$") + + +@dataclass(frozen=True) +class ResolvedAuditConfig: + """Snapshot of the audit configuration actually in force.""" + + enabled: bool + sinks: tuple[str, ...] + file: str | None + process_file: str | None + rotation_max_size_mb: float + max_bytes: int + rotation_interval: str + rotation_interval_seconds: int + max_backups: int + tool_args: bool + disabled_events: tuple[int, ...] + + @property + def writes_file(self) -> bool: + """True when a file sink is actually going to be opened.""" + return self.enabled and SINK_FILE in self.sinks and self.file is not None + + @property + def writes_console(self) -> bool: + """True when records are going to stderr.""" + return self.enabled and SINK_CONSOLE in self.sinks + + def as_dict(self) -> dict[str, Any]: + """JSON-friendly view for the server-configuration tool and records.""" + return { + "enabled": self.enabled, + "sinks": list(self.sinks), + "file": self.file, + "process_file": self.process_file, + "rotation_max_size_mb": self.rotation_max_size_mb, + "rotation_interval": self.rotation_interval, + "max_backups": self.max_backups, + "tool_args": self.tool_args, + "disabled_events": list(self.disabled_events), + } + + +def parse_audit_sinks(raw: str | None) -> tuple[str, ...]: + """Parse ``CB_MCP_AUDIT_LOG_SINKS`` into an ordered tuple of sink names. + + Same shape as ``CB_MCP_LOG_SINKS``: comma-separated, case-insensitive, + whitespace trimmed. Unknown tokens are warned about and ignored; if nothing + valid survives the default sink is used, so enabling auditing always + produces output somewhere rather than silently producing none. + + The return is ordered rather than a set so the status tool and the ``audit + configuration changed`` record report sinks in a stable order. + """ + if raw is None: + raw = DEFAULT_AUDIT_SINKS + + selected: list[str] = [] + invalid: list[str] = [] + for part in raw.split(","): + token = part.strip() + if not token: + continue + normalised = token.lower() + if normalised not in ALLOWED_AUDIT_SINKS: + invalid.append(token) + elif normalised not in selected: + selected.append(normalised) + + if invalid: + logger.warning( + "Ignored invalid audit sink value(s) %s in " + "--audit-log-sinks/CB_MCP_AUDIT_LOG_SINKS; allowed values are %s.", + sorted(invalid), + list(ALLOWED_AUDIT_SINKS), + ) + if not selected: + logger.warning( + "No valid audit sink configured; falling back to the default of %r.", + DEFAULT_AUDIT_SINKS, + ) + selected.append(DEFAULT_AUDIT_SINKS) + + # Canonical order, independent of how the operator happened to type it. + return tuple(name for name in ALLOWED_AUDIT_SINKS if name in selected) + + +def parse_rotation_interval(raw: str | None) -> int | None: + """Parse ```` into seconds. ``0`` means off; ``None`` invalid. + + Accepts the PRD's vocabulary exactly: an integer followed by ``d`` (24 + hours) or ``w`` (7 days), plus the bare ``0`` that turns interval rotation + off. Returns the interval in seconds, ``0`` for off, or ``None`` when the + value cannot be read — the caller decides what to do about that, because + the right response differs between a Click callback and a direct call. + """ + if raw is None: + return None + entry = raw.strip().lower() + if not entry: + return None + match = _INTERVAL_PATTERN.match(entry) + if match is None: + return 0 if entry.isdigit() and int(entry) == 0 else None + value, unit = int(match.group(1)), match.group(2) + if unit not in AUDIT_INTERVAL_UNIT_SECONDS: + return None + return value * AUDIT_INTERVAL_UNIT_SECONDS[unit] + + +def _normalise_interval(raw: str | None) -> tuple[str, int]: + """Resolve the interval setting to its canonical text and its seconds. + + The canonical text is what the status tool reports, so an operator reading + it back sees the same vocabulary they configured. + """ + if raw is None: + raw = DEFAULT_AUDIT_ROTATION_INTERVAL + + seconds = parse_rotation_interval(raw) + if seconds is None: + default_seconds = parse_rotation_interval(DEFAULT_AUDIT_ROTATION_INTERVAL) + logger.warning( + "Invalid audit rotation interval %r; expected where " + "the unit is 'd' (days) or 'w' (weeks), e.g. '1d', '30d', '8w', or " + "'0' to turn interval-based rotation off. Falling back to the " + "default of %r.", + raw, + DEFAULT_AUDIT_ROTATION_INTERVAL, + ) + return DEFAULT_AUDIT_ROTATION_INTERVAL, int(default_seconds or 0) + if seconds == 0: + return "0", 0 + return raw.strip().lower(), seconds + + +def _read_event_tokens(value: str) -> list[str] | None: + """Split the raw setting into entries, from a file or a comma-separated list. + + Returns ``None`` when a file was named but could not be read, so the caller + can distinguish "nothing configured" from "configuration unreadable". + """ + candidate = Path(value) + try: + is_file = candidate.exists() and candidate.is_file() + except OSError: # pragma: no cover - unreadable path, treat as a list + is_file = False + + if not is_file: + return [part.strip() for part in value.split(",") if part.strip()] + + try: + with open(candidate, encoding="utf-8") as handle: + return [ + entry + for entry in (line.strip() for line in handle) + if entry and not entry.startswith("#") + ] + except OSError as exc: + logger.error( + "Failed to read audit disabled-events file %s: %s. " + "No events will be filtered.", + candidate, + exc, + ) + return None + + +def _resolve_event_id(token: str) -> int | None: + """Map one entry — a numeric event id — onto an event id. + + Numeric ids only. Catalogue names were accepted here at one point and were + withdrawn deliberately: the id is the wire contract and the name is not. + Names carry spaces and are English prose ("write blocked (read-only mode)"), + which makes them awkward to quote in a shell and in a Docker ``-e`` value, + and a name is free to be reworded for clarity in a way an id never is — so + a filter written against a name could silently stop matching after an + editorial change. ``descriptor.json`` maps every id to its name for anyone + composing a filter. + """ + if not token.isdigit(): + return None + event_id = int(token) + return event_id if event_id in ALL_IDS else None + + +def parse_disabled_events(raw: str | None) -> set[int]: + """Parse ``CB_MCP_AUDIT_LOG_DISABLED_EVENTS`` into a set of event ids. + + Accepts a comma-separated list, or a path to a file with one entry per line + (``#`` comments allowed), matching how ``--disabled-tools`` already works. + Entries are **numeric event ids**; see :func:`_resolve_event_id` for why + catalogue names are not accepted. + + Two classes of entry are rejected with a warning rather than silently + honoured, because both would leave an operator believing they had filtered + something they had not: + + * an entry that is not a numeric id in the catalogue at all; + * a **non-filterable** event. Writes and security decisions are mandated — + an operator may turn read noise down but may not switch off the record of + who changed what. + """ + if not raw or not raw.strip(): + return set() + + tokens = _read_event_tokens(raw.strip()) + if tokens is None: + return set() + + resolved: set[int] = set() + unknown: list[str] = [] + not_filterable: list[str] = [] + + for token in tokens: + event_id = _resolve_event_id(token) + if event_id is None: + unknown.append(token) + elif event_id not in FILTERABLE_IDS: + not_filterable.append(token) + else: + resolved.add(event_id) + + if unknown: + logger.warning( + "Ignored unknown audit event(s) in CB_MCP_AUDIT_LOG_DISABLED_EVENTS: %s.", + sorted(unknown), + ) + if not_filterable: + logger.warning( + "Refused to disable non-filterable audit event(s): %s. Write and " + "security events are always recorded.", + sorted(not_filterable), + ) + return resolved + + +def _resolve_size_mb(raw: float | None) -> float: + """Rotation size in MB. ``0`` is off; negative falls back with a warning.""" + if raw is None: + return DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB + size_mb = float(raw) + if size_mb < 0: + logger.warning( + "Invalid audit rotation size %s MB; falling back to the default of " + "%s MB. Use 0 to turn size-based rotation off.", + raw, + DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB, + ) + return DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB + return size_mb + + +def _resolve_max_backups(raw: int | None) -> int: + """Retained backups. ``0`` keeps only the live file; negative falls back.""" + if raw is None: + return DEFAULT_AUDIT_MAX_BACKUPS + backups = int(raw) + if backups < 0: + logger.warning( + "Invalid audit retention max backups %s; falling back to the " + "default of %s.", + raw, + DEFAULT_AUDIT_MAX_BACKUPS, + ) + return DEFAULT_AUDIT_MAX_BACKUPS + return backups + + +def resolve_audit_config( + *, + enabled: bool | None, + sinks: str | None = None, + file: str | None, + rotation_max_size_mb: float | None, + rotation_interval: str | None = None, + max_backups: int | None, + tool_args: bool | None, + disabled_events: str | None, +) -> ResolvedAuditConfig: + """Turn raw CLI/env values into the configuration actually in force.""" + is_enabled = DEFAULT_AUDIT_ENABLED if enabled is None else bool(enabled) + selected_sinks = parse_audit_sinks(sinks) + + trimmed_file = file.strip() if isinstance(file, str) else None + trimmed_file = trimmed_file or None + + if is_enabled and SINK_FILE in selected_sinks and trimmed_file is None: + remaining = tuple(name for name in selected_sinks if name != SINK_FILE) + logger.error( + "The 'file' audit sink is selected but no audit file path is " + "configured. Set --audit-log-file-path / " + "CB_MCP_AUDIT_LOG_FILE_PATH. The server will start with %s.", + ( + f"audit records going to {', '.join(remaining)} only" + if remaining + else "audit logging disabled" + ), + ) + selected_sinks = remaining + if not selected_sinks: + is_enabled = False + + if is_enabled and trimmed_file is not None and SINK_FILE not in selected_sinks: + # The opposite mistake, and a quieter one: a path is configured but the + # file sink was never selected, so nothing is written to it and the + # operator finds an empty directory where they expected records. + logger.warning( + "An audit file path is configured (%s) but 'file' is not in " + "--audit-log-sinks/CB_MCP_AUDIT_LOG_SINKS (sinks=%s), so no audit " + "file will be written. Add 'file' to the sinks to use the path.", + trimmed_file, + ",".join(selected_sinks), + ) + + size_mb = _resolve_size_mb(rotation_max_size_mb) + interval_text, interval_seconds = _normalise_interval(rotation_interval) + backups = _resolve_max_backups(max_backups) + include_args = DEFAULT_AUDIT_TOOL_ARGS if tool_args is None else bool(tool_args) + + if include_args and is_enabled: + logger.warning( + "CB_MCP_AUDIT_LOG_TOOL_ARGS is enabled: tool arguments will be " + "recorded verbatim in the audit log, including full document " + "bodies passed to document-write tools. There is no redaction " + "capability in this release. Ensure the audit file's retention and " + "access controls are appropriate for the data it will contain." + ) + + filtered = parse_disabled_events(disabled_events) if is_enabled else set() + writes_file = ( + is_enabled and SINK_FILE in selected_sinks and trimmed_file is not None + ) + + return ResolvedAuditConfig( + enabled=is_enabled, + sinks=selected_sinks, + file=trimmed_file, + process_file=(str(process_scoped_path(trimmed_file)) if writes_file else None), + rotation_max_size_mb=size_mb, + # 0 stays 0 — the sink reads that as "no size-based rotation". Any + # positive size is at least one byte, so a sub-byte setting cannot + # round down into the off switch. + max_bytes=0 if size_mb == 0 else max(1, int(size_mb * BYTES_PER_MB)), + rotation_interval=interval_text, + rotation_interval_seconds=interval_seconds, + max_backups=backups, + tool_args=include_args, + disabled_events=tuple(sorted(filtered)), + ) + + +__all__ = [ + "SINK_CONSOLE", + "SINK_FILE", + "ResolvedAuditConfig", + "parse_audit_sinks", + "parse_disabled_events", + "parse_rotation_interval", + "resolve_audit_config", +] diff --git a/src/cb_mcp/audit/descriptor.json b/src/cb_mcp/audit/descriptor.json new file mode 100644 index 00000000..effb1bf6 --- /dev/null +++ b/src/cb_mcp/audit/descriptor.json @@ -0,0 +1,319 @@ +{ + "service_packages": [ + "operational", + "operational_insights" + ], + "blocks": { + "core": 57344, + "operational": 61440, + "operational_insights": 65536 + }, + "events": [ + { + "id": 57344, + "name": "server started", + "description": "MCP server started; carries the static server context", + "category": "lifecycle", + "default_enabled": true, + "filterable": false + }, + { + "id": 57345, + "name": "server stopped", + "description": "MCP server shut down cleanly", + "category": "lifecycle", + "default_enabled": true, + "filterable": false + }, + { + "id": 57346, + "name": "server configuration", + "description": "Resolved server configuration recorded at startup, including read-only mode and the withheld and disabled tool sets", + "category": "lifecycle", + "default_enabled": true, + "filterable": false + }, + { + "id": 57360, + "name": "session initialized", + "description": "MCP initialize handshake completed", + "category": "session", + "default_enabled": true, + "filterable": true + }, + { + "id": 57376, + "name": "token rejected", + "description": "Bearer JWT rejected during verification", + "category": "authorization", + "default_enabled": true, + "filterable": false + }, + { + "id": 57377, + "name": "scope check denied", + "description": "A required OAuth scope was not granted by the presented token", + "category": "authorization", + "default_enabled": true, + "filterable": false + }, + { + "id": 57488, + "name": "write blocked (read-only mode)", + "description": "A SQL++ statement modifying data or structure was refused because CB_MCP_READ_ONLY_MODE is enabled", + "category": "guardrail", + "default_enabled": true, + "filterable": false + }, + { + "id": 57490, + "name": "confirmation declined", + "description": "The user rejected an elicitation for a confirmation-required tool", + "category": "guardrail", + "default_enabled": true, + "filterable": false + }, + { + "id": 57491, + "name": "confirmation skipped", + "description": "A confirmation-required tool executed without confirmation because the client does not support elicitation", + "category": "guardrail", + "default_enabled": true, + "filterable": false + }, + { + "id": 61488, + "name": "cluster read", + "description": "A cluster read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": true + }, + { + "id": 61489, + "name": "schema read", + "description": "A schema read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": true + }, + { + "id": 61490, + "name": "document read", + "description": "A document read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": true + }, + { + "id": 61491, + "name": "query read", + "description": "A query read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": true + }, + { + "id": 61492, + "name": "index read", + "description": "An index read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": true + }, + { + "id": 61493, + "name": "performance read", + "description": "A performance read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": true + }, + { + "id": 61494, + "name": "search read", + "description": "A search read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": true + }, + { + "id": 61495, + "name": "config read", + "description": "A config read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": true + }, + { + "id": 61520, + "name": "cluster write", + "description": "A cluster write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": false + }, + { + "id": 61521, + "name": "schema write", + "description": "A schema write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": false + }, + { + "id": 61522, + "name": "document write", + "description": "A document write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": false + }, + { + "id": 61523, + "name": "query write", + "description": "A query write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": false + }, + { + "id": 61524, + "name": "index write", + "description": "An index write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": false + }, + { + "id": 61525, + "name": "performance write", + "description": "A performance write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": false + }, + { + "id": 61526, + "name": "search write", + "description": "A search write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": false + }, + { + "id": 61527, + "name": "config write", + "description": "A config write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational", + "default_enabled": true, + "filterable": false + }, + { + "id": 65584, + "name": "cluster read", + "description": "A cluster read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": true + }, + { + "id": 65585, + "name": "schema read", + "description": "A schema read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": true + }, + { + "id": 65587, + "name": "query read", + "description": "A query read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": true + }, + { + "id": 65588, + "name": "index read", + "description": "An index read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": true + }, + { + "id": 65591, + "name": "config read", + "description": "A config read operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": true + }, + { + "id": 65616, + "name": "cluster write", + "description": "A cluster write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": false + }, + { + "id": 65617, + "name": "schema write", + "description": "A schema write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": false + }, + { + "id": 65619, + "name": "query write", + "description": "A query write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": false + }, + { + "id": 65620, + "name": "index write", + "description": "An index write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": false + }, + { + "id": 65623, + "name": "config write", + "description": "A config write operation was requested through a tool", + "category": "tool_call", + "service_package": "operational_insights", + "default_enabled": true, + "filterable": false + } + ] +} diff --git a/src/cb_mcp/audit/emitter.py b/src/cb_mcp/audit/emitter.py new file mode 100644 index 00000000..4757dda2 --- /dev/null +++ b/src/cb_mcp/audit/emitter.py @@ -0,0 +1,412 @@ +"""The audit logger: catalogue plus sink, with a process-wide accessor. + +A module-level singleton, mirroring how ``get_resolved_logging_config`` already +works in this codebase. The alternative — threading a reference through every +call site — does not work for the JWT verifier, which FastMCP constructs before +the lifespan runs and which has no access to the request context. + +Every public method is a no-op when auditing is disabled or failed to start, so +callers never need to branch. Nothing here raises: an audit failure must not be +able to break a tool call. +""" + +from __future__ import annotations + +import atexit +import logging +import os +import signal +import threading +import types +from dataclasses import replace +from typing import Any + +from ..utils.constants import LOGGER_NAMESPACE +from .catalog import AuditEvent, ToolCallEvent +from .config import SINK_FILE, ResolvedAuditConfig +from .record import OUTCOME_SUCCESS, AuditRecord, ServerContext +from .sink import ( + AuditSink, + AuditSinkProtocol, + CompositeAuditSink, + ConsoleAuditSink, +) + +logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit") + + +class AuditLogger: + """Formats catalogue events into records and hands them to the sink.""" + + def __init__( + self, config: ResolvedAuditConfig, sink: AuditSinkProtocol | None + ) -> None: + self.config = config + self._sink = sink + self._server = ServerContext.detect() + self._disabled = frozenset(config.disabled_events) + + @property + def active(self) -> bool: + """True when records are actually being written.""" + return self._sink is not None + + @property + def server(self) -> ServerContext: + return self._server + + @property + def stats(self) -> dict[str, int]: + return self._sink.stats if self._sink is not None else {} + + def is_enabled_for(self, event_id: int) -> bool: + """False when the operator has filtered this event out.""" + return event_id not in self._disabled + + # -- emission --------------------------------------------------------- + + def emit(self, record: AuditRecord) -> None: + """Write one record, unless the event is filtered or auditing is off.""" + if self._sink is None or not self.is_enabled_for(record.id): + return + try: + self._sink.emit(record.to_json_line(self._server)) + except Exception: # auditing must never break a call + logger.exception("Unexpected failure while emitting an audit record") + + def emit_event( + self, + event: AuditEvent, + *, + outcome: str, + real_userid: dict[str, str] | None = None, + cid: str | None = None, + reason: str | None = None, + **payload: Any, + ) -> None: + """Emit a core-catalogue event.""" + self.emit( + AuditRecord( + id=event.id, + name=event.event_name, + description=event.description, + outcome=outcome, + real_userid=real_userid, + cid=cid, + reason=reason, + payload=payload, + ) + ) + + def emit_tool_call( + self, + event: ToolCallEvent, + *, + outcome: str, + real_userid: dict[str, str] | None = None, + cid: str | None = None, + reason: str | None = None, + **payload: Any, + ) -> None: + """Emit a Tier-2 tool-call event.""" + self.emit( + AuditRecord( + id=event.id, + name=event.event_name, + description=event.description, + outcome=outcome, + real_userid=real_userid, + cid=cid, + reason=reason, + payload=payload, + ) + ) + + def close(self) -> None: + if self._sink is not None: + self._sink.close() + self._sink = None + + +#: Inactive logger used before initialisation and after shutdown, so callers +#: never have to guard against ``None``. +_DISABLED = AuditLogger( + ResolvedAuditConfig( + enabled=False, + sinks=(), + file=None, + process_file=None, + rotation_max_size_mb=0.0, + max_bytes=0, + rotation_interval="0", + rotation_interval_seconds=0, + max_backups=0, + tool_args=False, + disabled_events=(), + ), + sink=None, +) + +_active: AuditLogger = _DISABLED + + +def get_audit_logger() -> AuditLogger: + """Return the process-wide audit logger, active or not.""" + return _active + + +def _open_file_sink(config: ResolvedAuditConfig) -> AuditSink | None: + """Open the file sink, or report why it could not be opened. + + A sink that cannot be opened is reported as an error and left out — per the + PRD, a bad audit path must not prevent the server starting. Any console + sink the operator also selected still runs, so the records go somewhere. + """ + if config.file is None: + return None + try: + sink = AuditSink( + config.file, + max_bytes=config.max_bytes, + max_backups=config.max_backups, + interval_seconds=config.rotation_interval_seconds, + ) + except (OSError, ValueError) as exc: + logger.error( + "Failed to open the audit file %r: %s. The server will start " + "without the file audit sink.", + config.file, + exc, + ) + return None + return sink + + +def _describe_rotation(config: ResolvedAuditConfig) -> str: + """How the live file will roll over, in the operator's own vocabulary.""" + triggers = [] + if config.max_bytes > 0: + triggers.append(f"size>{config.rotation_max_size_mb:g}MB") + if config.rotation_interval_seconds > 0: + triggers.append(f"age>{config.rotation_interval}") + if not triggers: + return "never (both size and interval rotation are off)" + return " or ".join(triggers) + + +def init_audit(config: ResolvedAuditConfig) -> AuditLogger: + """Build and install the audit logger for this process.""" + global _active # noqa: PLW0603 + + # One lifespan per process makes a second call unreachable today, but if it + # ever became reachable the old sink would keep running: its idle rotation + # would rename the live file out from under the new sink, whose records + # would then go to an unlinked inode and still be counted as written. + shutdown_audit() + + if not config.enabled or not config.sinks: + _active = AuditLogger(config, sink=None) + return _active + + sinks: list[AuditSinkProtocol] = [] + file_sink: AuditSink | None = None + if config.writes_console: + sinks.append(ConsoleAuditSink()) + if config.writes_file: + file_sink = _open_file_sink(config) + if file_sink is not None: + sinks.append(file_sink) + else: + # The snapshot is not cosmetic: it is reported by + # ``get_server_configuration_status`` and written into the ``audit + # configuration changed`` record, where it is permanent. A config + # still claiming a file sink that failed to open would tell an + # auditor records were being kept on disk when they were not. + config = replace( + config, + sinks=tuple(name for name in config.sinks if name != SINK_FILE), + process_file=None, + ) + + if not sinks: + _active = AuditLogger(replace(config, enabled=False), sink=None) + return _active + + sink: AuditSinkProtocol = sinks[0] if len(sinks) == 1 else CompositeAuditSink(sinks) + try: + sink.start() + except Exception: + # Starting a writer thread can fail for reasons that have nothing to do + # with this server -- at a container's pids.max, Thread.start raises + # RuntimeError. Letting that propagate would abort startup because + # auditing could not start, which is the one outcome the design + # forbids. Close what did start so the console thread and the file + # handle do not leak, and run with auditing off. + logger.exception( + "Could not start the audit writer; the server will run with " + "auditing disabled. Audit records will NOT be written." + ) + try: + sink.close() + except Exception: + logger.debug( + "Audit sink cleanup failed after a failed start", exc_info=True + ) + _active = AuditLogger(replace(config, enabled=False), sink=None) + return _active + + _active = AuditLogger(config, sink=sink) + # Only once auditing is actually running: a server with auditing off has no + # reason to touch the process's signal disposition. + _install_sigterm_handler() + + logger.info( + "Audit logging enabled. sinks=%s, file=%s, " + "rotates=%s, max_backups=%d, backups_compressed=yes, tool_args=%s, " + "disabled_events=%s. Each server process writes its own file; the " + "configured path has the host and process id inserted so concurrent " + "stdio servers cannot corrupt one another's records.", + ",".join(config.sinks), + file_sink.path if file_sink is not None else "none", + _describe_rotation(config), + config.max_backups, + config.tool_args, + list(config.disabled_events) or "none", + ) + return _active + + +def shutdown_audit() -> None: + """Flush and close the audit sink, and revert to the inactive logger.""" + global _active # noqa: PLW0603 + current = _active + _active = _DISABLED + current.close() + + +@atexit.register +def _shutdown_at_exit() -> None: + """Best-effort flush if the process exits without the lifespan closing. + + A stdio client that kills the server, or an unhandled exit, would otherwise + lose whatever is still queued. This cannot help on ``SIGKILL``; a missing + ``server stopped`` record therefore means "not a clean shutdown" — it is + not evidence of tampering, and must not be read as such. + """ + shutdown_audit() + + +#: The SIGTERM handler that was in place before auditing installed its own, so +#: it can still run. ``None`` means nothing has been installed yet. +_previous_sigterm: Any = None + +#: How long to wait for the audit trail to close on a SIGTERM that nothing else +#: is handling. Bounded because a shutdown must not hang; short because the +#: process is about to terminate either way. +_SIGTERM_DRAIN_SECONDS = 3.0 + + +def _close_audit_for_signal() -> None: + """Record the stop and close the sink. Runs on a helper thread, not inline. + + A signal handler runs on the main thread — the same thread that calls + ``emit`` and therefore the one that holds the sink queue's lock while it + does. That lock is not reentrant, so queueing a record from inside the + handler could deadlock a shutdown that must not hang. On a helper thread the + worst case is waiting for the main thread's lock, which the bounded join + below turns into a missing record rather than a wedged process. + """ + audit = get_audit_logger() + if audit.active: + audit.emit_event( + AuditEvent.SERVER_STOPPED, + outcome=OUTCOME_SUCCESS, + shutdown_signal="SIGTERM", + ) + shutdown_audit() + + +def _handle_sigterm(signum: int, frame: types.FrameType | None) -> None: + """Close the audit trail when, and only when, nothing else will. + + ``docker stop`` sends SIGTERM, and Python's default disposition terminates + the process without running ``atexit`` hooks or unwinding the lifespan. So + under stdio the most ordinary shutdown a container has produced no ``server + stopped`` record at all — while the catalogue says a missing one means the + shutdown was not clean. An operator restarting a container would have read + an unclean shutdown on every single restart, which makes the signal + worthless for spotting a real one. + + **When another handler already owns SIGTERM, this one does nothing but hand + over.** Under the http transport that handler is uvicorn's, which sets a + flag and then *drains in-flight requests* before exiting. Closing the audit + trail here would leave every one of those calls unrecorded — the middleware + reads the process-wide logger, finds it inactive, and emits nothing, not + even a dropped count. A write cancelled by a rolling restart would vanish, + which is precisely what recording cancelled calls exists to prevent. The + lifespan already emits ``server stopped`` when uvicorn unwinds it, so + deferring loses nothing and keeps the records that are still arriving. + """ + previous = _previous_sigterm + if previous is signal.SIG_IGN: + return + if callable(previous): + previous(signum, frame) + return + + # SIG_DFL (or an unreadable handler): nothing else will unwind anything, so + # this is the only chance to close the trail. + worker = threading.Thread( + target=_close_audit_for_signal, name="cb-mcp-audit-sigterm", daemon=True + ) + worker.start() + worker.join(timeout=_SIGTERM_DRAIN_SECONDS) + + # Terminate with the conventional status for the signal rather than exiting + # 0 and hiding that we were killed. + signal.signal(signal.SIGTERM, signal.SIG_DFL) + os.kill(os.getpid(), signum) + + +def _install_sigterm_handler() -> None: + """Install :func:`_handle_sigterm`, once, when it is possible to do so. + + ``signal.signal`` only works on the main thread of the main interpreter, so + an embedding host that builds the app on a worker thread simply does not get + this; auditing is unaffected otherwise. + + "Already installed" is read from the live signal disposition rather than + tracked in a module flag. The flag was a second copy of state the ``signal`` + module already owns, and the two could disagree: a host that installed its + own handler after ours would leave the flag saying "installed" while the + disposition said otherwise, and ours would never be restored. Reading the + disposition cannot drift — and it is what makes the guard below load-bearing + rather than an optimisation. + """ + global _previous_sigterm # noqa: PLW0603 + try: + previous = signal.getsignal(signal.SIGTERM) + if previous is _handle_sigterm: + # Ours is already in place. Falling through would record this very + # function as its own predecessor, and the next SIGTERM would + # recurse into it until the stack ran out. + return + # Published before the handler is installed: a SIGTERM delivered + # between the two would otherwise read ``None`` and take the default + # path, skipping the handler that actually owns the shutdown. + _previous_sigterm = previous + signal.signal(signal.SIGTERM, _handle_sigterm) + except (ValueError, OSError, AttributeError): + logger.debug( + "Could not install the audit SIGTERM handler; a SIGTERM shutdown " + "will not record a 'server stopped' event.", + exc_info=True, + ) + + +__all__ = [ + "AuditLogger", + "get_audit_logger", + "init_audit", + "shutdown_audit", +] diff --git a/src/cb_mcp/audit/exceptions.py b/src/cb_mcp/audit/exceptions.py new file mode 100644 index 00000000..7e84062c --- /dev/null +++ b/src/cb_mcp/audit/exceptions.py @@ -0,0 +1,85 @@ +"""Typed exceptions for auditable MCP-layer refusals. + +Each refusal below needs an ``outcome`` and a ``reason`` on its audit record. +Deriving those by matching exception message strings would mis-attribute +records after any copy edit, and a mis-attributed audit record is worse than a +missing one, so each refusal carries its own type and its own metadata. + +Every class **subclasses the broader exception type its call site raises** +(``PermissionError``, or ``ValueError`` for a read-only DML block), so callers +catching the broad type are unaffected and the public message text is +unchanged; only the type is narrowed. + +Note that FastMCP wraps a tool's exception in ``ToolError`` before it reaches +middleware (the original is preserved on ``__cause__``). Middleware therefore +does **not** rely on catching these types — the raising site records the +structured refusal into the per-request audit state (see +:mod:`cb_mcp.audit.state`), and these classes exist so the raise site is +self-documenting and so callers can still discriminate programmatically. +""" + +from .catalog import AuditEvent + + +class AuditableRefusalError(Exception): + """Mixin marking an exception the audit layer knows how to classify. + + Carries the catalogue event plus the ``outcome``/``reason`` pair the record + should report. Subclasses inherit from both this and the original builtin + exception type, keeping backwards compatibility at the call site. + """ + + audit_event: AuditEvent + audit_outcome: str + audit_reason: str + + +class ScopeDeniedError(AuditableRefusalError, PermissionError): + """A token was present but lacked a scope the tool requires.""" + + audit_event = AuditEvent.SCOPE_CHECK_DENIED + audit_outcome = "denied" + audit_reason = "missing_scope" + + +class ConfirmationDeclinedError(AuditableRefusalError, PermissionError): + """The user rejected an elicitation for a confirmation-required tool.""" + + audit_event = AuditEvent.CONFIRMATION_DECLINED + audit_outcome = "blocked" + audit_reason = "confirmation_declined" + + +class ReadOnlyWriteBlockedError(AuditableRefusalError, ValueError): + """A SQL++ statement that modifies data or structure was refused. + + Raised when ``CB_MCP_READ_ONLY_MODE`` is enabled. Subclasses ``ValueError`` + because that is what ``run_sql_plus_plus_query`` raised for this case + before, and callers (including tests) match on it. + """ + + audit_event = AuditEvent.WRITE_BLOCKED_READ_ONLY + audit_outcome = "blocked" + audit_reason = "read_only_mode" + + +class StatementScopeDeniedError(AuditableRefusalError, PermissionError): + """A SQL++ modification was refused because the token lacks write scope. + + Distinct from :class:`ScopeDeniedError`: the per-tool scope wrapper classifies + SQL++ as a read tool, so this denial is decided by inspecting the statement + at invocation rather than by the tool's static classification. + """ + + audit_event = AuditEvent.SCOPE_CHECK_DENIED + audit_outcome = "denied" + audit_reason = "missing_scope" + + +__all__ = [ + "AuditableRefusalError", + "ConfirmationDeclinedError", + "ReadOnlyWriteBlockedError", + "ScopeDeniedError", + "StatementScopeDeniedError", +] diff --git a/src/cb_mcp/audit/identity.py b/src/cb_mcp/audit/identity.py new file mode 100644 index 00000000..792edf0e --- /dev/null +++ b/src/cb_mcp/audit/identity.py @@ -0,0 +1,195 @@ +"""Resolution of ``real_userid`` — the access-decision identity. + +This is the only trustworthy answer to "who did this", and the reason an MCP +audit log exists at all: the cluster only ever sees the single +``CB_USERNAME`` the server connects with, so Couchbase Server's own audit log +cannot tell two callers apart. + +Three domains, per the PRD: + +* ``oauth`` — Streamable HTTP with OAuth active. The user is the bearer token's + subject. +* ``local`` — stdio. The user is the OS process owner, resolved from the + effective uid rather than the environment: under stdio the client launches + the server and therefore controls its environment, so anything an + environment variable could set is not an identity. +* ``anonymous`` — HTTP without OAuth. There is no caller authentication, so + there is no identity to record. + +The ``domain`` is recorded faithfully rather than papered over, because two of +these cases are degraded and a reviewer needs to be able to filter them out +rather than trust them: + +* ``anonymous`` collapses every caller together — exactly the problem auditing + is meant to solve. :func:`warn_on_unauthenticated_http` reports this at + startup. +* ``local`` inside a container resolves to the image's user (often ``root``), + which looks like an identity but identifies nothing. +""" + +from __future__ import annotations + +import getpass +import logging +import os + +try: # pragma: no cover - import guard, exercised by platform not by test + import pwd +except ImportError: # pragma: no cover - Windows + pwd = None # type: ignore[assignment] + +from fastmcp.server.dependencies import get_access_token + +from ..utils.constants import LOGGER_NAMESPACE, STREAMABLE_HTTP_TRANSPORT + +logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.identity") + +DOMAIN_OAUTH = "oauth" +DOMAIN_LOCAL = "local" +DOMAIN_ANONYMOUS = "anonymous" + +#: Recorded when the OS username cannot be determined at all. +UNKNOWN_USER = "unknown" + + +def _process_owner() -> str: + """Best-effort OS process owner, resolved from the kernel where possible. + + The value lands in ``real_userid.user`` for every stdio record, which is + the access-decision identity an auditor reads. It must therefore come from + something the caller cannot set. + + ``getpass.getuser`` cannot be that source: it consults ``LOGNAME``, + ``USER``, ``LNAME`` and ``USERNAME`` *before* the password database, so a + caller who launches the server with ``USER=someone-else`` writes that name + into every record. On POSIX the effective uid is asked of the kernel and + mapped through the password database instead; ``getpass`` remains the + fallback on Windows, which has no ``pwd`` module and no such environment + precedence problem in the same form. + + A container with no matching passwd entry raises :class:`KeyError`, so the + numeric uid is the last resort before :data:`UNKNOWN_USER`. + """ + if pwd is not None: # POSIX + try: + return pwd.getpwuid(os.geteuid()).pw_name + except Exception: + logger.debug( + "Could not map the effective uid to a passwd entry; " + "falling back to the numeric uid", + exc_info=True, + ) + else: # pragma: no cover - Windows has no pwd module + try: + return getpass.getuser() + except Exception: + logger.debug("getpass.getuser() failed", exc_info=True) + return UNKNOWN_USER + + try: + return str(os.geteuid()) + except Exception: # pragma: no cover - defensive + return UNKNOWN_USER + + +def _token_subject(token: object) -> str | None: + """Extract the caller's subject from a FastMCP access token. + + Prefers the parsed ``subject``, then the raw ``sub`` claim, then the + ``client_id``. A token that yields none of these is treated as having no + usable identity rather than being reported under a misleading value. + """ + subject = getattr(token, "subject", None) + if isinstance(subject, str) and subject.strip(): + return subject.strip() + + claims = getattr(token, "claims", None) + if isinstance(claims, dict): + sub = claims.get("sub") + if isinstance(sub, str) and sub.strip(): + return sub.strip() + + client_id = getattr(token, "client_id", None) + if isinstance(client_id, str) and client_id.strip(): + return client_id.strip() + + return None + + +def resolve_real_userid(transport: str) -> dict[str, str]: + """Return the ``{"domain": ..., "user": ...}`` pair for this request. + + Reads the request-scoped access token, which is ``None`` whenever no + authenticated caller is present — stdio, or HTTP with OAuth disabled. + """ + token = _current_token() + + if token is not None: + subject = _token_subject(token) + if subject is not None: + return {"domain": DOMAIN_OAUTH, "user": subject} + + if transport == STREAMABLE_HTTP_TRANSPORT: + return {"domain": DOMAIN_ANONYMOUS, "user": DOMAIN_ANONYMOUS} + + return {"domain": DOMAIN_LOCAL, "user": _process_owner()} + + +def _current_token() -> object | None: + """Return this request's access token, or ``None`` outside a request. + + ``get_access_token`` raises when there is no request context at all — for + example on a stdio server, or when auditing is exercised outside FastMCP — + so every caller goes through this guard rather than repeating it. + """ + try: + return get_access_token() + except Exception: + logger.debug("No access token available for this request", exc_info=True) + return None + + +def client_id_of_current_token() -> str | None: + """Return the OAuth ``client_id`` for this request, when there is one.""" + token = _current_token() + client_id = getattr(token, "client_id", None) if token is not None else None + return client_id if isinstance(client_id, str) and client_id else None + + +def scopes_of_current_token() -> list[str] | None: + """Return the scopes held by this request's token, when there is one.""" + token = _current_token() + if token is None: + return None + scopes = getattr(token, "scopes", None) + return sorted(scopes) if scopes else [] + + +def warn_on_unauthenticated_http(transport: str, oauth_enabled: bool) -> None: + """Warn when auditing is enabled on an HTTP transport without OAuth. + + In that configuration every record resolves to ``anonymous`` and the audit + log cannot answer "who did this" — the operator should know at startup, not + when an auditor asks. + """ + if transport == STREAMABLE_HTTP_TRANSPORT and not oauth_enabled: + logger.warning( + "Audit logging is enabled on the %s transport without OAuth. " + "Every audit record will report real_userid " + '{"domain": "anonymous", "user": "anonymous"} because callers are ' + "not authenticated. Configure the CB_MCP_OAUTH_JWT_* settings to " + "record the actual caller identity.", + transport, + ) + + +__all__ = [ + "DOMAIN_ANONYMOUS", + "DOMAIN_LOCAL", + "DOMAIN_OAUTH", + "UNKNOWN_USER", + "client_id_of_current_token", + "resolve_real_userid", + "scopes_of_current_token", + "warn_on_unauthenticated_http", +] diff --git a/src/cb_mcp/audit/middleware.py b/src/cb_mcp/audit/middleware.py new file mode 100644 index 00000000..7d1119ab --- /dev/null +++ b/src/cb_mcp/audit/middleware.py @@ -0,0 +1,451 @@ +"""The audit middleware. + +Sits in FastMCP's message pipeline and produces one record per tool call, plus +the session and guardrail records. It is deliberately *not* the only audit hook: +a rejected bearer token never becomes a JSON-RPC message, so it is caught in +the JWT verifier, and server lifecycle records come from the lifespan. What +middleware alone cannot see is *why* a call was refused — FastMCP wraps a +tool's exception in ``ToolError`` — so refusals are recorded by the gate that +made the decision, into :mod:`cb_mcp.audit.state`, and read back here. + +Correlation is minted here and nowhere else. ``on_message`` is the outermost +middleware hook and runs for every MCP request — ``initialize``, ``tools/call``, +the listings — so it is the one place that mints the per-request ``cid``. Every +other hook, and every guardrail, only *reads* it out of +:mod:`cb_mcp.audit.state`. + +Doing it here rather than in the authentication layer is deliberate. There is +no "token accepted" event, so on the success path the auth layer emits no +record for a shared ``cid`` to join to, and a *rejected* token is refused +before any MCP message exists, which makes its record a singleton whatever id +it carries. Minting in middleware therefore loses nothing, and it means a host +with a completely different authentication stack — the managed Capella runtime +— inherits the whole correlation model without writing any code. + +A request is the only correlation scope. No session identifier is recorded: +MCP is moving to a stateless model in which a server must not treat connection +or process identity as a proxy for session continuity, so an id derived from it +would group unrelated interleaved conversations together. + +Emission rules, following the PRD: + +* A call refused at a gate emits **only** the core authorization or guardrail + event. The tool never ran, so there is no tool-call event; the ``reason`` + vocabulary for tool calls covers execution failures, not policy decisions. +* A call that ran emits a tool-call event with ``success`` or ``error``. +* A confirmation that was silently skipped emits its own guardrail record + *and* is noted on the tool-call record, because the tool did run. Both + records share the correlation id, so they join. +""" + +from __future__ import annotations + +import logging +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + +from fastmcp.exceptions import NotFoundError, ValidationError +from fastmcp.server.middleware import Middleware + +from ..utils.constants import LOGGER_NAMESPACE +from . import state +from .catalog import AuditEvent +from .classification import ( + classify_tool, + is_classified, + is_statement_classified, + required_scope_for, + resolve_tool_call_event, +) +from .emitter import AuditLogger, get_audit_logger +from .identity import ( + client_id_of_current_token, + resolve_real_userid, + scopes_of_current_token, +) +from .record import ( + OUTCOME_ERROR, + OUTCOME_SUCCESS, + REASON_EXECUTION_ERROR, + REASON_INVALID_ARGUMENTS, +) + +logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.middleware") + +#: Argument names that together identify the keyspace a tool acted on. Every +#: keyspace-scoped tool in this server uses exactly these names. +_KEYSPACE_ARGS = ("bucket_name", "scope_name", "collection_name") + +#: Event ids that describe a policy refusal rather than an invocation. +_REFUSAL_EVENTS: dict[int, AuditEvent] = { + event.id: event + for event in ( + AuditEvent.SCOPE_CHECK_DENIED, + AuditEvent.WRITE_BLOCKED_READ_ONLY, + AuditEvent.CONFIRMATION_DECLINED, + ) +} + + +def extract_keyspace(arguments: dict[str, Any] | None) -> str | None: + """Build the dotted ``bucket.scope.collection`` keyspace from tool arguments. + + Returns the longest unbroken prefix that is present, so a bucket-only tool + yields ``"travel-sample"`` and a document tool yields + ``"travel-sample.inventory.airline"``. Returns ``None`` for tools that do + not address a keyspace at all, such as the cluster-level ones. + """ + if not arguments: + return None + parts: list[str] = [] + for name in _KEYSPACE_ARGS: + value = arguments.get(name) + if not isinstance(value, str) or not value: + break + parts.append(value) + return ".".join(parts) if parts else None + + +@contextmanager +def _request_state(context: Any) -> Iterator[dict[str, Any]]: + """Yield this request's audit state, installing it if it is missing. + + ``on_message`` normally installs the state, so the inner hooks find it + already there and reuse it — that is what keeps one ``cid`` across a + request. The fallback covers a hook invoked without ``on_message`` having + run: correlation degrades to per-hook rather than disappearing. + """ + existing = state.current() + if existing is not None: + yield existing + return + token = state.install(cid=str(uuid.uuid4())) + try: + installed = state.current() + yield installed if installed is not None else {} + finally: + state.reset(token) + + +class AuditMiddleware(Middleware): + """Emits session and tool-call audit records.""" + + def __init__( + self, + *, + transport: str, + cb_userid: str | None = None, + service_package: str, + audit_logger: AuditLogger | None = None, + ) -> None: + """ + Args: + transport: The resolved transport name, used to decide whether an + unauthenticated caller is ``anonymous`` (HTTP) or the local + process owner (stdio). + service_package: Which Tier-2 block this server's tool calls are + booked against, and which classification table names them. + Supplied from ``ServerSpec.audit_package`` so two servers that + share a tool name cannot share an event id. + cb_userid: The Couchbase user the server connects to the cluster + with. Recorded so a reviewer can see which cluster identity the + MCP-layer caller was mapped onto — the collapse this audit log + exists to undo. + audit_logger: Injected in tests; defaults to the process logger. + """ + self._transport = transport + self._cb_userid = cb_userid + self._service_package = service_package + self._injected_logger = audit_logger + + @property + def _audit(self) -> AuditLogger: + return self._injected_logger or get_audit_logger() + + # -- correlation ------------------------------------------------------ + + async def on_message(self, context, call_next): # type: ignore[no-untyped-def] + """Mint this request's correlation id, for every hook below to read. + + Runs for every MCP *request*; notifications reach neither this hook nor + ``on_notification`` in FastMCP 3.4.6, so nothing is minted for them. + """ + audit = self._audit + if not audit.active: + return await call_next(context) + + token = state.install(cid=str(uuid.uuid4())) + try: + return await call_next(context) + finally: + state.reset(token) + + # -- session ---------------------------------------------------------- + + async def on_initialize(self, context, call_next): # type: ignore[no-untyped-def] + """Record the MCP initialize handshake: what connected. + + A standalone record: it carries this request's ``cid`` like any other, + but nothing later joins to it, because no session identifier is + recorded. Its value is the statement that a client of this name and + version connected at this time, under this identity. + """ + result = await call_next(context) + audit = self._audit + if not audit.active: + return result + try: + params = getattr(context.message, "params", None) + client_info = getattr(params, "clientInfo", None) + with _request_state(context) as request_state: + audit.emit_event( + AuditEvent.SESSION_INITIALIZED, + outcome=OUTCOME_SUCCESS, + real_userid=resolve_real_userid(self._transport), + cid=request_state.get("cid"), + protocol_version=getattr(params, "protocolVersion", None), + client_name=getattr(client_info, "name", None), + client_version=getattr(client_info, "version", None), + ) + except Exception: # auditing must never break a session + logger.exception("Failed to emit the session-initialized audit record") + return result + + # -- tool calls ------------------------------------------------------- + + async def on_call_tool(self, context, call_next): # type: ignore[no-untyped-def] + """Record one tool call, or the gate that refused it.""" + audit = self._audit + if not audit.active: + return await call_next(context) + + tool_name = getattr(context.message, "name", None) or "unknown" + arguments = getattr(context.message, "arguments", None) + real_userid = resolve_real_userid(self._transport) + + with _request_state(context) as request_state: + cid = request_state.get("cid") + try: + result = await call_next(context) + except BaseException as exc: + # ``BaseException``, not ``Exception``: ``asyncio.CancelledError`` + # is not an ``Exception``, and a cancelled call is exactly the + # one that must still be recorded. The tool may already have + # mutated the cluster before the client timed out, disconnected + # or the server began shutting down — and a caller able to drop + # the record of a write by cancelling would have a way to act + # without leaving a trace, on events the catalogue makes + # non-filterable precisely so they cannot be suppressed. + self._emit_outcome( + audit, + tool_name=tool_name, + arguments=arguments, + cid=cid, + real_userid=real_userid, + failure=exc, + ) + raise + self._emit_outcome( + audit, + tool_name=tool_name, + arguments=arguments, + cid=cid, + real_userid=real_userid, + failure=None, + result=result, + ) + return result + + # -- emission --------------------------------------------------------- + + def _emit_outcome( + self, + audit: AuditLogger, + *, + tool_name: str, + arguments: dict[str, Any] | None, + cid: str | None, + real_userid: dict[str, str], + failure: BaseException | None, + result: Any = None, + ) -> None: + """Emit whichever records this call earned. Never raises.""" + try: + if isinstance(failure, NotFoundError): + # The tool does not exist as far as this server is concerned — + # either the name is wrong, or the tool was withheld at + # registration by read-only mode or the disabled set. Nothing + # executed, so emitting a tool-call record would state an + # ``execution_error`` that never happened, and withheld tools + # deliberately produce no audit event: a client is never told + # they exist, so there is no refusal to record. The enforced + # tool surface is captured once by the server-configuration + # event at startup instead. + logger.debug( + "No audit record for unknown or withheld tool %r", tool_name + ) + return + + confirmation = state.get_confirmation() + refusal = state.get_refusal() + + # The confirmation wrapper sits *outside* the tool and the SQL++ + # gates sit inside it, so a call can skip confirmation and then be + # refused anyway. 57491 says the tool "executed without + # confirmation" and carries ``outcome=success``; emitting it for a + # call a gate then blocked asserts an execution that never + # happened. The refusal below is the whole truth about that call. + if confirmation == state.CONFIRMATION_SKIPPED and refusal is None: + # Not ``blocked``: the tool *ran*, unconfirmed, which is what + # the catalogue entry describes. Recording it as blocked would + # make a reviewer counting ``outcome=blocked`` read unconfirmed + # destructive executions as prevented ones — the exact opposite + # of what happened. The ``reason`` carries the disposition. + audit.emit_event( + AuditEvent.CONFIRMATION_SKIPPED, + outcome=OUTCOME_SUCCESS, + real_userid=real_userid, + cid=cid, + reason="confirmation_unsupported", + tool_name=tool_name, + confirmation="skipped", + ) + + if refusal is not None: + self._emit_refusal( + audit, + refusal=refusal, + tool_name=tool_name, + cid=cid, + real_userid=real_userid, + ) + return + + self._emit_tool_call( + audit, + tool_name=tool_name, + arguments=arguments, + cid=cid, + real_userid=real_userid, + confirmation=confirmation, + failure=failure, + result=result, + ) + except Exception: # auditing must never break a call + logger.exception("Failed to emit an audit record for tool %r", tool_name) + + def _emit_refusal( + self, + audit: AuditLogger, + *, + refusal: dict[str, Any], + tool_name: str, + cid: str | None, + real_userid: dict[str, str], + ) -> None: + """Emit the core authorization or guardrail record for a refused call.""" + event = _REFUSAL_EVENTS.get(int(refusal["event_id"])) + if event is None: # pragma: no cover - guarded by the state contract + logger.warning( + "Ignoring audit refusal for unknown event id %r", + refusal.get("event_id"), + ) + return + + payload: dict[str, Any] = {"tool_name": tool_name} + for key, value in refusal.items(): + if key not in ("event_id", "event_name", "outcome", "reason"): + payload[key] = value + + if event is AuditEvent.SCOPE_CHECK_DENIED: + payload.setdefault("client_id", client_id_of_current_token()) + payload.setdefault("scopes", scopes_of_current_token()) + # One shape for this field across every event that carries it: the + # short class, as on tool-call records. ``setdefault`` leaves the + # SQL++ statement gate's explicit value alone, since that gate + # classifies per statement rather than per tool. + _, operation_class = classify_tool(tool_name, package=self._service_package) + payload.setdefault("required_scope", required_scope_for(operation_class)) + + audit.emit_event( + event, + outcome=str(refusal["outcome"]), + real_userid=real_userid, + cid=cid, + reason=str(refusal["reason"]), + **payload, + ) + + def _emit_tool_call( + self, + audit: AuditLogger, + *, + tool_name: str, + arguments: dict[str, Any] | None, + cid: str | None, + real_userid: dict[str, str], + confirmation: str | None, + failure: BaseException | None, + result: Any, + ) -> None: + """Emit the Tier-2 record for a call that actually reached the tool.""" + override = ( + state.get_statement_class() + if is_statement_classified(tool_name, self._service_package) + else None + ) + event = resolve_tool_call_event( + tool_name, + package=self._service_package, + operation_class_override=override, + ) + + outcome = OUTCOME_SUCCESS + reason: str | None = None + if failure is not None: + outcome = OUTCOME_ERROR + # Malformed input and a failed execution are different facts, and + # the PRD gives them different reasons. FastMCP rejects arguments + # against the tool's schema *before* the body runs, so + # ``execution_error`` on one of those asserts an execution that + # never began — on a record that also carries a keyspace and a + # required scope taken from arguments the server refused. + reason = ( + REASON_INVALID_ARGUMENTS + if isinstance(failure, ValidationError) + else REASON_EXECUTION_ERROR + ) + elif getattr(result, "is_error", False): + # A tool that reports failure through the result rather than by + # raising is still a failed operation as far as an auditor cares. + outcome = OUTCOME_ERROR + reason = REASON_EXECUTION_ERROR + + payload: dict[str, Any] = { + "tool_name": tool_name, + "required_scope": required_scope_for(event.operation_class), + "ks": extract_keyspace(arguments), + "cb_userid": self._cb_userid, + "confirmation": confirmation, + } + if not is_classified(tool_name, self._service_package): + # Fail-closed classification: recorded against a write id so it is + # never filtered, and flagged so the placeholder category is never + # mistaken for a real one. + payload["unclassified"] = True + if audit.config.tool_args and arguments: + payload["args"] = arguments + + audit.emit_tool_call( + event, + outcome=outcome, + real_userid=real_userid, + cid=cid, + reason=reason, + **payload, + ) + + +__all__ = ["AuditMiddleware", "extract_keyspace"] diff --git a/src/cb_mcp/audit/record.py b/src/cb_mcp/audit/record.py new file mode 100644 index 00000000..2e573bfc --- /dev/null +++ b/src/cb_mcp/audit/record.py @@ -0,0 +1,189 @@ +"""The audit record and its JSON-Lines serialisation. + +One immutable record per line, matching Couchbase Server and Sync Gateway: +the same field vocabulary (``id``, ``name``, ``description``, ``timestamp``, +``real_userid``, ``cid``, ``outcome``) with identical semantics, so a customer +already running Couchbase auditing gets one format across the estate. + +Key order is fixed rather than incidental. Audit files are read by people as +well as machines, and a stable prefix (id, name, description, timestamp, who, +where, correlation) means a human can scan a file without a parser. ``None`` +values are omitted entirely so absence is unambiguous — notably ``reason``, +which the PRD specifies is present only when ``outcome`` is not ``success``. + +Correlation has one scope: ``cid`` groups the records of one request. There is +deliberately no session identifier — MCP is moving to a stateless model in +which connection identity must not be read as session continuity. +""" + +from __future__ import annotations + +import json +import socket +from dataclasses import dataclass, field +from datetime import datetime, timezone +from importlib.metadata import PackageNotFoundError, version +from typing import Any + +_PACKAGE_NAME = "couchbase-mcp-server" + +#: MCP-layer dispositions, per the PRD. +OUTCOME_SUCCESS = "success" +OUTCOME_DENIED = "denied" +OUTCOME_BLOCKED = "blocked" +OUTCOME_ERROR = "error" + +#: Reasons recorded on a non-success tool call. +REASON_EXECUTION_ERROR = "execution_error" +REASON_INVALID_ARGUMENTS = "invalid_arguments" + +#: Reason recorded when a bearer token is rejected. Deliberately a single +#: value: FastMCP's JWT verifier collapses expiry, signature, audience and +#: issuer failures into one ``None`` return, so a finer taxonomy would have to +#: be re-derived by decoding the token a second time. Out of phase-1 scope. +REASON_TOKEN_INVALID = "token_invalid" # noqa: S105 - an audit reason, not a credential + + +def _server_version() -> str: + try: + return version(_PACKAGE_NAME) + except PackageNotFoundError: # pragma: no cover - editable/source checkouts + return "unknown" + + +def _hostname() -> str: + try: + return socket.gethostname() + except Exception: # pragma: no cover - defensive + return "unknown" + + +def utc_timestamp() -> str: + """Current UTC time as ``YYYY-MM-DDTHH:MM:SS.mmmZ``. + + Millisecond precision with a literal ``Z``, matching the PRD's samples and + Sync Gateway's audit format. ``datetime.isoformat`` would emit microseconds + and a ``+00:00`` offset, so the value is assembled explicitly. + """ + now = datetime.now(timezone.utc) + return f"{now.strftime('%Y-%m-%dT%H:%M:%S')}.{now.microsecond // 1000:03d}Z" + + +#: The three characters Python's ``str.splitlines`` treats as line terminators +#: and ``json.dumps`` leaves raw under ``ensure_ascii=False``. Mapped back to +#: their escape sequences so one record is always exactly one line. +#: +#: Only three, not the whole ``splitlines`` set: ``json.dumps`` already escapes +#: every character below ``0x20``, so VT, FF, FS, GS and RS can never reach the +#: output raw and listing them would imply a guard that does nothing. +_LINE_TERMINATOR_ESCAPES = str.maketrans( + { + "\u2028": "\\u2028", + "\u2029": "\\u2029", + "\x85": "\\u0085", + } +) + + +@dataclass(frozen=True) +class ServerContext: + """Static per-process context, resolved once at startup.""" + + version: str + host: str + + @classmethod + def detect(cls) -> ServerContext: + return cls(version=_server_version(), host=_hostname()) + + def as_dict(self) -> dict[str, str]: + return {"version": self.version, "host": self.host} + + +@dataclass +class AuditRecord: + """A single audit event. + + Args: + id: Catalogue event id. + name: Catalogue event name. + description: Catalogue event description. + outcome: MCP-layer disposition. + real_userid: ``{"domain": ..., "user": ...}`` access-decision identity. + Omitted for records that are not attributable to a caller, such as + server start. + cid: Server-minted correlation id joining the records of one request. + reason: Present only when ``outcome`` is not ``success``. + payload: Event-specific fields, merged in after the fixed prefix. + """ + + id: int + name: str + description: str + outcome: str + real_userid: dict[str, str] | None = None + cid: str | None = None + reason: str | None = None + timestamp: str = field(default_factory=utc_timestamp) + payload: dict[str, Any] = field(default_factory=dict) + + def as_dict(self, server: ServerContext) -> dict[str, Any]: + """Materialise the record in wire order, dropping absent fields.""" + document: dict[str, Any] = { + "id": self.id, + "name": self.name, + "description": self.description, + "timestamp": self.timestamp, + } + if self.real_userid is not None: + document["real_userid"] = self.real_userid + document["server"] = server.as_dict() + if self.cid is not None: + document["cid"] = self.cid + document["outcome"] = self.outcome + if self.reason is not None: + document["reason"] = self.reason + for key, value in self.payload.items(): + if value is not None: + document[key] = value + return document + + def to_json_line(self, server: ServerContext) -> str: + """Serialise to a single JSON line, newline included. + + ``default=str`` guarantees a line is always produced: tool arguments + can contain arbitrary caller-supplied values, and a record that failed + to serialise would be a silently missing audit entry — strictly worse + than a stringified value. ``ensure_ascii=False`` keeps non-Latin + document ids and keyspace names readable. + + **One record is one line.** That is the whole contract of JSON Lines, + and a caller must not be able to break it. ``ensure_ascii=False`` leaves + U+2028 and U+2029 as raw bytes, and Python's ``str.splitlines`` — used + by this repo's own readers, and by many log shippers — treats both as + line terminators. A bucket or document name containing one would split + its own record into unparseable fragments, destroying the very entry + that recorded the call. They are escaped back to their ``\\u`` form, + which is still valid JSON and reads identically once parsed. + """ + line = json.dumps( + self.as_dict(server), + ensure_ascii=False, + separators=(",", ":"), + default=str, + ) + return line.translate(_LINE_TERMINATOR_ESCAPES) + "\n" + + +__all__ = [ + "OUTCOME_BLOCKED", + "OUTCOME_DENIED", + "OUTCOME_ERROR", + "OUTCOME_SUCCESS", + "REASON_EXECUTION_ERROR", + "REASON_INVALID_ARGUMENTS", + "REASON_TOKEN_INVALID", + "AuditRecord", + "ServerContext", + "utc_timestamp", +] diff --git a/src/cb_mcp/audit/sink.py b/src/cb_mcp/audit/sink.py new file mode 100644 index 00000000..4329bb34 --- /dev/null +++ b/src/cb_mcp/audit/sink.py @@ -0,0 +1,955 @@ +"""The audit sinks: a per-process JSON-Lines file, and stderr. + +Three properties drive the file sink's design. + +**No shared-file rotation.** ``logging.handlers.RotatingFileHandler`` is not +multi-process safe, and the default stdio deployment is inherently +multi-process: every MCP client spawns its own server process, and they all +read the same ``CB_MCP_AUDIT_LOG_FILE_PATH`` from the environment. Sharing one +rotating file across them produces interleaved partial lines and rotation races +that silently destroy records. So each writer gets its **own** file, named +``audit...log``. No locking is needed because no two processes ever +touch the same file, and it behaves identically on every platform. This matches +the PRD's own position that auditing is per-node and consolidation is the +operator's responsibility. + +The host is part of the name, not just the pid, because the pid alone is not +unique across containers. This image's ``ENTRYPOINT`` is exec form, so the +server is **PID 1 in every container**: several containers sharing one mounted +volume would every one of them write ``audit.1.log`` — precisely the +interleaving this design exists to prevent, in the configuration the Docker +documentation recommends. Docker assigns each container a distinct hostname by +default, which restores uniqueness. Two containers explicitly given the same +hostname and the same volume will still collide; that is documented rather than +defended against. + +**Two independent rotation triggers.** Size and age are separate switches, +either of which may be off (``0``). The stdlib cannot do both — ``Rotating-`` +and ``TimedRotatingFileHandler`` are alternatives, which is exactly what the +PRD discussion concluded — but this sink owns its own rotation, so honouring +both is a two-line condition rather than a library swap. Whichever trigger fires +first rotates the file; with both off the file simply grows, which is the PRD's +"store them all" case. + +**Never block the event loop.** ``emit`` only puts a formatted line on a +bounded queue; a dedicated daemon thread does the writing, rotating, flushing +and compressing. A synchronous write from async middleware would stall the whole +server on every tool call. When the queue is full, records are dropped and +counted rather than applying back-pressure to tool execution. + +Runtime failures — disk full, permissions revoked, the file unmounted — are +reported loudly to the operational log as errors and counted, and the server +keeps serving: audit unavailability must not become an outage. +""" + +from __future__ import annotations + +import contextlib +import glob +import gzip +import logging +import os +import queue +import re +import shutil +import socket +import sys +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import IO, Protocol + +from ..utils.constants import LOGGER_NAMESPACE + +logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.sink") + +#: Bounded queue depth. Deep enough to absorb a burst of tool calls while the +#: writer thread is mid-flush, shallow enough that a wedged disk cannot grow +#: memory without limit. +DEFAULT_QUEUE_SIZE = 10_000 + +#: Lines coalesced into a single write+flush by the writer thread. +_WRITE_BATCH = 64 + +#: Minimum seconds between repeated write-failure error records, so a full disk +#: cannot flood the operational log. +_ERROR_LOG_INTERVAL_SECONDS = 60.0 + +#: Seconds the writer thread is given to drain on close. +DEFAULT_CLOSE_TIMEOUT = 5.0 + +#: How long the writer blocks on an empty queue before re-checking whether the +#: sink has been closed, and whether an interval rotation has fallen due. The +#: sentinel normally wakes it immediately; this is the fallback for the one case +#: the sentinel cannot cover — a queue already full when ``close`` runs, so the +#: sentinel could not be posted — and it is also what lets a completely idle +#: server still roll its file over at the configured interval. +_POLL_SECONDS = 0.2 + +_SENTINEL = object() + + +class _WriterExitError(Exception): + """Raised inside the writer loop to end it deliberately. + + Distinct from an error so the loop's blanket handler can tell "stop now" + from "something broke"; only the former ends the thread. + """ + + +#: Timestamp appended to a rotated file. Compact ISO 8601 basic format in UTC, +#: to the microsecond: filename-safe on every platform and fixed-width. The +#: sub-second part is not decoration — a small size cap rotates many times a +#: second, and a second-resolution stamp would make those backups +#: indistinguishable. +_BACKUP_TIMESTAMP_FORMAT = "%Y%m%dT%H%M%S%fZ" + +#: Matches the suffix this sink appends to a rotated file: the timestamp, an +#: optional ``-N`` disambiguator, and an optional ``.gz``. Used both to find +#: *this* sink's backups when pruning — so an unrelated file sitting beside +#: them is never deleted — and to read their age back out, which is what orders +#: them for retention. +_BACKUP_SUFFIX = re.compile(r"\.(?P\d{8}T\d{12}Z)(?:-(?P\d+))?(?:\.gz)?$") + + +#: Characters kept from a hostname. Anything else — dots that would confuse the +#: suffix split, path separators, whitespace — collapses to a single dash. +_HOST_UNSAFE = re.compile(r"[^A-Za-z0-9_-]+") + + +def _host_token(host: str | None = None) -> str: + """A filename-safe short hostname, or ``unknown`` if it cannot be read.""" + try: + raw = socket.gethostname() if host is None else host + except Exception: # pragma: no cover - defensive + return "unknown" + # Short form only: an FQDN would put dots in the middle of the filename. + token = _HOST_UNSAFE.sub("-", (raw or "").split(".")[0]).strip("-") + return token[:64] or "unknown" + + +#: Filename used when CB_MCP_AUDIT_LOG_FILE_PATH names a directory rather +#: than a file, so a trailing slash writes inside the directory instead of +#: beside it. +DEFAULT_AUDIT_FILENAME = "audit.log" + + +def process_scoped_path( + path: str | os.PathLike[str], + pid: int | None = None, + host: str | None = None, +) -> Path: + """Insert the host and process id before the file extension. + + ``audit.log`` becomes ``audit...log``; a path with no suffix + becomes ``audit..``. Exposed separately so startup can log, and + tests can assert, the exact file that will be written. + + The host is included because the pid is not unique across containers: this + image's ENTRYPOINT is exec form, so the server is PID 1 in every container + and a shared volume would otherwise give every container ``audit.1.log``. + """ + resolved = Path(path) + + # A path that names a directory rather than a file gets the default + # filename inside it. Two shapes reach here from an operator's + # CB_MCP_AUDIT_LOG_FILE_PATH, and without this guard each fails badly: + # + # "/" with_name() raises ValueError on a path with no name, + # and resolve_audit_config does not catch it, so the + # server would refuse to start -- contradicting this + # module's rule that a broken file sink degrades rather + # than aborts. + # "/var/log/cbmcp/" would yield /var/log/cbmcp.., a *sibling* + # of the directory the operator mounted and chmod'ed + # rather than a file inside it. Silent, and the likelier + # typo of the two. + # The trailing separator has to be read off the original string: Path() + # normalises it away, so Path("/var/log/cbmcp/").name is already "cbmcp" + # and nothing downstream can tell it from a filename. + raw = os.fspath(path) + looks_like_a_directory = raw.endswith((os.sep, "/")) or ( + os.altsep and raw.endswith(os.altsep) + ) + if looks_like_a_directory or resolved.name == "" or resolved.is_dir(): + resolved = resolved / DEFAULT_AUDIT_FILENAME + + actual_pid = os.getpid() if pid is None else pid + scope = f"{_host_token(host)}.{actual_pid}" + if resolved.suffix: + return resolved.with_name(f"{resolved.stem}.{scope}{resolved.suffix}") + return resolved.with_name(f"{resolved.name}.{scope}") + + +class AuditSinkProtocol(Protocol): + """What the emitter needs from a sink, whatever it writes to. + + Each method below is bodied by its docstring alone. An ellipsis would be + an expression statement with no effect, which static analysis flags — and + rightly, since nothing distinguishes a deliberate protocol stub from a + line someone left unfinished. The docstring is a real body, says what an + implementation owes the emitter, and leaves no empty statement behind. + + Not ``runtime_checkable``, matching :mod:`cb_mcp.core.contracts`: such a + protocol checks method *names* only, never signatures, so an ``isinstance`` + against it would be more misleading than useful. Nothing branches on a + sink's type — ``init_audit`` builds the set it was configured to build. + """ + + def start(self) -> None: + """Begin accepting records. Idempotent; may be a no-op.""" + + def emit(self, line: str) -> None: + """Write one already-formatted JSON line. Must never raise or block.""" + + def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: + """Flush and stop, within ``timeout`` seconds. Safe to call twice.""" + + @property + def stats(self) -> dict[str, int]: + """``written`` / ``dropped`` / ``write_errors`` for the status tool.""" + + +class _QueuedSink: + """Producer/consumer plumbing for a sink that must never block its caller. + + ``emit`` only puts a line on a bounded queue; a dedicated daemon thread does + the writing. **Every** destination needs this, not just the file: the caller + is the asyncio event loop, and a synchronous write there — to a disk, a pipe + or a terminal — stalls every in-flight tool call and the JSON-RPC reader + with it. When the queue is full, records are dropped and counted rather than + applying back-pressure to tool execution. That is the deliberate trade: + audit unavailability must not become an outage. + + Subclasses implement :meth:`_write_batch`, and may override :meth:`_on_idle` + (work to do while no records are arriving) and :meth:`_on_close` (releasing + whatever the subclass owns). + """ + + #: Writer thread name, per sink, so a stack dump says which destination is + #: stuck. + _THREAD_NAME = "cb-mcp-audit-writer" + + def __init__(self, *, queue_size: int = DEFAULT_QUEUE_SIZE) -> None: + self._queue: queue.Queue = queue.Queue(maxsize=queue_size) + self._thread: threading.Thread | None = None + self._closed = threading.Event() + + # Counters are only mutated by the writer thread, except ``dropped`` + # which is incremented by the caller. Both are plain ints guarded by + # the GIL for a single increment; exactness under contention is not + # required for a diagnostic counter. + self._written = 0 + self._dropped = 0 + self._errors = 0 + self._last_error_log = 0.0 + self._last_producer_error_log = 0.0 + + # -- lifecycle -------------------------------------------------------- + + def start(self) -> None: + """Start the writer thread. Idempotent.""" + if self._thread is not None: + return + self._thread = threading.Thread( + target=self._run, name=self._THREAD_NAME, daemon=True + ) + self._thread.start() + + def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: + """Drain the queue, stop the writer thread, release the destination. + + Safe to call more than once. Best-effort: a wedged destination cannot be + allowed to hang shutdown, so the drain is bounded by ``timeout``. + """ + if self._closed.is_set(): + return + self._closed.set() + if self._thread is not None: + # Best-effort: on a full queue the sentinel cannot be posted, and + # dropping a queued record to make room for it would lose an audit + # line purely to shut down faster. The writer polls the closed flag + # instead, so it still exits once it has drained what it has. + with contextlib.suppress(queue.Full): + self._queue.put_nowait(_SENTINEL) + self._thread.join(timeout=timeout) + self._thread = None + else: + # Never started. Records queued before ``start`` would otherwise be + # discarded in silence, with neither ``written`` nor ``dropped`` + # accounting for them. + self._drain_remaining() + + # Drain rather than measure. qsize() only reads the queue, and on a + # timed-out join the writer is still alive -- which is the case this + # branch exists for. It would then pull these same records, fail to + # write them against a closed stream, and count them a second time: 500 + # lost records reported as dropped: 1000. Since `dropped` is the only + # way an operator learns records were lost, a doubled figure is exactly + # what they would act on. Taking the items out makes the count exact + # and leaves the writer nothing to re-count. + abandoned = 0 + while True: + try: + item = self._queue.get_nowait() + except queue.Empty: + break + if item is not _SENTINEL: + abandoned += 1 + if abandoned: + # The join timed out on a wedged destination. Those records are + # gone; say so, because an audit gap nobody can see is the one + # outcome this sink exists to avoid. + self._dropped += abandoned + self._log_failure( + f"{abandoned} audit record(s) still queued when the writer was " + f"given up on after {timeout:g}s" + ) + self._on_close() + + # -- producer side ---------------------------------------------------- + + def emit(self, line: str) -> None: + """Queue one already-formatted line. Never raises, never blocks.""" + if self._closed.is_set(): + self._dropped += 1 + return + try: + self._queue.put_nowait(line) + except queue.Full: + self._dropped += 1 + self._log_failure("audit queue is full", producer=True) + + @property + def stats(self) -> dict[str, int]: + """Counters for ``get_server_configuration_status``. + + ``dropped`` is the number that matters: it is the only way an operator + learns that audit records were lost. + """ + return { + "written": self._written, + "dropped": self._dropped, + "write_errors": self._errors, + } + + # -- writer thread ---------------------------------------------------- + + def _run(self) -> None: + """Writer loop. Must not exit on an unexpected error. + + Every exit from this method is permanent: :meth:`start` returns early + when ``self._thread is not None``, so nothing restarts the writer. An + exception escaping ``_write_batch`` or ``_on_idle`` therefore stops all + audit delivery for the life of the process, silently, with no counter + moving. + + ``_write_batch`` guards the errors it expects (``OSError``, + ``ValueError``). The ones it cannot expect are what this wrapper is + for: ``sys.stderr`` is ``None`` under pythonw and under hosts that + detach stdio, so the console sink's ``stream.write`` raises + ``AttributeError`` -- and console is the default sink. + """ + while True: + try: + self._run_once() + except _WriterExitError: + return + except Exception: # the loop must outlive any error + # Already-dequeued records are gone; count them so the loss is + # visible rather than silent. + self._dropped += 1 + self._log_failure( + "audit writer hit an unexpected error; the record was " + "dropped and the writer is continuing" + ) + + def _run_once(self) -> None: + """One iteration of the writer loop. Raises :class:`_WriterExitError` to stop.""" + while True: + try: + item = self._queue.get(timeout=_POLL_SECONDS) + except queue.Empty: + # Nothing left to write. Exit only once the sink is closed, + # which is what lets the thread finish when ``close`` could not + # post the sentinel into a full queue. + if self._closed.is_set(): + raise _WriterExitError from None + self._on_idle() + continue + if item is _SENTINEL: + self._drain_remaining() + raise _WriterExitError from None + batch = [item] + # Coalesce whatever else is already waiting into one write+flush. + while len(batch) < _WRITE_BATCH: + try: + nxt = self._queue.get_nowait() + except queue.Empty: + break + if nxt is _SENTINEL: + self._write_batch(batch) + self._drain_remaining() + raise _WriterExitError from None + batch.append(nxt) + self._write_batch(batch) + + def _drain_remaining(self) -> None: + """Write anything queued behind the sentinel before the writer exits. + + ``emit`` checks the closed flag and then queues; ``close`` sets the flag + and then posts the sentinel. A record that passes the check just before + the flag is set lands *behind* the sentinel, and without this drain the + writer would return without it — unwritten and uncounted, which is the + one outcome an audit sink must not produce quietly. + + It narrows that window rather than closing it: a record queued after the + final ``get_nowait`` below is still lost, and still uncounted. Closing it + completely would mean holding a lock across ``emit``, which is what the + whole queue design exists to avoid. + """ + leftovers: list[str] = [] + while True: + try: + item = self._queue.get_nowait() + except queue.Empty: + break + if item is not _SENTINEL: + leftovers.append(item) + if leftovers: + self._write_batch(leftovers) + + # -- subclass hooks --------------------------------------------------- + + def _write_batch(self, lines: list[str]) -> None: + raise NotImplementedError + + def _on_idle(self) -> None: + """Called on the writer thread whenever the queue is empty.""" + + def _on_close(self) -> None: + """Release whatever the subclass owns, once the writer has stopped.""" + + def _describe(self) -> str: + """Short identifier for failure reports.""" + return type(self).__name__ + + def _log_failure(self, message: str, *, producer: bool = False) -> None: + """Report a sink failure as an error, rate-limited. + + The first failure is always reported; subsequent ones at most once per + interval, carrying the running totals so the operational log shows the + scale of the gap without being flooded by it. + + The producer side (a full queue) and the writer side (a failed write, + rotation or compression) are rate-limited **separately**. They are + different faults with different fixes, and sharing one timer meant a + burst of queue-full reports could hide a disk failure for a minute. + """ + now = time.monotonic() + last = self._last_producer_error_log if producer else self._last_error_log + if last and now - last < _ERROR_LOG_INTERVAL_SECONDS: + return + if producer: + self._last_producer_error_log = now + else: + self._last_error_log = now + logger.error( + "Audit sink failure: %s (sink=%s, dropped=%d, write_errors=%d). " + "Audit records are being lost; the server continues to serve.", + message, + self._describe(), + self._dropped, + self._errors, + ) + + +class ConsoleAuditSink(_QueuedSink): + """Writes audit lines to stderr, on a background thread. + + **stderr, never stdout.** Under the stdio transport stdout *is* the JSON-RPC + channel: one audit line written there corrupts the client's stream and takes + the session down. The PRD calls this sink "console"; the stream is stderr, + and that is not configurable. + + **Queued, like the file sink.** This wrote inline once, reasoning that a + console is a pipe or a terminal rather than a disk that can fill. A pipe is + exactly what fills: under stdio, stderr belongs to the MCP client, and a + client that does not drain it blocks the writer at the 64 KiB pipe buffer — + which, written inline, is the whole event loop. Since ``console`` is also + the *default* sink, that made enabling auditing alone enough to hang the + server. The queue bounds the damage to dropped records, which are counted. + """ + + _THREAD_NAME = "cb-mcp-audit-console" + + def __init__( + self, stream: IO[str] | None = None, *, queue_size: int = DEFAULT_QUEUE_SIZE + ) -> None: + super().__init__(queue_size=queue_size) + self._stream = stream + + def _describe(self) -> str: + return "console" + + def _write_batch(self, lines: list[str]) -> None: + # Resolved per batch rather than held: pytest's capsys, and a host that + # redirects output, both replace ``sys.stderr`` after construction. + stream = self._stream if self._stream is not None else sys.stderr + if stream is None: + # sys.stderr is None under pythonw and under hosts that detach + # stdio. Without this the write raises AttributeError, which the + # except clause below does not cover; on the default sink that + # would take the writer thread down permanently. + self._dropped += len(lines) + self._log_failure( + f"no console stream available; dropped {len(lines)} audit record(s)" + ) + return + buffered = 0 + try: + for line in lines: + stream.write(line) + buffered += 1 + stream.flush() + except (OSError, ValueError) as exc: + # Every line in this batch is lost, not just the ones that had not + # been handed to ``write`` yet: a buffered line that is never + # flushed never reaches the console. ``dropped`` is documented as + # the only way an operator learns of a gap, so it must account for + # all of them. + self._errors += 1 + self._dropped += len(lines) + self._log_failure(f"failed to write to the console: {exc}") + return + self._written += buffered + + +class CompositeAuditSink: + """Fans every record out to several sinks. + + One failing sink must not stop the others: each is given the line + independently, and no sink's ``emit`` is allowed to raise anyway. Stats are + summed, so an operator reading ``get_server_configuration_status`` sees the + total records lost across destinations rather than having to add up a list. + """ + + def __init__(self, sinks: list[AuditSinkProtocol]) -> None: + self._sinks = list(sinks) + + @property + def sinks(self) -> tuple[AuditSinkProtocol, ...]: + return tuple(self._sinks) + + def start(self) -> None: + for sink in self._sinks: + sink.start() + + def emit(self, line: str) -> None: + for sink in self._sinks: + sink.emit(line) + + def close(self, timeout: float = DEFAULT_CLOSE_TIMEOUT) -> None: + for sink in self._sinks: + sink.close(timeout=timeout) + + @property + def stats(self) -> dict[str, int]: + totals = {"written": 0, "dropped": 0, "write_errors": 0} + for sink in self._sinks: + for key, value in sink.stats.items(): + totals[key] = totals.get(key, 0) + value + return totals + + +class AuditSink(_QueuedSink): + """Writes audit lines to a per-process file on a background thread.""" + + _THREAD_NAME = "cb-mcp-audit-writer" + + def __init__( + self, + path: str | os.PathLike[str], + *, + max_bytes: int, + max_backups: int, + interval_seconds: int = 0, + compress: bool = True, + queue_size: int = DEFAULT_QUEUE_SIZE, + ) -> None: + """Open the sink's file, creating parent directories as needed. + + ``max_bytes`` of 0 turns size-based rotation off, ``interval_seconds`` + of 0 turns age-based rotation off, and both off means one file that + grows for as long as the process runs. Only negative values are + rejected — they are typos, not instructions. + + Raises: + OSError: if the file cannot be created or opened. The caller is + expected to report this and continue without auditing rather + than abort startup. + """ + if max_bytes < 0: + raise ValueError(f"max_bytes must not be negative, got {max_bytes}.") + if max_backups < 0: + raise ValueError(f"max_backups must not be negative, got {max_backups}.") + if interval_seconds < 0: + raise ValueError( + f"interval_seconds must not be negative, got {interval_seconds}." + ) + + super().__init__(queue_size=queue_size) + self.path = process_scoped_path(path) + self._max_bytes = max_bytes + self._max_backups = max_backups + self._interval = interval_seconds + self._compress = compress + + self.path.parent.mkdir(parents=True, exist_ok=True) + # Block-buffered text append: durability comes from the explicit flush + # at the end of each batch, not from the open mode. Opened eagerly so a + # misconfigured path fails at startup, where it can be reported, rather + # than on the first audited operation. + # Deliberately not a context manager: the stream is owned for the + # lifetime of the sink and written by the background thread. Closing it + # per record would defeat both the batching and the append semantics. + self._stream = open(self.path, "a", encoding="utf-8") # noqa: SIM115 + self._size = self.path.stat().st_size + self._rotate_at = self._next_rotation_deadline(from_existing_file=True) + + def _next_rotation_deadline(self, *, from_existing_file: bool = False) -> float: + """When the interval trigger next falls due, or ``inf`` when it is off. + + On open the clock is anchored to the file's **last modification time** + rather than to now, so a process does not hand the live file a fresh + interval it has not earned. Once running, each rotation sets the next + deadline from the clock, so a busy file cannot push its own deadline + forward by being written to. + + Reaching that anchoring at all requires a file to be there already, and + the live filename carries the process id — ``audit...log``. + So the only way a new process finds one is pid reuse: the operating + system hands out a pid an earlier server held, and that server's file + is still on disk. Uncommon, but not rare over a container's lifetime. + + The anchoring is what makes that case safe. The earlier file is older + than the interval in any realistic case, so it is already due and + rotates on the first record — the previous process's records go to a + timestamped backup and this process starts a clean file, rather than + the two interleaving in one file with nothing to tell them apart. + """ + if self._interval <= 0: + return float("inf") + anchor = time.time() + if from_existing_file: + try: + stat = self.path.stat() + if stat.st_size > 0: + anchor = stat.st_mtime + except OSError: # pragma: no cover - file was just opened + pass + return anchor + self._interval + + # -- file-specific hooks ---------------------------------------------- + + def _describe(self) -> str: + return str(self.path) + + def _on_idle(self) -> None: + """An idle server still ages its file. + + Without this, a daily rotation on a quiet deployment would not happen + until the next record arrived — possibly days late, with a day's worth + of records already in the wrong file. + """ + self._rotate_on_idle() + + def _on_close(self) -> None: + """Flush and close the live file once the writer has stopped.""" + try: + if not self._stream.closed: + self._stream.flush() + self._stream.close() + except (OSError, ValueError): # pragma: no cover - nothing left to do + # ValueError is "I/O operation on closed file". The writer may still + # be draining past the join timeout and may close the stream between + # the check above and the flush; letting that escape would abort + # shutdown — including the sibling sinks a composite is still + # closing, and the atexit hook. + pass + + def _reopen_if_closed(self) -> None: + """Re-establish the stream if it is not open. + + Rotation closes the live file before reopening it, so a failure in + between — a full disk, a revoked permission, an unmounted volume — + leaves the sink holding a closed handle. Without this the sink would + never write again even after the condition cleared, which is the worst + possible failure mode for an audit log: silent and permanent. Recovery + is attempted per record, so the first write after the disk frees up + succeeds. + """ + if self._stream is not None and not self._stream.closed: + return + if self._closed.is_set(): + # ``close`` joins the writer with a timeout and then closes the + # stream, so a writer still draining past that timeout — the wedged + # filesystem this timeout exists for — would otherwise reopen a file + # nobody will close again, and append records dated after "server + # stopped". Refuse, and let the caller count the record as dropped. + raise ValueError("audit sink is closed") + self._stream = open(self.path, "a", encoding="utf-8") # noqa: SIM115 + self._size = self.path.stat().st_size + + def _write_batch(self, lines: list[str]) -> None: + buffered = 0 + for line in lines: + try: + encoded_length = len(line.encode("utf-8")) + self._reopen_if_closed() + self._rotate_if_needed(encoded_length) + self._stream.write(line) + self._size += encoded_length + buffered += 1 + except (OSError, ValueError) as exc: + # ValueError is "I/O operation on closed file": reachable when + # ``close`` closes the stream while this thread is still + # draining, and not an OSError subclass. + self._errors += 1 + self._dropped += 1 + self._log_failure(f"failed to write audit record: {exc}") + try: + if not self._stream.closed: + self._stream.flush() + except (OSError, ValueError) as exc: + # The records counted as buffered above never reached the disk. A + # full disk fails here, not at ``write`` — so counting them as + # written at write time reported a clean audit trail for records + # that were lost, and ``dropped`` is documented as the only way an + # operator discovers a gap. + self._errors += 1 + self._dropped += buffered + buffered = 0 + self._log_failure(f"failed to flush audit file: {exc}") + self._written += buffered + + def _interval_is_due(self) -> bool: + """Whether the interval has elapsed, skipping the period if nothing was written. + + An empty live file is never rotated — there is nothing to keep, and a + zero-byte backup would spend a retention slot — but the deadline must + still move on. Leaving it in the past would mean the *first* record + after a quiet period arrives already overdue and is immediately split + off into a backup of its own: with ``max_backups=0`` that record is + truncated away by the very next one, counted as written and never seen + again. + """ + if self._interval <= 0 or time.time() < self._rotate_at: + return False + if self._size == 0: + self._rotate_at = self._next_rotation_deadline() + return False + return True + + def _rotate_on_idle(self) -> None: + """Roll over on the interval alone, with no record waiting to be written.""" + if not self._interval_is_due(): + return + try: + self._reopen_if_closed() + self._rotate() + except (OSError, ValueError) as exc: + self._errors += 1 + self._log_failure(f"failed to rotate the audit file: {exc}") + + def _rotate_if_needed(self, incoming_bytes: int) -> None: + """Roll the file over when either trigger has fallen due. + + The two triggers are independent and either may be off. For size, a + single line larger than ``max_bytes`` is still written rather than + discarded — losing the record would be worse than briefly exceeding the + cap — but it lands in a file of its own on the next rotation. + """ + # Asked first, and unconditionally: it is what advances the deadline + # past a period in which nothing was written. + due_on_interval = self._interval_is_due() + if self._size == 0: + return + due_on_size = ( + self._max_bytes > 0 and self._size + incoming_bytes > self._max_bytes + ) + if not (due_on_size or due_on_interval): + return + self._rotate() + + def _rotate(self) -> None: + """Close the live file, preserve it per the retention policy, reopen.""" + self._stream.flush() + self._stream.close() + + if self._max_backups == 0: + # Retention of zero means no backups at all: the live file is + # truncated, which is the PRD's "limited space, single file" case — + # at the cap, writing starts again from zero and what was there is + # gone. + self._stream = open(self.path, "w", encoding="utf-8") # noqa: SIM115 + self._size = 0 + self._rotate_at = self._next_rotation_deadline() + return + + backup = self._reserve_backup_path() + self.path.replace(backup) + self._size = 0 + self._rotate_at = self._next_rotation_deadline() + try: + self._stream = open(self.path, "a", encoding="utf-8") # noqa: SIM115 + finally: + # Compress and prune in a ``finally`` so a failed reopen — a disk + # that filled at exactly the wrong moment — still leaves the + # retired file compressed and the retention count honoured. The + # caller sees the OSError, counts it and recovers on the next + # record; what it must not find later is an uncompressed orphan + # holding a retention slot for good. + compressed = self._compress_backup(backup) if self._compress else backup + self._prune_backups(keep=compressed) + + def _reserve_backup_path(self) -> Path: + """A free ``.`` path for the file being retired. + + The timestamp is when the rotation happened, in UTC to the microsecond. + Two rotations inside one microsecond — which a small size cap makes + conceivable — would otherwise collide, so a ``-N`` disambiguator is + appended rather than overwriting a backup that was just made. The + search starts above the highest index already present rather than at + the first free one: reusing an index freed by pruning would give a + brand-new backup the sort position of the file it replaced. + """ + stamp = datetime.now(timezone.utc).strftime(_BACKUP_TIMESTAMP_FORMAT) + candidate = self.path.with_name(f"{self.path.name}.{stamp}") + if not candidate.exists() and not _with_gz(candidate).exists(): + return candidate + index = max( + ( + taken + for existing in self._existing_backups() + for taken in (_backup_sort_key(existing)[1],) + if _backup_sort_key(existing)[0] == stamp + ), + default=0, + ) + while True: + index += 1 + candidate = self.path.with_name(f"{self.path.name}.{stamp}-{index}") + if not candidate.exists() and not _with_gz(candidate).exists(): + return candidate + + def _compress_backup(self, backup: Path) -> Path: + """Gzip a rotated file, returning the path that now holds it. + + Audit retention is measured in months, so the backups are the bulk of + what an operator stores; JSON Lines compresses roughly ten to one. The + **live** file is deliberately left uncompressed — ``tail -f`` and + ``grep`` on the current file are how an incident actually gets + investigated. + + A compression failure is reported and the uncompressed backup is kept. + Losing an audit file to save space would invert the entire point. + """ + target = _with_gz(backup) + try: + with ( + open(backup, "rb") as source, + gzip.open(target, "wb") as destination, + ): + shutil.copyfileobj(source, destination) + backup.unlink() + return target + except OSError as exc: + self._errors += 1 + self._log_failure(f"failed to compress the rotated audit file: {exc}") + with contextlib.suppress(OSError): + if target.exists(): + target.unlink() + return backup + + def _existing_backups(self) -> list[Path]: + """This file's own rotated backups, oldest first. + + Matching is deliberately narrow: the glob is anchored to *this* + process's live filename and the suffix must be one this sink wrote. + Per-process filenames mean another server's backups never match, and + the suffix check means an operator's own ``audit.log.notes`` beside them + is never a deletion candidate. + + Ordering is by the *parsed* timestamp and index, not by the raw + filename. Sorting the names would be wrong, and silently so: ``-`` + sorts before ``.``, so ``…Z-1.gz`` precedes ``…Z.gz`` even though it is + the newer file, and ``-10`` precedes ``-2``. Retention would then delete + the most recent history and keep the oldest — the exact inverse of what + every one of the PRD's retention cases asks for. + """ + parent = self.path.parent + try: + # The live filename is escaped before it becomes a glob pattern. + # Unescaped, a path like ``audit[1].log`` reads ``[1]`` as a + # character class, matches nothing, and retention silently stops + # pruning — the directory grows without limit while max_backups + # claims to bound it. + candidates = list(parent.glob(f"{glob.escape(self.path.name)}.*")) + except OSError: # pragma: no cover - directory vanished mid-rotation + return [] + return sorted( + (path for path in candidates if _BACKUP_SUFFIX.search(path.name)), + key=_backup_sort_key, + ) + + def _prune_backups(self, *, keep: Path) -> None: + """Delete the oldest backups beyond ``max_backups``. + + ``keep`` is the backup just created; it is never a deletion candidate + even if the retention count is somehow already exceeded, because + deleting the records that were just rotated out would lose the most + recent history rather than the oldest. + """ + backups = [path for path in self._existing_backups() if path != keep] + excess = len(backups) - (self._max_backups - 1) + for path in backups[:excess] if excess > 0 else []: + try: + path.unlink() + except OSError as exc: # pragma: no cover - racing external deletion + self._log_failure( + f"failed to delete the old audit backup {path}: {exc}" + ) + + +def _with_gz(path: Path) -> Path: + """The compressed name for a backup path.""" + return path.with_name(f"{path.name}.gz") + + +def _backup_sort_key(path: Path) -> tuple[str, int]: + """``(timestamp, index)`` for a rotated file — chronological order. + + The index is the same-microsecond disambiguator, read as a number so ``-2`` + orders before ``-10``, and ``0`` for the undisambiguated first file of its + microsecond so it orders before ``-1``. A path that somehow does not match + sorts first, which makes it the first candidate for deletion rather than + the last — the conservative direction for something unrecognised sitting + among the backups. + """ + match = _BACKUP_SUFFIX.search(path.name) + if match is None: # pragma: no cover - callers filter on the same regex + return ("", 0) + return (match.group("stamp"), int(match.group("index") or 0)) + + +__all__ = [ + "DEFAULT_CLOSE_TIMEOUT", + "DEFAULT_QUEUE_SIZE", + "AuditSink", + "AuditSinkProtocol", + "CompositeAuditSink", + "ConsoleAuditSink", + "process_scoped_path", +] diff --git a/src/cb_mcp/audit/state.py b/src/cb_mcp/audit/state.py new file mode 100644 index 00000000..dbab86e0 --- /dev/null +++ b/src/cb_mcp/audit/state.py @@ -0,0 +1,151 @@ +"""Per-request audit state. + +Holds two kinds of thing for the life of one MCP request: the correlation id +that every record for that request shares, and the refusal channel. + +**Correlation.** ``cid`` identifies one request. It is written once, by the +middleware's outermost hook, and only read afterwards. Keeping it here rather +than passing it down as an argument means tool code can reach it too — which is +what a later phase needs in order to propagate it to Couchbase Server as a +SQL++ ``client_context_id``. + +There is deliberately no session id. MCP is moving to a stateless model in +which a server must not treat connection identity as session continuity, so a +request is the only correlation scope this audit log claims. + +**Refusals.** FastMCP wraps a tool's exception in ``ToolError`` before +middleware sees it, so middleware cannot reliably classify a refusal from the +exception it catches. Instead the raising site records a structured refusal +here, and the middleware reads it after ``call_next`` returns or raises. + +The contextvar holds a **mutable dict** that the middleware installs once per +tool call. Everything downstream mutates that dict *in place* rather than +rebinding the contextvar. This matters: FastMCP runs synchronous tools on a +worker thread via ``anyio.to_thread``, which copies the context. A rebind +inside the worker would be invisible to the middleware, but an in-place +mutation of the shared object is visible because both contexts reference the +same dict. This behaviour is covered by +``tests/unit/test_audit_state.py::test_mutation_from_worker_thread_is_visible``. + +Every accessor is a no-op when no state is installed, so tool code can record +unconditionally without caring whether auditing is enabled. +""" + +from __future__ import annotations + +from contextvars import ContextVar, Token +from typing import Any + +_AUDIT_STATE: ContextVar[dict[str, Any] | None] = ContextVar( + "cb_mcp_audit_state", default=None +) + +#: Recorded when a confirmation-gated tool ran without confirmation because the +#: client does not advertise elicitation support. +CONFIRMATION_SKIPPED = "skipped" + +#: Recorded when the user accepted an elicitation. +CONFIRMATION_ACCEPTED = "accepted" + +#: Recorded when the user rejected an elicitation. +CONFIRMATION_DECLINED = "declined" + + +def install(*, cid: str | None = None) -> Token: + """Install a fresh state dict for this request and return its reset token. + + Args: + cid: Correlation id shared by every record this request produces. + """ + return _AUDIT_STATE.set({"cid": cid}) + + +def reset(token: Token) -> None: + """Restore the previous state, undoing :func:`install`.""" + _AUDIT_STATE.reset(token) + + +def current() -> dict[str, Any] | None: + """Return the live state dict, or ``None`` when auditing is not tracking.""" + return _AUDIT_STATE.get() + + +def record_refusal( + *, event_id: int, event_name: str, outcome: str, reason: str, **extra: Any +) -> None: + """Record a structured MCP-layer refusal for the middleware to emit. + + Safe to call when no state is installed. Only the first refusal in a call + is kept — the earliest gate to fire is the one that actually stopped the + request, and a later one cannot have run. + """ + state = _AUDIT_STATE.get() + if state is None or "refusal" in state: + return + state["refusal"] = { + "event_id": event_id, + "event_name": event_name, + "outcome": outcome, + "reason": reason, + **extra, + } + + +def record_confirmation(status: str) -> None: + """Record the confirmation disposition for this call.""" + state = _AUDIT_STATE.get() + if state is not None: + state["confirmation"] = status + + +def record_statement_class(operation_class: str) -> None: + """Record the read/write class of an inspected SQL++ statement. + + Lets the middleware book a SQL++ call against the query *write* ID when the + statement actually modifies data or structure, and lets the read-only guard + and the audit record share a single parse of the statement. + """ + state = _AUDIT_STATE.get() + if state is not None: + state["statement_class"] = operation_class + + +def get_cid() -> str | None: + """Return this request's correlation id, if state is installed.""" + state = _AUDIT_STATE.get() + return state.get("cid") if state else None + + +def get_refusal() -> dict[str, Any] | None: + """Return the recorded refusal, if any.""" + state = _AUDIT_STATE.get() + return state.get("refusal") if state else None + + +def get_confirmation() -> str | None: + """Return the recorded confirmation disposition, if any.""" + state = _AUDIT_STATE.get() + return state.get("confirmation") if state else None + + +def get_statement_class() -> str | None: + """Return the recorded SQL++ statement class, if any.""" + state = _AUDIT_STATE.get() + return state.get("statement_class") if state else None + + +__all__ = [ + "CONFIRMATION_ACCEPTED", + "CONFIRMATION_DECLINED", + "CONFIRMATION_SKIPPED", + "current", + "get_cid", + "get_confirmation", + "get_refusal", + "get_statement_class", + "install", + "record_confirmation", + "record_refusal", + "record_statement_class", + "reset", +] diff --git a/src/cb_mcp/auth.py b/src/cb_mcp/auth.py index b7cd16dd..4fad46ce 100644 --- a/src/cb_mcp/auth.py +++ b/src/cb_mcp/auth.py @@ -20,12 +20,17 @@ """ import logging +import uuid from typing import ClassVar -from fastmcp.server.auth import AuthProvider, RemoteAuthProvider +from fastmcp.server.auth import AccessToken, AuthProvider, RemoteAuthProvider from fastmcp.server.auth.providers.jwt import JWTVerifier from pydantic import AnyHttpUrl, ValidationError +from .audit.catalog import AuditEvent +from .audit.emitter import get_audit_logger +from .audit.identity import DOMAIN_ANONYMOUS +from .audit.record import OUTCOME_DENIED, REASON_TOKEN_INVALID from .utils.constants import ( DEFAULT_OAUTH_ALGORITHM, LOGGER_NAMESPACE, @@ -108,6 +113,50 @@ def _extract_scopes(self, claims: dict) -> list[str]: raw = super()._extract_scopes(claims) return [self._scope_aliases.get(s, s) for s in raw] + async def verify_token(self, token: str) -> AccessToken | None: + """Validate a bearer token, auditing a rejection. + + This is the one audit hook that cannot live in middleware: a rejected + token is refused in the ASGI auth layer, so no JSON-RPC message is ever + constructed and the MCP message pipeline never runs. + + The rejection is recorded without a fine-grained reason. FastMCP's + verifier collapses expiry, bad signature, wrong audience and wrong + issuer into a single ``None`` return, keeping the discriminating detail + in a debug log line only; re-deriving it would mean decoding the token a + second time and duplicating upstream validation logic. A single + ``token_invalid`` reason is the honest record. ``real_userid`` is + ``anonymous`` because a rejected token establishes no identity. + + A token that is *missing* entirely is refused by the ASGI auth layer + before this method is reached, so it produces no ``token rejected`` + record in this release. An unauthenticated caller is therefore visible + in the audit file only as the absence of records, not as a denial. + + Correlation: this record carries a freshly minted ``cid``, so that + grouping an audit file by ``cid`` works unconditionally, and carries no + ``sid``, because no MCP session exists. It is a deliberate singleton — + the rejection produced a 401 and no MCP message was ever dispatched, so + no sibling record can exist. Nothing is read from the request scope + here on purpose: correlation is minted in middleware precisely so that + a host with a different authentication layer inherits it unchanged. + """ + result = await super().verify_token(token) + if result is None: + audit = get_audit_logger() + if audit.active: + audit.emit_event( + AuditEvent.TOKEN_REJECTED, + outcome=OUTCOME_DENIED, + real_userid={ + "domain": DOMAIN_ANONYMOUS, + "user": DOMAIN_ANONYMOUS, + }, + cid=str(uuid.uuid4()), + reason=REASON_TOKEN_INVALID, + ) + return result + def build_oauth( *, diff --git a/src/cb_mcp/core/app.py b/src/cb_mcp/core/app.py index 1e6ec5df..c7d037e6 100644 --- a/src/cb_mcp/core/app.py +++ b/src/cb_mcp/core/app.py @@ -21,6 +21,17 @@ from fastmcp.server.auth import AuthProvider from fastmcp.tools import FunctionTool +from ..audit import ( + AuditEvent, + AuditMiddleware, + ResolvedAuditConfig, + get_audit_logger, + init_audit, + shutdown_audit, + unclassified_tool_names, + warn_on_unauthenticated_http, +) +from ..audit.record import OUTCOME_ERROR, OUTCOME_SUCCESS from ..utils.constants import ( LOGGER_NAMESPACE, NETWORK_TRANSPORTS, @@ -35,6 +46,70 @@ logger = logging.getLogger(f"{LOGGER_NAMESPACE}.core.app") +def _start_audit( + spec: ServerSpec, + audit_config: ResolvedAuditConfig, + *, + transport: str, + oauth_enabled: bool, + read_only_mode: bool, + registered_tool_names: list[str], + settings: Mapping[str, Any], +) -> ResolvedAuditConfig: + """Initialise auditing and write the two startup records. + + Emits nothing at all when the server declares no audit package, or when + auditing is configured off or its sink could not be opened. + + Returns the configuration **actually in force**, which is not always the + one passed in: a file sink whose path cannot be opened is dropped at this + point. Everything that reports the audit configuration must use the value + returned here, never the requested one. + """ + audit = init_audit(audit_config) + if not audit.active: + return audit.config + + warn_on_unauthenticated_http(transport, oauth_enabled) + + unclassified = unclassified_tool_names(registered_tool_names, spec.audit_package) + if unclassified: + # Fail-closed classification keeps these audited, but an operator + # should not have to discover the gap by reading the audit file. + logger.warning( + "Audit: %d registered tool(s) in server %r have no classification " + "entry and will be recorded against a write event id: %s", + len(unclassified), + spec.id, + unclassified, + ) + + audit.emit_event(AuditEvent.SERVER_STARTED, outcome=OUTCOME_SUCCESS) + audit.emit_event( + AuditEvent.SERVER_CONFIGURATION, + outcome=OUTCOME_SUCCESS, + server_id=spec.id, + service_package=spec.audit_package, + transport=transport, + read_only_mode=read_only_mode, + oauth_enabled=oauth_enabled, + # The withheld and disabled tool sets are recorded here rather than as + # per-call refusals: neither kind of tool is registered with FastMCP, so + # a client is never told they exist and there is no invocation to + # refuse. This record is what makes the enforced surface auditable. + registered_tools=sorted(registered_tool_names), + disabled_tools=sorted(settings.get("disabled_tools", ())), + confirmation_required_tools=sorted( + settings.get("confirmation_required_tools", ()) + ), + # The configuration in force, not the one requested: this record is + # permanent, and claiming a file sink that failed to open would tell an + # auditor records were on disk when none were. + audit_config=audit.config.as_dict(), + ) + return audit.config + + def build_app( spec: ServerSpec, *, @@ -44,6 +119,7 @@ def build_app( auth: AuthProvider | None = None, read_only_mode: bool = True, logging_config: Mapping[str, Any] | None = None, + audit_config: ResolvedAuditConfig | None = None, ) -> FastMCP: """Build the ``FastMCP`` application for ``spec``, ready to ``run()``. @@ -63,14 +139,51 @@ def build_app( populate it without adopting ours. It surfaces via ``get_server_configuration_status``. + ``audit_config`` is threaded through from the host for the same reason as + ``logging_config``: a host that resolves its configuration from somewhere + other than a command line — the managed Capella runtime, for instance — can + populate it without adopting this repo's CLI. Auditing activates only when + the host supplies a config *and* ``spec.audit_package`` names a Tier-2 + block, so a server that has not been given an audit block cannot have its + records booked against another server's ids. + The returned server is *not* started; the caller chooses the transport and calls ``run()``. See :func:`run_app` for the standard invocation. """ + audits = audit_config is not None and spec.audit_package is not None + + if audit_config is not None and audit_config.enabled and spec.audit_package is None: + # Silence here is indistinguishable from a broken audit sink. An + # operator who set CB_MCP_AUDIT_LOG_ENABLED=true on this server would + # otherwise find an empty audit file, or none at all, with the only + # signal being ``active: false`` buried in the status tool. + logger.warning( + "Audit logging is enabled but server %r declares no audit package, " + "so no audit records will be written. Auditing currently applies to " + "the operational server only; the settings are accepted and " + "reported so a shared configuration can be used unchanged.", + spec.id, + ) @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: """Build the lifespan AppContext from host-resolved configuration.""" transport = settings.get("transport") + # Audit is initialised first so the server-started record is the first + # line in the file and every later record shares its static context. + # What auditing is actually doing, which differs from what was asked + # for when a sink could not be opened. The status tool reports this. + effective_audit_config = audit_config + if audits: + effective_audit_config = _start_audit( + spec, + audit_config, # type: ignore[arg-type] + transport=str(transport), + oauth_enabled=bool(settings.get("oauth_enabled", False)), + read_only_mode=read_only_mode, + registered_tool_names=[tool.__name__ for tool in tools], + settings=settings, + ) # Name the server: both servers log into the same hierarchy, so in an # aggregated stream these lines are otherwise indistinguishable. The # wire-visible name is a static fact and lives in the env-info record, @@ -83,26 +196,74 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: # whenever the user runs with --log-level DEBUG. log_environment_info(transport, settings, spec) send_install_ping(transport, server_id=spec.id) - app_context = AppContext( - cluster_provider=provider_factory(), - settings=settings, - read_only_mode=read_only_mode, - logging_config=logging_config, - server_id=spec.id, - server_name=spec.fastmcp_name, - ) + # Built inside the try, not before it: ``provider_factory`` opens a + # connection and can fail, and auditing has already started by this + # point — with a sink open, a writer thread running and the + # server-started record queued. Constructing this outside the try meant + # a provider failure skipped the cleanup below entirely, losing + # whatever was queued and leaving the sink open for the life of the + # process. A failed startup is exactly when the audit trail matters. + app_context: AppContext | None = None + failed = False try: + app_context = AppContext( + cluster_provider=provider_factory(), + settings=settings, + read_only_mode=read_only_mode, + logging_config=logging_config, + audit_config=( + effective_audit_config.as_dict() + if effective_audit_config is not None + else None + ), + server_id=spec.id, + server_name=spec.fastmcp_name, + ) yield app_context except Exception as e: + failed = True logger.error(f"Error in app lifespan: {e}", exc_info=True) raise finally: - if app_context.cluster_provider: + if app_context is not None and app_context.cluster_provider: app_context.cluster_provider.close() + # Recorded before the sink closes so the stop record is the last + # line written. A process killed outright leaves no stop record; + # its absence means "not a clean shutdown", not "tampered with". + if audits: + active_audit = get_audit_logger() + if active_audit.active: + # The catalogue describes this event as "shut down + # cleanly", so it must not claim success for a lifespan + # that exited by raising. A failed startup that recorded + # "server started" followed by "success" would read as a + # healthy run in an audit review. + active_audit.emit_event( + AuditEvent.SERVER_STOPPED, + outcome=OUTCOME_ERROR if failed else OUTCOME_SUCCESS, + reason="lifespan_error" if failed else None, + ) + shutdown_audit() logger.info("Closing MCP server") mcp = FastMCP(spec.fastmcp_name, lifespan=app_lifespan, auth=auth) + # Registered before tools so it wraps every one of them. The middleware + # short-circuits whenever the sink is inactive, so a failed sink degrades to + # no records rather than to a half-instrumented server. ``transport`` + # decides whether an unauthenticated caller is recorded as anonymous (http) + # or as the local process owner (stdio); ``cb_userid`` records the single + # backing identity every caller collapses onto, which is the gap this audit + # log exists to close. + if audits: + mcp.add_middleware( + AuditMiddleware( + transport=str(settings.get("transport")), + cb_userid=settings.get("username"), + service_package=spec.audit_package, # type: ignore[arg-type] + ) + ) + logger.info( f"Registering {len(tools)} tool(s) for server '{spec.id}' " f"with modes (read_only_mode={read_only_mode})" diff --git a/src/cb_mcp/core/cli/options.py b/src/cb_mcp/core/cli/options.py index c336c30d..966af17d 100644 --- a/src/cb_mcp/core/cli/options.py +++ b/src/cb_mcp/core/cli/options.py @@ -26,6 +26,10 @@ from ...utils.constants import ( ALLOWED_OAUTH_ALGORITHMS, ALLOWED_TRANSPORTS, + DEFAULT_AUDIT_ENABLED, + DEFAULT_AUDIT_FILE, + DEFAULT_AUDIT_SINKS, + DEFAULT_AUDIT_TOOL_ARGS, DEFAULT_HOST, DEFAULT_LOG_BACKUP_COUNT, DEFAULT_LOG_LEVEL, @@ -406,6 +410,101 @@ def logging_options(*, default_log_file: str) -> Callable: ) """OAuth resource-server configuration. Shared by every server; scope label defaults are per-server.""" + +audit_options = compose( + click.option( + "--audit-log-enabled", + "audit_log_enabled", + envvar="CB_MCP_AUDIT_LOG_ENABLED", + type=bool, + default=DEFAULT_AUDIT_ENABLED, + help="Enable audit logging. Applies to both stdio and http transports; the " + "set of applicable events differs by transport (authorization events are " + "http-only). Audit output is a separate sink from the --log-* operational " + "logs, with a stable schema and its own retention. Honored only by servers " + "that declare an audit package; on a server that does not, the flag " + "resolves and is reported but nothing is recorded.", + ), + click.option( + "--audit-log-sinks", + "audit_log_sinks", + envvar="CB_MCP_AUDIT_LOG_SINKS", + default=DEFAULT_AUDIT_SINKS, + help="Comma-separated list of audit sinks. Allowed values: console, file. " + "Default is console. 'console' writes to stderr — never stdout, which " + "carries the JSON-RPC protocol under the stdio transport. Include 'file' " + "with --audit-log-file-path for a deployment that retains records.", + ), + click.option( + "--audit-log-file-path", + "audit_log_file_path", + envvar="CB_MCP_AUDIT_LOG_FILE_PATH", + default=DEFAULT_AUDIT_FILE, + help="Path to the audit log file. Required when 'file' is in " + "--audit-log-sinks; if it is missing the server still starts, with the " + "file sink dropped and an error recorded. Each server process writes its " + "own file with the host and process id inserted before the extension " + "(audit.log -> audit.myhost.1234.log), because a single stdio deployment " + "runs one server process per client and sharing one file between them " + "would corrupt records.", + ), + click.option( + "--audit-log-rotation-max-size-mb", + "audit_log_rotation_max_size_mb", + envvar="CB_MCP_AUDIT_LOG_ROTATION_MAX_SIZE_MB", + type=click.FloatRange(min=0), + default=None, + help="Maximum size in MB the live audit file may reach before it rotates. " + "Default is 10 MB. 0 turns size-based rotation off; combined with an " + "interval of 0 that gives a single unbounded file.", + ), + click.option( + "--audit-log-rotation-interval", + "audit_log_rotation_interval", + envvar="CB_MCP_AUDIT_LOG_ROTATION_INTERVAL", + default=None, + help="Age the live audit file may reach before it rotates, as " + " where the unit is 'd' (24 hours) or 'w' (7 days) — e.g. " + "1d, 30d, 1w, 8w. Default is 1d. 0 turns interval-based rotation off. " + "Size and interval are independent: whichever falls due first rotates " + "the file.", + ), + click.option( + "--audit-log-retention-max-backups", + "audit_log_retention_max_backups", + envvar="CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS", + type=click.IntRange(min=0), + default=None, + help="Number of rotated audit files retained, excluding the live file. " + "Default is 10. Rotated files are gzipped and named with the UTC rotation " + "time. Set to 0 to keep no backups at all: the live file is truncated at " + "each rotation, so it stays bounded and nothing older is kept.", + ), + click.option( + "--audit-log-tool-args", + "audit_log_tool_args", + envvar="CB_MCP_AUDIT_LOG_TOOL_ARGS", + type=bool, + default=DEFAULT_AUDIT_TOOL_ARGS, + help="Record tool argument values in the audit log. Off by default: there " + "is no redaction capability in this release, so enabling this writes " + "arguments verbatim, including the full document bodies passed to " + "document-write tools. Review the audit file's retention and access " + "controls before enabling it.", + ), + click.option( + "--audit-log-disabled-events", + "audit_log_disabled_events", + envvar="CB_MCP_AUDIT_LOG_DISABLED_EVENTS", + default=None, + help="Audit events to suppress. Accepts comma-separated numeric event " + "ids (e.g. '61490,61491'), or a file path with one id per line. Ids are " + "listed in the audit descriptor. Only filterable events can be " + "suppressed: write and security events are always recorded, and an " + "attempt to disable one is refused with a warning.", + ), +) +"""Audit sink configuration. Shared by every server; honored where a spec declares an audit package.""" embedding_options = compose( click.option( "--embedding-provider", diff --git a/src/cb_mcp/core/spec.py b/src/cb_mcp/core/spec.py index ecc78e5e..89b8c14d 100644 --- a/src/cb_mcp/core/spec.py +++ b/src/cb_mcp/core/spec.py @@ -186,3 +186,20 @@ class ServerSpec: #: Settings keys whose *presence* may be logged but never their value. secret_settings_keys: tuple[str, ...] = () + + #: Tier-2 audit service package this server's tools are classified under, + #: or ``None`` when this server is not audited. + #: + #: Auditing is opt-in per server rather than automatic, because a Tier-2 + #: block and its category slots are a wire contract: once records exist + #: against an id, that id can never mean anything else. A server with no + #: package would otherwise have every tool fall through classification's + #: fail-closed path and be recorded against *another* server's block — + #: same numbers, different meaning, which is the one failure the two-tier + #: catalogue exists to prevent. + #: + #: ``None`` costs nothing: the audit middleware is simply not registered, + #: and the resolved audit configuration is still reported by + #: ``get_server_configuration_status`` so an operator can see that the + #: flags were honored and nothing is being recorded. + audit_package: str | None = None diff --git a/src/cb_mcp/servers/operational/spec.py b/src/cb_mcp/servers/operational/spec.py index 07d9b00c..7e052321 100644 --- a/src/cb_mcp/servers/operational/spec.py +++ b/src/cb_mcp/servers/operational/spec.py @@ -85,4 +85,5 @@ def _configure_couchbase_sdk_logging(logger_root: str, level: int) -> None: "embedding_aws_access_key_id", "embedding_aws_secret_access_key", ), + audit_package="operational", ) diff --git a/src/cb_mcp/servers/operational_insights/spec.py b/src/cb_mcp/servers/operational_insights/spec.py index 5b8c959c..569c7534 100644 --- a/src/cb_mcp/servers/operational_insights/spec.py +++ b/src/cb_mcp/servers/operational_insights/spec.py @@ -23,6 +23,13 @@ fastmcp_name=FASTMCP_SERVER_NAME, logger_namespace=OPERATIONAL_INSIGHTS_LOGGER_NAMESPACE, display_name="Couchbase Operational Insights MCP Server", + # Audited under its own Tier-2 block (0x10000). It shares the category axis + # with the operational server but not the block: the two collide on tool + # names — get_server_configuration_status, create_index, list_indexes, + # get_collections_in_scope, get_schema_for_collection — and booking this + # server's calls against the operational block would file them under ids + # that mean a different server. + audit_package="operational_insights", tools=TOOL_SET, # Must differ from every other server's values (see ServerSpec.default_port # / default_log_file docstrings). diff --git a/src/cb_mcp/tools/operational/query.py b/src/cb_mcp/tools/operational/query.py index 699d093f..f75fb044 100644 --- a/src/cb_mcp/tools/operational/query.py +++ b/src/cb_mcp/tools/operational/query.py @@ -12,6 +12,9 @@ from fastmcp.server.dependencies import get_access_token from lark_sqlpp import modifies_data, modifies_structure, parse_sqlpp +from ...audit import state as audit_state +from ...audit.emitter import get_audit_logger +from ...audit.exceptions import ReadOnlyWriteBlockedError, StatementScopeDeniedError from ...servers.operational.constants import OPERATIONAL_LOGGER_NAMESPACE from ...utils.constants import SCOPE_WRITE from ...utils.operational.connection import connect_to_bucket, format_keyspace @@ -30,6 +33,28 @@ logger = logging.getLogger(f"{OPERATIONAL_LOGGER_NAMESPACE}.tools.query") +def _query_correlation_options() -> dict[str, str]: + """SDK query options carrying this request's audit correlation id. + + Couchbase Server's SQL++ audit records carry ``clientContextId``, captured + from the query's ``client_context_id``. Propagating our ``cid`` into it + makes an MCP audit record joinable to the Server audit record for the same + statement — an exact join, not a timestamp heuristic. + + Nothing consumes that join yet; it is set now so the correlation exists in + historical data by the time a later phase wants to query it. Empty when + auditing is inactive, so an unaudited call is byte-for-byte the request it + was before. + + Only the query service can do this. The KV protocol exposes no + client-supplied correlation field, so a document-level join between an + MCP record and a Couchbase Server audit record stays approximate: it has + to be made on timestamp and keyspace rather than on a shared id. + """ + cid = audit_state.get_cid() + return {"client_context_id": cid} if cid else {} + + def get_schema_for_collection( ctx: Context, bucket_name: str, @@ -172,10 +197,6 @@ def run_sql_plus_plus_query( function — it keeps FTS-specific result shape (score, fragments, facets) out of general query results and gives clearer tracing of what was searched. """ - cluster = get_cluster_connection(ctx) - - bucket = connect_to_bucket(cluster, bucket_name) - app_context = ctx.request_context.lifespan_context read_only_mode = app_context.read_only_mode @@ -192,6 +213,84 @@ def run_sql_plus_plus_query( lacks_write_scope = token is not None and SCOPE_WRITE not in (token.scopes or []) block_query_writes = read_only_mode or lacks_write_scope + # Classification and the write guard both run *before* the cluster is + # touched. Connecting first meant an unreachable cluster or a missing + # bucket raised before the statement was ever inspected, so a failed + # attempt to run DML was recorded against the query *read* id — and read + # ids are filterable, so "someone tried to UPDATE and it failed" could be + # filtered out of the audit log by an operator turning read noise down. + # Nothing here does I/O: read-only mode and the token both come from the + # request context. + # + # It also means a statement the guard refuses is now refused without a + # connection attempt, so the caller gets the accurate reason (blocked) + # rather than whatever connection error happened to come first. + # EXPLAIN statements are always safe to execute and should bypass write checks. + is_explain = _is_explain_statement(query) + + # This tool is the one whose audit category depends on the statement it + # is given, so when auditing is active the statement must be inspected + # even where the write guard would not have needed to. Otherwise an + # *allowed* DML statement would be recorded against the query-read id, + # and read ids are filterable — a successful mutation could be filtered + # out of the audit log. The parse is done once here and shared with the + # guard below, so an audited call never pays for it twice, and a call + # made with auditing off pays nothing at all. + audit_active = get_audit_logger().active + needs_audit_classification = audit_active and not is_explain + + if is_explain and audit_active: + audit_state.record_statement_class("read") + + if (block_query_writes or needs_audit_classification) and not is_explain: + try: + kind = _blocked_write_kind(parse_sqlpp(query)) + except Exception: + if block_query_writes: + raise + if audit_active: + audit_state.record_statement_class("write") + kind = None + else: + if audit_active: + audit_state.record_statement_class( + "write" if kind is not None else "read" + ) + + if kind is not None and block_query_writes: + if lacks_write_scope and not read_only_mode: + # lacks_write_scope implies token is not None here. + held_scopes = sorted(set(token.scopes or [])) + msg = ( + f"SQL++ {kind} modification requires the " + f"'{SCOPE_WRITE}' scope; token scopes are {held_scopes}." + ) + logger.warning(msg) + audit_state.record_refusal( + event_id=StatementScopeDeniedError.audit_event.id, + event_name=StatementScopeDeniedError.audit_event.event_name, + outcome=StatementScopeDeniedError.audit_outcome, + reason=StatementScopeDeniedError.audit_reason, + required_scope="write", + statement_kind=kind, + ) + raise StatementScopeDeniedError(msg) + msg = f"{kind.capitalize()} modification query is not allowed in read-only mode" + logger.error(msg) + audit_state.record_refusal( + event_id=ReadOnlyWriteBlockedError.audit_event.id, + event_name=ReadOnlyWriteBlockedError.audit_event.event_name, + outcome=ReadOnlyWriteBlockedError.audit_outcome, + reason=ReadOnlyWriteBlockedError.audit_reason, + statement_kind=kind, + ) + raise ReadOnlyWriteBlockedError(msg) + + # Only now is the cluster touched. Everything above is a policy + # decision made from the request context alone. + cluster = get_cluster_connection(ctx) + bucket = connect_to_bucket(cluster, bucket_name) + try: scope = bucket.scope(scope_name) logger.debug( @@ -199,32 +298,14 @@ def run_sql_plus_plus_query( f"(write_blocked={block_query_writes})" ) - # EXPLAIN statements are always safe to execute and should bypass write checks. - if block_query_writes and not _is_explain_statement(query): - parsed_query = parse_sqlpp(query) - kind = _blocked_write_kind(parsed_query) - - if kind is not None: - if lacks_write_scope and not read_only_mode: - # lacks_write_scope implies token is not None here. - held_scopes = sorted(set(token.scopes or [])) - msg = ( - f"SQL++ {kind} modification requires the " - f"'{SCOPE_WRITE}' scope; token scopes are {held_scopes}." - ) - logger.warning(msg) - raise PermissionError(msg) - msg = f"{kind.capitalize()} modification query is not allowed in read-only mode" - logger.error(msg) - raise ValueError(msg) - # Reached only for read-only queries (or when writes are allowed). # Forward named parameters only when provided so existing callers that # pass none keep the exact previous behaviour. + options = _query_correlation_options() result = ( - scope.query(query, named_parameters=named_parameters) + scope.query(query, named_parameters=named_parameters, **options) if named_parameters is not None - else scope.query(query) + else scope.query(query, **options) ) # The SDK's QueryResult streams rows, so this stops reading — and stops # the cluster sending — once the budget is spent, rather than buffering @@ -296,7 +377,9 @@ def run_cluster_query(ctx: Context, query: str, **kwargs: Any) -> list[dict[str, try: logger.debug("Executing cluster query") - result = cluster.query(query, **kwargs) + # Caller-supplied kwargs win, so an explicit client_context_id is never + # overwritten by the audit correlation id. + result = cluster.query(query, **{**_query_correlation_options(), **kwargs}) for row in result: results.append(row) logger.info(f"Cluster query returned {len(results)} row(s)") diff --git a/src/cb_mcp/tools/operational_insights/query.py b/src/cb_mcp/tools/operational_insights/query.py index 37513ec2..3fd602b5 100644 --- a/src/cb_mcp/tools/operational_insights/query.py +++ b/src/cb_mcp/tools/operational_insights/query.py @@ -39,6 +39,8 @@ from fastmcp import Context from fastmcp.server.dependencies import get_access_token +from ...audit import state as audit_state +from ...audit.emitter import get_audit_logger from ...servers.operational_insights.constants import ( OPERATIONAL_INSIGHTS_LOGGER_NAMESPACE, ) @@ -79,6 +81,32 @@ def _strip_leading_comments(statement: str) -> str: pos = match.end() +#: Statement forms that only read. Anything else is treated as a write. +#: +#: Deliberately a short allow-list rather than a parser. The Analytics dialect +#: is not the one lark_sqlpp parses, and a misparse here would be worse than no +#: parse at all: the fallback has to be the non-filterable class, so an +#: unrecognised statement is recorded as a write. The cost is that a SELECT +#: written behind a CTE is audited as a write -- noise in the log, not a gap in +#: it. +_READ_ONLY_STATEMENT = re.compile(r"^(SELECT|EXPLAIN)\b", re.IGNORECASE) + + +def _audit_statement_class(statement: str) -> str: + """Classify a statement as ``"read"`` or ``"write"`` for the audit record. + + These tools accept SELECT, DML and DDL through one entry point, so the + class cannot come from the tool name. Read ids are filterable, which is why + anything not provably a read is recorded as a write: a mutation booked as a + filterable read could be configured out of the audit log entirely. + """ + return ( + "read" + if _READ_ONLY_STATEMENT.match(_strip_leading_comments(statement)) + else "write" + ) + + def _is_copy_to_statement(statement: str) -> bool: """True for a ``COPY ... TO`` statement. @@ -304,6 +332,12 @@ def operational_insights_run_query_sync( lacks_write_scope = token is not None and SCOPE_WRITE not in (token.scopes or []) enforce_readonly = read_only_mode or lacks_write_scope + # The audit category depends on the statement, not on the tool name, so it + # has to be reported from here. Without this the record falls back to the + # table entry in cb_mcp.audit.classification. + if get_audit_logger().active: + audit_state.record_statement_class(_audit_statement_class(statement)) + try: statement_to_run, destination = _resolve_copy_to( statement, @@ -513,6 +547,12 @@ def operational_insights_run_query_async( lacks_write_scope = token is not None and SCOPE_WRITE not in (token.scopes or []) enforce_readonly = read_only_mode or lacks_write_scope + # The audit category depends on the statement, not on the tool name, so it + # has to be reported from here. Without this the record falls back to the + # table entry in cb_mcp.audit.classification. + if get_audit_logger().active: + audit_state.record_statement_class(_audit_statement_class(statement)) + try: statement_to_run, destination = _resolve_copy_to( statement, diff --git a/src/cb_mcp/tools/status.py b/src/cb_mcp/tools/status.py index 2b427093..91edf25d 100644 --- a/src/cb_mcp/tools/status.py +++ b/src/cb_mcp/tools/status.py @@ -26,8 +26,10 @@ from fastmcp import Context +from ..audit.emitter import get_audit_logger from ..utils.config import get_settings from ..utils.context import ( + get_audit_config, get_cluster_provider, get_logging_config, get_server_id, @@ -78,11 +80,32 @@ def get_server_configuration_status(ctx: Context) -> dict[str, Any]: # implementations that don't populate it. logging_status = get_logging_config(ctx) + # Audit state: the resolved configuration plus the sink counters. The + # counters matter as much as the config — ``dropped`` is the only way an + # operator discovers that audit records were lost to a full disk or revoked + # permissions, since the sink deliberately fails open rather than turning an + # audit outage into a server outage. + # + # Reported from here, beside the logging snapshot, because auditing is a + # server-level concern rather than a service one: the configuration comes + # from the lifespan context and the counters from the process-wide logger, + # and neither names an SDK. A server that has not enabled auditing reports + # ``None``, which is the same answer as a host that never adopted it — and + # that is the correct report in both cases. + audit_status = get_audit_config(ctx) + if audit_status is not None: + audit_status = dict(audit_status) + audit_logger = get_audit_logger() + audit_status["active"] = audit_logger.active + if audit_logger.active: + audit_status["stats"] = audit_logger.stats + return { "server_name": get_server_name(ctx), "server_id": get_server_id(ctx), "status": "running", "configuration": configuration, "logging": logging_status, + "audit": audit_status, "connections": connection_status, } diff --git a/src/cb_mcp/utils/__init__.py b/src/cb_mcp/utils/__init__.py index 240cbead..d7043a3b 100644 --- a/src/cb_mcp/utils/__init__.py +++ b/src/cb_mcp/utils/__init__.py @@ -22,10 +22,18 @@ # Constants from .constants import ( + ALLOWED_AUDIT_SINKS, ALLOWED_LOG_LEVELS, ALLOWED_LOG_SINKS, ALLOWED_OAUTH_ALGORITHMS, ALLOWED_TRANSPORTS, + DEFAULT_AUDIT_ENABLED, + DEFAULT_AUDIT_FILE, + DEFAULT_AUDIT_MAX_BACKUPS, + DEFAULT_AUDIT_ROTATION_INTERVAL, + DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB, + DEFAULT_AUDIT_SINKS, + DEFAULT_AUDIT_TOOL_ARGS, DEFAULT_HOST, DEFAULT_LOG_BACKUP_COUNT, DEFAULT_LOG_FORMAT, @@ -48,6 +56,7 @@ # Context utilities from .context import ( AppContext, + get_audit_config, get_cluster_provider, get_logging_config, ) @@ -86,10 +95,19 @@ "AppContext", "get_cluster_provider", "get_logging_config", + "get_audit_config", # Constants "LOGGER_NAMESPACE", "LOGGER_ROOT", "DEFAULT_READ_ONLY_MODE", + "ALLOWED_AUDIT_SINKS", + "DEFAULT_AUDIT_ENABLED", + "DEFAULT_AUDIT_FILE", + "DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB", + "DEFAULT_AUDIT_ROTATION_INTERVAL", + "DEFAULT_AUDIT_MAX_BACKUPS", + "DEFAULT_AUDIT_SINKS", + "DEFAULT_AUDIT_TOOL_ARGS", "DEFAULT_TRANSPORT", "DEFAULT_LOG_LEVEL", "DEFAULT_LOG_MAX_BYTES", diff --git a/src/cb_mcp/utils/cli_params.py b/src/cb_mcp/utils/cli_params.py index 2b0513fb..06655a41 100644 --- a/src/cb_mcp/utils/cli_params.py +++ b/src/cb_mcp/utils/cli_params.py @@ -37,14 +37,16 @@ """ from collections.abc import Callable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, NamedTuple import click from fastmcp.server.auth import AuthProvider +from ..audit.config import ResolvedAuditConfig, resolve_audit_config from ..auth import OAuthConfigError, resolve_oauth from ..core.cli.options import ( + audit_options, compose, credential_options, embedding_options, @@ -344,6 +346,21 @@ def apply(self, *, sdk_log_hook: Callable[[str, int], None] | None) -> None: ) +#: The Click destinations :attr:`CliParams.audit` resolves from. Listed +#: explicitly so the raw params mapping — which also holds the cluster password +#: — is not retained wholesale. +_AUDIT_PARAM_KEYS = ( + "audit_log_enabled", + "audit_log_sinks", + "audit_log_file_path", + "audit_log_rotation_max_size_mb", + "audit_log_rotation_interval", + "audit_log_retention_max_backups", + "audit_log_tool_args", + "audit_log_disabled_events", +) + + @dataclass(frozen=True) class CliParams: """Everything one invocation configured, grouped by the flag stack it came from. @@ -360,6 +377,47 @@ class CliParams: gating: GatingParams oauth: OAuthParams embedding: EmbeddingParams + #: Raw audit flags, resolved lazily by :attr:`audit`. + audit_params: Mapping[str, Any] = field(default_factory=dict, repr=False) + #: One-slot cache for :attr:`audit`. A list because this dataclass is + #: frozen: the list is mutated, the field is not reassigned. + _audit_cache: list[ResolvedAuditConfig] = field( + default_factory=list, repr=False, compare=False + ) + + @property + def audit(self) -> ResolvedAuditConfig: + """The resolved audit configuration, computed on **first access**. + + Deliberately not resolved in :meth:`from_click`. Resolution is where + the "file sink selected without a path" error and the tool-args warning + are emitted, and ``from_click`` runs two lines before + ``cli.logging.apply()`` in ``_start_server`` — so eager resolution sent + every one of those diagnostics to ``logging.lastResort`` instead of the + operator's configured sinks, while README and DOCKER.md both promised + "an error in the log". Resolving on first access puts them where they + were documented to be, because the first access is after logging is up. + """ + if not self._audit_cache: + self._audit_cache.append( + resolve_audit_config( + enabled=self.audit_params.get("audit_log_enabled"), + sinks=self.audit_params.get("audit_log_sinks"), + file=self.audit_params.get("audit_log_file_path"), + rotation_max_size_mb=self.audit_params.get( + "audit_log_rotation_max_size_mb" + ), + rotation_interval=self.audit_params.get( + "audit_log_rotation_interval" + ), + max_backups=self.audit_params.get( + "audit_log_retention_max_backups" + ), + tool_args=self.audit_params.get("audit_log_tool_args"), + disabled_events=self.audit_params.get("audit_log_disabled_events"), + ) + ) + return self._audit_cache[0] @classmethod def from_click( @@ -372,6 +430,13 @@ def from_click( gating=GatingParams.from_click(params), oauth=OAuthParams.from_click(params), embedding=EmbeddingParams.from_click(params), + # Carried raw, not resolved: see :attr:`audit` for why resolution + # must happen after logging is configured. Narrowed to the audit + # flags rather than the whole mapping, which would keep the cluster + # password alive on this object for the life of the process. + audit_params={ + key: params.get(key) for key in _AUDIT_PARAM_KEYS if key in params + }, ) def resolve_auth(self, spec: ServerSpec) -> AuthProvider | None: @@ -526,5 +591,6 @@ def server_options( tool_gating_options, logging_options(default_log_file=default_log_file), oauth_options, + audit_options, embedding_options, ) diff --git a/src/cb_mcp/utils/constants.py b/src/cb_mcp/utils/constants.py index bcca58ac..25021876 100644 --- a/src/cb_mcp/utils/constants.py +++ b/src/cb_mcp/utils/constants.py @@ -96,6 +96,46 @@ # again — it shares that value by history, not by contract. FALLBACK_LOG_FILE = "mcp_server.log" +# Audit Logging Configuration +# Audit is a separate sink from the CB_MCP_LOG_* operational logs: it +# guarantees a stable schema, completeness for non-filterable events, and is +# sensitive by default. Opt-in — an operator must enable it, and name a file if +# the file sink is selected. +DEFAULT_AUDIT_ENABLED = False +# Where audit records go. Comma-separated, same shape as CB_MCP_LOG_SINKS. +# "console" is the default because it needs no configuration and makes an +# enabled audit visible immediately; "file" is what a real deployment uses. +# +# "console" writes to **stderr**, never stdout: under the stdio transport +# stdout carries the JSON-RPC protocol, and a single audit line written there +# would corrupt the client's stream. The name follows the PRD; the stream +# follows the protocol. +ALLOWED_AUDIT_SINKS = ("console", "file") +DEFAULT_AUDIT_SINKS = "console" +# No default path: selecting the file sink without naming a file is a +# configuration error that is reported at startup, leaving the file sink off +# rather than aborting. +DEFAULT_AUDIT_FILE = None +# Rotation size per audit file, in MB. 0 is valid and turns size-based rotation +# off entirely. +DEFAULT_AUDIT_ROTATION_MAX_SIZE_MB = 10.0 +# Age the live audit file may reach before it rotates, as where +# the unit is "d" (24 hours) or "w" (7 days). 0 is valid and turns +# interval-based rotation off entirely. Size and interval rotation are +# independent: either, both, or neither may be active. +DEFAULT_AUDIT_ROTATION_INTERVAL = "1d" +# Rotated backups retained per audit file, excluding the live file. 0 is valid +# and keeps only the live file. +DEFAULT_AUDIT_MAX_BACKUPS = 10 +# Tool argument values are sensitive: document-write tools carry entire +# document bodies. There is no redaction capability in this release, so the +# default is off and enabling it emits a startup warning. +DEFAULT_AUDIT_TOOL_ARGS = False +# Seconds per interval unit. "d" is 24 hours and "w" is 7 days, exactly as the +# PRD defines them; no month or year unit exists, because neither has a fixed +# length and an audit retention window must be arithmetic, not calendrical. +AUDIT_INTERVAL_UNIT_SECONDS = {"d": 86_400, "w": 604_800} + # OAuth Scopes # Tokens carrying SCOPE_READ may call read-only tools (including SQL++ query, # which is classified read-only at startup and runtime-gated by diff --git a/src/cb_mcp/utils/context.py b/src/cb_mcp/utils/context.py index d89fe0b1..d67b0be7 100644 --- a/src/cb_mcp/utils/context.py +++ b/src/cb_mcp/utils/context.py @@ -48,6 +48,10 @@ class AppContext: management) are disabled and KV and index write tools are not loaded. logging_config: Optional snapshot of the active logging configuration, populated by the server entrypoint after configuring its loggers. + audit_config: Optional snapshot of the active audit configuration, + populated by the server entrypoint after initialising the audit + sink. Reported by ``get_server_configuration_status`` so the tool + output and the audit file always agree on what is running. server_id: Which server this process is running (e.g. "operational"). An identity, not configuration — nobody sets it, so it is kept off ``settings``, which holds operator-resolved values only. Populated @@ -59,6 +63,7 @@ class AppContext: settings: Mapping[str, Any] = field(default_factory=dict) read_only_mode: bool = True logging_config: Mapping[str, Any] | None = None + audit_config: Mapping[str, Any] | None = None server_id: str | None = None server_name: str | None = None @@ -97,3 +102,15 @@ def get_logging_config(ctx: Context) -> Mapping[str, Any] | None: context type doesn't carry a ``logging_config`` attribute at all. """ return getattr(ctx.request_context.lifespan_context, "logging_config", None) # type: ignore + + +def get_audit_config(ctx: Context) -> Mapping[str, Any] | None: + """Return the audit-config snapshot attached to the lifespan context. + + Returns ``None`` when the server entrypoint doesn't populate the field — + including host servers whose lifespan context type doesn't carry an + ``audit_config`` attribute at all — so a host that has not adopted auditing + is indistinguishable from one running with it switched off, which is the + correct report in both cases. + """ + return getattr(ctx.request_context.lifespan_context, "audit_config", None) # type: ignore diff --git a/src/cb_mcp/utils/elicitation.py b/src/cb_mcp/utils/elicitation.py index 1d8a17be..c4508f12 100644 --- a/src/cb_mcp/utils/elicitation.py +++ b/src/cb_mcp/utils/elicitation.py @@ -13,6 +13,8 @@ from mcp import types from pydantic import BaseModel, Field +from ..audit import state as audit_state +from ..audit.exceptions import ConfirmationDeclinedError from .constants import LOGGER_NAMESPACE logger = logging.getLogger(f"{LOGGER_NAMESPACE}.utils.elicitation") @@ -92,6 +94,12 @@ async def wrapper(*args, **kwargs): f"Client does not advertise elicitation support for '{tool_name}'; " "proceeding without confirmation" ) + # A confirmation-required tool is about to run *without* + # confirmation. Before auditing existed this bypass left no + # trace above DEBUG, which made it invisible in exactly the + # situation an auditor would care about. Record it so the + # middleware can emit the guardrail event. + audit_state.record_confirmation(audit_state.CONFIRMATION_SKIPPED) else: message = _build_confirmation_message(tool_name, call_arguments) result = await ctx.elicit( @@ -104,8 +112,17 @@ async def wrapper(*args, **kwargs): ): msg = f"Execution of '{tool_name}' was not confirmed by the user." logger.warning(msg) - raise PermissionError(msg) - + audit_state.record_confirmation(audit_state.CONFIRMATION_DECLINED) + audit_state.record_refusal( + event_id=ConfirmationDeclinedError.audit_event.id, + event_name=ConfirmationDeclinedError.audit_event.event_name, + outcome=ConfirmationDeclinedError.audit_outcome, + reason=ConfirmationDeclinedError.audit_reason, + confirmation="declined", + ) + raise ConfirmationDeclinedError(msg) + + audit_state.record_confirmation(audit_state.CONFIRMATION_ACCEPTED) logger.info(f"User confirmed execution of '{tool_name}'") # Keep wrapper behavior transparent: await async tools, call sync tools directly. diff --git a/src/cb_mcp/utils/scope_enforcement.py b/src/cb_mcp/utils/scope_enforcement.py index 24b9d858..85f4daea 100644 --- a/src/cb_mcp/utils/scope_enforcement.py +++ b/src/cb_mcp/utils/scope_enforcement.py @@ -22,6 +22,8 @@ from fastmcp.server.dependencies import get_access_token +from ..audit import state as audit_state +from ..audit.exceptions import ScopeDeniedError from ..core.spec import ScopeSpec from .constants import LOGGER_NAMESPACE @@ -104,7 +106,26 @@ async def wrapper(*args, **kwargs): if hint: msg = f"{msg} {hint}" logger.warning(msg) - raise PermissionError(msg) + # Record the refusal for the audit middleware before raising. + # FastMCP wraps a tool's exception in ToolError, so the + # middleware cannot classify this from the exception it sees. + # Deliberately does not record ``required_scope``. This gate + # holds the server's full scope labels + # (``couchbase-mcp:write``), while the tool-call record and the + # SQL++ statement gate both record the short class + # (``write``) that the PRD's samples use. One event id must not + # carry two JSON shapes — a SIEM field mapping would have to + # handle a list and a string for event 57377 — so the + # middleware derives the short form from the tool's audit + # classification instead, in one place. + audit_state.record_refusal( + event_id=ScopeDeniedError.audit_event.id, + event_name=ScopeDeniedError.audit_event.event_name, + outcome=ScopeDeniedError.audit_outcome, + reason=ScopeDeniedError.audit_reason, + missing_scopes=sorted(missing), + ) + raise ScopeDeniedError(msg) if inspect.iscoroutinefunction(fn): return await fn(*args, **kwargs) diff --git a/src/mcp_server.py b/src/mcp_server.py index 05b60cb7..f98b510c 100644 --- a/src/mcp_server.py +++ b/src/mcp_server.py @@ -113,6 +113,7 @@ def _start_server( auth=auth, read_only_mode=cli.gating.read_only_mode, logging_config=resolved_logging_snapshot(), + audit_config=cli.audit, ) run_app( diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..b563fe2f --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,52 @@ +"""Shared fixtures for the unit suite. + +The audit subsystem keeps three pieces of process-global state, and a test that +installs any of them without restoring it changes the result of a *later, +unrelated* test. That is the worst kind of suite failure: the output names a +file that is not the one at fault, and the failure moves when tests are +reordered or run in isolation. + +The three: + +* the ``SIGTERM`` disposition, which :func:`cb_mcp.audit.emitter.init_audit` + replaces and ``shutdown_audit`` does not restore; +* ``emitter._previous_sigterm``, the handler the audit handler chains to, which + is what makes a server under uvicorn hand over instead of closing the trail; +* ``emitter._active``, the installed logger, which ``get_audit_logger`` returns + to every caller including tools asserting ``audit["active"] is False``. + +This module snapshots all three before each test and puts them back after, so +no test can depend on, or damage, another's leftovers. +""" + +from __future__ import annotations + +import contextlib +import signal + +import pytest + +from cb_mcp.audit import emitter + + +@pytest.fixture(autouse=True) +def _restore_audit_global_state(): + """Snapshot and restore the audit subsystem's process-global state. + + Autouse on purpose. Opting in per test is what produced the leak: the tests + that install this state are not the ones that notice it, so the fixture has + to cover the tests that would otherwise be the victims. + """ + previous_disposition = signal.getsignal(signal.SIGTERM) + previous_chain = emitter._previous_sigterm + previous_active = emitter._active + + yield + + emitter._active = previous_active + emitter._previous_sigterm = previous_chain + # Only settable from the main thread of the main interpreter, and a handler + # read back as None cannot be reinstalled. Neither is worth failing a + # passing test over. + with contextlib.suppress(TypeError, ValueError, OSError): + signal.signal(signal.SIGTERM, previous_disposition) diff --git a/tests/unit/operational/test_query_admin_tools_unit.py b/tests/unit/operational/test_query_admin_tools_unit.py index 51692d57..e150b35a 100644 --- a/tests/unit/operational/test_query_admin_tools_unit.py +++ b/tests/unit/operational/test_query_admin_tools_unit.py @@ -377,9 +377,7 @@ def test_reports_success_once_delivered_to_a_single_node(self) -> None: assert result == { "success": True, "request_id": "abc-123", - "nodes": [ - {"node": "localhost:8093", "http_status": 200, "body": "ok"} - ], + "nodes": [{"node": "localhost:8093", "http_status": 200, "body": "ok"}], } def test_sends_to_every_node_regardless_of_status_code(self) -> None: diff --git a/tests/unit/test_audit_catalog.py b/tests/unit/test_audit_catalog.py new file mode 100644 index 00000000..10ff8cc2 --- /dev/null +++ b/tests/unit/test_audit_catalog.py @@ -0,0 +1,350 @@ +"""Tests for the audit event catalogue and its shipped descriptor. + +The event ids are a wire contract: once a customer writes a SIEM rule against +``61522`` it cannot be changed. These tests pin every id from the PRD literally +rather than recomputing them from the same helpers the implementation uses, so +an accidental renumbering fails loudly instead of silently agreeing with itself. + +Coverage map: +- literal PRD ids for every core and tool-call event +- block layout arithmetic (read offset, write delta, category order) +- filterability policy: writes never filterable, reads filterable +- descriptor.json matches the in-code catalogue exactly +- events deliberately absent (disabled-tool, session-terminated) +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from cb_mcp.audit import catalog +from cb_mcp.audit.catalog import ( + CORE_BLOCK, + OPERATIONAL_BLOCK, + READ_OFFSET, + WRITE_DELTA, + AuditEvent, + all_tool_call_events, + build_descriptor, + categories_for, + tool_call_event, + tool_call_event_id, +) + +DESCRIPTOR_PATH = Path(catalog.__file__).with_name("descriptor.json") + + +def test_block_bases_match_the_prd(): + assert CORE_BLOCK == 57344 # 0xE000 + assert OPERATIONAL_BLOCK == 61440 # 0xF000 + assert READ_OFFSET == 48 + assert WRITE_DELTA == 32 + + +def test_category_order_is_the_wire_contract(): + # The list index is the ``n`` in base+48+n, so reordering silently + # renumbers every tool-call event. + assert categories_for("operational") == ( + "cluster", + "schema", + "kv", + "query", + "index", + "performance", + "search", + "config", + ) + + +@pytest.mark.parametrize( + ("event", "expected_id", "expected_name"), + [ + (AuditEvent.SERVER_STARTED, 57344, "server started"), + (AuditEvent.SERVER_STOPPED, 57345, "server stopped"), + (AuditEvent.SERVER_CONFIGURATION, 57346, "server configuration"), + (AuditEvent.SESSION_INITIALIZED, 57360, "session initialized"), + (AuditEvent.TOKEN_REJECTED, 57376, "token rejected"), + (AuditEvent.SCOPE_CHECK_DENIED, 57377, "scope check denied"), + ( + AuditEvent.WRITE_BLOCKED_READ_ONLY, + 57488, + "write blocked (read-only mode)", + ), + (AuditEvent.CONFIRMATION_DECLINED, 57490, "confirmation declined"), + (AuditEvent.CONFIRMATION_SKIPPED, 57491, "confirmation skipped"), + ], +) +def test_core_event_ids_are_the_prd_values(event, expected_id, expected_name): + assert event.id == expected_id + assert event.event_name == expected_name + assert event.description + + +@pytest.mark.parametrize( + ("category", "read_id", "write_id"), + [ + ("cluster", 61488, 61520), + ("schema", 61489, 61521), + ("kv", 61490, 61522), + ("query", 61491, 61523), + ("index", 61492, 61524), + ("performance", 61493, 61525), + ], +) +def test_tool_call_ids_are_the_prd_values(category, read_id, write_id): + assert tool_call_event_id(category, "read", "operational") == read_id + assert tool_call_event_id(category, "write", "operational") == write_id + assert write_id - read_id == WRITE_DELTA + + +def test_kv_events_use_the_document_label(): + # The PRD names the KV events "document read"/"document write" even though + # the category axis calls the category "kv". + assert tool_call_event("kv", "read", "operational").event_name == "document read" + assert tool_call_event("kv", "write", "operational").event_name == "document write" + + +def test_unknown_category_and_class_are_rejected(): + with pytest.raises(ValueError, match="Unknown audit category"): + tool_call_event_id("nonsense", "read", "operational") + with pytest.raises(ValueError, match="must be 'read' or 'write'"): + tool_call_event_id("kv", "sideways", "operational") + + +def test_writes_are_never_filterable_and_reads_always_are(): + for event in all_tool_call_events("operational"): + assert event.filterable is (event.operation_class == "read") + + +def test_security_and_lifecycle_events_are_not_filterable(): + for event in AuditEvent: + if event is AuditEvent.SESSION_INITIALIZED: + assert event.filterable is True + else: + assert event.filterable is False, f"{event.event_name} must be mandated" + + +def test_ids_are_unique(): + ids = [event.id for event in AuditEvent] + [ + event.id for event in all_tool_call_events("operational") + ] + assert len(ids) == len(set(ids)) + + +def test_events_named_in_the_prd_but_not_emitted_are_absent(): + """Two ids the PRD names are deliberately not in the catalogue. + + 57489 (tool invocation blocked - disabled): withheld tools are never + registered, so the model is never told they exist and there is no refusal + to record. 57361 (session terminated): FastMCP exposes no session-end hook, + so the event cannot be emitted reliably. + + Both must stay absent rather than be defined and never fired — an id in the + catalogue is an id a SIEM rule can be written against. + """ + ids = {event.id for event in AuditEvent} + assert 57489 not in ids + assert 57361 not in ids + + +def test_shipped_descriptor_matches_the_code(): + """The descriptor is the operator-facing contract; it must not drift. + + Regenerate with:: + + python -c "import json,sys; sys.path.insert(0,'src'); \ +from cb_mcp.audit.catalog import build_descriptor; \ +print(json.dumps(build_descriptor(), indent=2))" > src/cb_mcp/audit/descriptor.json + """ + assert DESCRIPTOR_PATH.exists(), "descriptor.json must ship with the package" + shipped = json.loads(DESCRIPTOR_PATH.read_text(encoding="utf-8")) + assert shipped == build_descriptor() + + +def test_descriptor_entries_are_well_formed(): + shipped = json.loads(DESCRIPTOR_PATH.read_text(encoding="utf-8")) + assert shipped["blocks"] == { + "core": 57344, + "operational": 61440, + "operational_insights": 65536, + } + assert shipped["service_packages"] == ["operational", "operational_insights"] + ids = [entry["id"] for entry in shipped["events"]] + assert ids == sorted(ids), "descriptor must be ordered by id" + for entry in shipped["events"]: + required = { + "id", + "name", + "description", + "category", + "default_enabled", + "filterable", + } + assert required <= set(entry), entry + # Tool-call names repeat across blocks -- "query read" is 61491 in + # operational and 65587 in operational_insights -- so a Tier-2 entry + # must name its package or a SIEM author cannot tell them apart. Core + # events are shared by every package and carry no such field. + if entry["category"] == "tool_call": + assert entry["service_package"] in {"operational", "operational_insights"} + assert set(entry) == required | {"service_package"} + else: + assert set(entry) == required + assert entry["default_enabled"] is True + assert entry["description"].strip() + + +# --------------------------------------------------------------------------- +# slot allocation +# --------------------------------------------------------------------------- + + +def test_category_slots_and_categories_cannot_disagree(): + """``categories_for`` is derived from the slot table, ordered by slot.""" + assert tuple( + name + for name, _ in sorted( + catalog.CATEGORY_SLOTS["operational"].items(), key=lambda kv: kv[1] + ) + ) == catalog.categories_for("operational") + + +def test_slot_numbers_are_unique_and_within_the_template(): + slots = list(catalog.CATEGORY_SLOTS["operational"].values()) + assert len(slots) == len(set(slots)), "two categories share a slot" + assert all(0 <= slot <= catalog.MAX_SLOT for slot in slots) + + +def test_slot_table_pins_the_shipped_numbers(): + """These numbers are a wire contract. Changing one renumbers a live event. + + Pinned literally so an edit to the table has to be a deliberate, visible + test change rather than something that quietly renumbers a customer's SIEM + rule. + """ + assert catalog.CATEGORY_SLOTS["operational"] == { + "cluster": 0, + "schema": 1, + "kv": 2, + "query": 3, + "index": 4, + "performance": 5, + # Both reuse retired slots, which is permitted only because neither + # retired category ever emitted a record. The allocation is contiguous + # across 0-7. + "search": 6, + "config": 7, + } + + +def test_operational_insights_slots_match_the_shared_axis(): + """A slot must mean the same category in every block. + + That is what lets an id decode to package-by-block and category-by-offset. + A package omits the slots it has no tools for; it never renumbers. + """ + oi = catalog.CATEGORY_SLOTS["operational_insights"] + # `cluster` carries no OI tool; it is allocated because it is the + # fail-closed category and every package must be able to resolve it. + assert oi == {"cluster": 0, "schema": 1, "query": 3, "index": 4, "config": 7} + shared = catalog.CATEGORY_SLOTS["operational"] + for category, slot in oi.items(): + assert shared[category] == slot, category + + +def test_operational_insights_ids_sit_in_its_own_block(): + """Same offsets, different block -- and never the operational numbers.""" + base = catalog.SERVICE_PACKAGE_BLOCKS["operational_insights"] + assert base == 0x10000 + assert catalog.tool_call_event_id("query", "read", "operational_insights") == 65587 + assert catalog.tool_call_event_id("query", "write", "operational_insights") == 65619 + assert catalog.tool_call_event_id("config", "read", "operational_insights") == 65591 + # The collision this block exists to prevent: the same tool name in both + # servers must never resolve to the same id. + assert catalog.tool_call_event_id( + "config", "read", "operational" + ) != catalog.tool_call_event_id("config", "read", "operational_insights") + + +def test_retiring_a_category_does_not_move_its_neighbours(monkeypatch): + """The reason the slot table is explicit rather than positional. + + Under positional indexing, removing ``index`` would have shifted + ``performance`` from 61493 down to 61492 — silently renumbering a shipped + event that customers already have SIEM rules for. + """ + trimmed = { + name: slot + for name, slot in catalog.CATEGORY_SLOTS["operational"].items() + if name != "index" + } + monkeypatch.setattr(catalog, "CATEGORY_SLOTS", {"operational": trimmed}) + assert catalog.tool_call_event_id("performance", "read", "operational") == 61493 + assert catalog.tool_call_event_id("query", "read", "operational") == 61491 + + +def test_next_free_slot_takes_the_lowest_never_allocated(monkeypatch): + monkeypatch.setattr(catalog, "RETIRED_SLOTS", {}) + # 0-7 are allocated, so 8 is the lowest never-allocated slot. + assert catalog.next_free_slot("operational") == 8 + + +def test_next_free_slot_skips_retired_slots(monkeypatch): + """Retired slots are not offered, even when reuse would be safe. + + Reuse remains *permissible* for a slot that never emitted a record. It is + simply never *automatic*: reuse has to be chosen with the evidence that the + slot never emitted in front of you, and written into ``CATEGORY_SLOTS`` by + hand. Handing retired slots back from a helper would make that the default + path, and the day a slot *has* emitted the default would be wrong. + """ + monkeypatch.setattr(catalog, "RETIRED_SLOTS", {"operational": {"withdrawn": 8}}) + # 0-7 are live and 8 is retired, so the helper must skip past it to 9. + # Retiring a slot *above* the first free one would make this pass + # whether or not RETIRED_SLOTS were consulted at all. + assert catalog.next_free_slot("operational") == 9 + + +def test_exhausted_block_is_an_explicit_error(monkeypatch): + monkeypatch.setattr( + catalog, + "CATEGORY_SLOTS", + {"operational": {f"c{n}": n for n in range(catalog.MAX_SLOT + 1)}}, + ) + monkeypatch.setattr(catalog, "RETIRED_SLOTS", {}) + with pytest.raises(ValueError, match="request its own block"): + catalog.next_free_slot("operational") + + +def test_nothing_is_retired_in_a_catalogue_that_has_not_shipped(): + """No category has been withdrawn from a released catalogue. + + The table exists for the first withdrawal, not to carry development + history: a slot that never reached a release never meant anything to an + operator, so recording it as retired would only make a reader wonder which + numbers they must avoid. + """ + assert catalog.RETIRED_SLOTS == {} + + +def test_every_slot_in_the_block_is_allocated_contiguously(): + """0-7 are in use and 8-15 are free, with no gaps to explain.""" + slots = sorted(catalog.CATEGORY_SLOTS["operational"].values()) + assert slots == list(range(8)) + assert catalog.next_free_slot("operational") == 8 + + +def test_slot_table_ids_are_the_shipped_numbers(): + """The point of the explicit slot table: every id pinned literally.""" + assert tool_call_event_id("cluster", "read", "operational") == 61488 + assert tool_call_event_id("performance", "read", "operational") == 61493 + assert tool_call_event_id("performance", "write", "operational") == 61525 + + +def test_search_ids_are_the_shipped_numbers(): + """Pinned literally, like every other shipped id.""" + assert tool_call_event_id("search", "read", "operational") == 61494 + assert tool_call_event_id("search", "write", "operational") == 61526 diff --git a/tests/unit/test_audit_classification.py b/tests/unit/test_audit_classification.py new file mode 100644 index 00000000..b8575dda --- /dev/null +++ b/tests/unit/test_audit_classification.py @@ -0,0 +1,340 @@ +"""Tests for tool classification. + +The classification map is what keeps every shipped tool auditable, so the +central test asserts it covers the registered tool set exactly — no gaps and no +stale entries for tools that no longer exist. + +Coverage map: +- every registered tool is classified, and nothing extra is +- the repository's own write-tool lists agree with the classification +- read-only tools are classified read +- SQL++ statement override moves the event to the query write id +- unknown tools fail closed to a write id +- required_scope is derived from the operation class +- unclassified_tool_names reports gaps +""" + +from __future__ import annotations + +import pytest + +from cb_mcp.audit.catalog import ( + CATEGORY_SLOTS, + FAILSAFE_SLOT_REQUIRED, + SERVICE_PACKAGE_BLOCKS, + categories_for, +) +from cb_mcp.audit.classification import ( + FAILSAFE_CATEGORY, + TOOL_CLASSIFICATION, + classify_tool, + is_classified, + is_statement_classified, + required_scope_for, + resolve_tool_call_event, + unclassified_tool_names, +) +from cb_mcp.tools.operational import ALL_TOOLS, READ_ONLY_TOOLS, WRITE_TOOLS +from cb_mcp.tools.operational_insights import ALL_TOOLS as OI_ALL_TOOLS +from cb_mcp.tools.operational_insights import WRITE_TOOLS as OI_WRITE_TOOLS +from cb_mcp.tools.operational_insights.query import _audit_statement_class + +#: The operational package. Operational Insights has its own block and table +#: its own block and table; constants for it are defined alongside these. +PACKAGE = "operational" + +#: Main collapsed the four per-area tool groups into read-only and write when +#: the second server landed (#250), so the parity assertions below read the two +#: that remain. The contract they test is unchanged: the audit class must agree +#: with the server's own categorisation of every registered tool. +REGISTERED = {tool.__name__ for tool in ALL_TOOLS} +WRITE_TOOL_NAMES = {tool.__name__ for tool in WRITE_TOOLS} +READ_ONLY_NAMES = {tool.__name__ for tool in READ_ONLY_TOOLS} + +#: The Operational Insights server, audited under its own block. +OI_REGISTERED = {tool.__name__ for tool in OI_ALL_TOOLS} +OI_WRITE_NAMES = {tool.__name__ for tool in OI_WRITE_TOOLS} + + +def test_every_registered_tool_is_classified(): + assert unclassified_tool_names(REGISTERED, PACKAGE) == [] + + +def test_no_stale_entries_for_tools_that_do_not_exist(): + assert sorted(set(TOOL_CLASSIFICATION[PACKAGE]) - REGISTERED) == [] + + +def test_classification_covers_the_registered_set_exactly(): + assert set(TOOL_CLASSIFICATION[PACKAGE]) == REGISTERED + + +def test_every_classified_package_has_a_block_and_vice_versa(): + """A half-registered package is the failure this guards. + + A table with no block raises at runtime the first time a tool is called; a + block with no table sends every tool down the fail-closed path, recording + real calls against a placeholder category. Both halves land together or + this fails. + """ + assert set(TOOL_CLASSIFICATION) == set(SERVICE_PACKAGE_BLOCKS) + assert set(TOOL_CLASSIFICATION) == {"operational", "operational_insights"} + + +def test_the_two_servers_resolve_their_shared_tool_names_differently(): + """The reason the table is keyed by package at all. + + Both servers register get_server_configuration_status. Booking one against + the other's block would file its records under ids that mean a different + server, and nothing in the record would reveal it. + """ + operational = resolve_tool_call_event( + "get_server_configuration_status", package="operational" + ) + insights = resolve_tool_call_event( + "get_server_configuration_status", package="operational_insights" + ) + assert operational.id == 61495 + assert insights.id == 65591 + # Same category and class, so the difference is purely the block. + assert operational.category == insights.category == "config" + assert operational.operation_class == insights.operation_class == "read" + + +def test_every_registered_oi_tool_is_classified(): + assert unclassified_tool_names(OI_REGISTERED, "operational_insights") == [] + + +def test_no_stale_oi_entries(): + assert ( + sorted(set(TOOL_CLASSIFICATION["operational_insights"]) - OI_REGISTERED) == [] + ) + + +@pytest.mark.parametrize( + ("tool_name", "expected_id"), + [ + ("get_server_configuration_status", 65591), # config read + ("operational_insights_get_databases_in_cluster", 65585), # schema read + ("operational_insights_get_schema_for_collection", 65585), + # Statement-classified: 65619 is the non-filterable fallback used + # when the statement reported no class. + ("operational_insights_run_query_sync", 65619), + ("operational_insights_cancel_async_query", 65587), + ("operational_insights_list_indexes", 65588), # index read + ("operational_insights_create_index", 65620), # index write + ], +) +def test_oi_tool_maps_to_the_expected_event_id(tool_name, expected_id): + """Every id is in the OI block, never the operational one.""" + event = resolve_tool_call_event(tool_name, package="operational_insights") + assert event.id == expected_id + assert 65536 <= event.id < 65536 + 4096 + + +@pytest.mark.parametrize("tool_name", sorted(OI_WRITE_NAMES)) +def test_oi_write_tools_are_classified_write(tool_name): + _, operation_class = classify_tool(tool_name, package="operational_insights") + assert operation_class == "write", tool_name + + +@pytest.mark.parametrize("tool_name", sorted(WRITE_TOOL_NAMES)) +def test_repository_write_tools_are_classified_write(tool_name): + """The audit class must agree with the server's own categorisation.""" + _, operation_class = classify_tool(tool_name, package=PACKAGE) + assert operation_class == "write", tool_name + assert resolve_tool_call_event(tool_name, package=PACKAGE).filterable is False + + +@pytest.mark.parametrize("tool_name", sorted(READ_ONLY_NAMES)) +def test_read_only_tools_are_classified_read(tool_name): + _, operation_class = classify_tool(tool_name, package=PACKAGE) + assert operation_class == "read", tool_name + + +@pytest.mark.parametrize( + ("tool_name", "expected_id"), + [ + ("get_document_by_id", 61490), + ("lookup_subdocument", 61490), + ("upsert_document_by_id", 61522), + ("mutate_subdocument", 61522), + ("delete_document_by_id", 61522), + ("get_client_connectivity_report", 61488), + ("test_cluster_connection", 61488), + ("get_cluster_tasks", 61488), + ("get_cluster_health_snapshot", 61488), + ("get_cluster_system_events", 61488), + # Cluster Setup & Health: both describe how a service is holding up + # rather than the query workload or the index inventory. + ("get_cluster_query_vitals", 61488), + ("get_index_stats", 61488), + # The server's own surface, split out of cluster on the same date. + ("get_server_configuration_status", 61495), + ("discover_tool_input_values", 61495), + ("get_buckets_in_cluster", 61489), + ("get_schema_for_collection", 61489), + ("create_scope", 61521), + ("delete_collection", 61521), + ("explain_sql_plus_plus_query", 61491), + ("run_sql_plus_plus_query", 61491), + ("list_indexes", 61492), + ("create_index", 61524), + # Superseding create_index must not move the id a SIEM rule watches. + ("create_query_index", 61524), + ("drop_index", 61524), + ("get_longest_running_queries", 61493), + ("get_queries_not_selective", 61493), + ("get_active_queries", 61493), + # The first tool to take the performance write id, and the only one in + # the server that cancels another caller's in-flight work. + ("delete_active_query", 61525), + # Search keeps its own category rather than folding into index: a + # reviewer filtering on `index read` must not get FTS traffic, and + # `run_search` is a query, not an index operation. Moved into the + # slot 6. + ("list_search_indexes", 61494), + ("run_search", 61494), + ("upsert_search_index", 61526), + ("drop_search_index", 61526), + ], +) +def test_tool_maps_to_the_expected_event_id(tool_name, expected_id): + """Pins the *category*, which the read/write tests above cannot. + + ``test_repository_write_tools_are_classified_write`` checks the operation + class against the server's own read/write split, so a tool filed under the + wrong category — an FTS write booked as an index write — satisfies it + while writing the wrong id into every record for that tool. The id is the + wire contract a SIEM rule is written against; only an explicit expectation + holds it still. + """ + assert resolve_tool_call_event(tool_name, package=PACKAGE).id == expected_id + + +def test_sqlpp_write_statement_moves_to_the_query_write_id(): + read_event = resolve_tool_call_event("run_sql_plus_plus_query", package=PACKAGE) + write_event = resolve_tool_call_event( + "run_sql_plus_plus_query", package=PACKAGE, operation_class_override="write" + ) + assert read_event.id == 61491 + assert write_event.id == 61523 + assert read_event.filterable is True + # A successful mutation must not be filed under a filterable read id. + assert write_event.filterable is False + + +@pytest.mark.parametrize("package", sorted(SERVICE_PACKAGE_BLOCKS)) +def test_fail_closed_resolves_in_every_package(package): + """The fail-closed path must produce a record, not an exception. + + An unclassified tool resolves to the failsafe category. If a package does + not allocate that category, the resolution raises -- and the audit + middleware swallows exceptions on purpose, so the call succeeds and + *nothing is recorded*. That is silence in the one case fail-closed exists + to make loud, and no counter moves: the operator sees dropped: 0. + + """ + event = resolve_tool_call_event("a_tool_nobody_classified", package=package) + assert event.operation_class == "write" + assert event.filterable is False + base = SERVICE_PACKAGE_BLOCKS[package] + assert base <= event.id < base + 4096 + assert is_classified("a_tool_nobody_classified", package) is False + + +def test_the_failsafe_category_is_allocated_everywhere(): + """Pins the invariant the import-time guard enforces.""" + assert FAILSAFE_CATEGORY == FAILSAFE_SLOT_REQUIRED + for package, slots in CATEGORY_SLOTS.items(): + assert FAILSAFE_CATEGORY in slots, package + + +def test_unknown_tool_fails_closed_to_a_non_filterable_write(): + assert is_classified("some_future_tool", PACKAGE) is False + event = resolve_tool_call_event("some_future_tool", package=PACKAGE) + assert event.operation_class == "write" + assert event.filterable is False + + +def test_unclassified_tool_names_reports_only_the_gaps(): + names = ["get_document_by_id", "brand_new_a", "brand_new_b"] + assert unclassified_tool_names(names, PACKAGE) == ["brand_new_a", "brand_new_b"] + + +@pytest.mark.parametrize( + ("operation_class", "expected"), + [("read", "read"), ("write", "write")], +) +def test_required_scope_is_derived_from_the_operation_class(operation_class, expected): + assert required_scope_for(operation_class) == expected + + +def test_every_classification_uses_a_known_category_and_class(): + for tool_name, (category, operation_class) in TOOL_CLASSIFICATION[PACKAGE].items(): + assert category in categories_for(PACKAGE), tool_name + assert operation_class in ("read", "write"), tool_name + + +class TestStatementClassifiedTools: + """Tools that accept SELECT, DML and DDL through one entry point. + + Their class cannot come from the tool name. Read ids are filterable, so a + mutation recorded as a read could be configured out of the audit log -- + which is why the static fallback for each is write. + """ + + def test_the_operational_insights_query_tools_are_statement_classified(self): + for tool_name in ( + "operational_insights_run_query_sync", + "operational_insights_run_query_async", + ): + assert is_statement_classified(tool_name, "operational_insights"), tool_name + + def test_sqlpp_is_statement_classified_on_the_operational_server(self): + assert is_statement_classified("run_sql_plus_plus_query", PACKAGE) + + def test_a_statement_classified_tool_is_not_shared_across_packages(self): + """The name only means something inside its own package.""" + assert not is_statement_classified( + "run_sql_plus_plus_query", "operational_insights" + ) + assert not is_statement_classified( + "operational_insights_run_query_sync", PACKAGE + ) + + @pytest.mark.parametrize( + "tool_name", + [ + "operational_insights_run_query_sync", + "operational_insights_run_query_async", + ], + ) + def test_the_fallback_class_is_the_non_filterable_one(self, tool_name): + """If no class was recorded, the record must not be filterable. + + The failure mode of the state channel is silence, so the static answer + has to be the one that cannot hide a mutation. + """ + event = resolve_tool_call_event(tool_name, package="operational_insights") + assert event.operation_class == "write" + assert event.filterable is False + + @pytest.mark.parametrize( + ("statement", "expected"), + [ + ("SELECT 1", "read"), + (" -- a comment\nSELECT 1", "read"), + ("EXPLAIN SELECT 1", "read"), + ("INSERT INTO x VALUES (1)", "write"), + ("DELETE FROM x WHERE a = 1", "write"), + ("CREATE INDEX i ON d(a)", "write"), + ("COPY d TO link AT 'p'", "write"), + # Not provably a read, so it is recorded as a write: noise in the + # log rather than a mutation that could be filtered out of it. + ("WITH a AS (SELECT 1) SELECT * FROM a", "write"), + ], + ) + def test_statements_that_are_not_provably_reads_are_writes( + self, statement, expected + ): + assert _audit_statement_class(statement) == expected diff --git a/tests/unit/test_audit_config.py b/tests/unit/test_audit_config.py new file mode 100644 index 00000000..490fe63e --- /dev/null +++ b/tests/unit/test_audit_config.py @@ -0,0 +1,436 @@ +"""Tests for audit configuration resolution. + +Coverage map: +- defaults when nothing is configured +- sink parsing: console, file, both, invalid, empty +- selecting the file sink without a path is reported and degrades, not fatal +- a path with no file sink is warned about +- rotation size: 0 is off, negative falls back +- rotation interval parsing: units, 0, invalid +- retention: 0 honoured, negative falls back +- MB to bytes conversion +- disabled-events parsing: ids only, files, comments, whitespace +- non-filterable events cannot be suppressed +- unknown events are ignored with a warning +- the tool-args warning fires only when it can matter +- the snapshot round-trips for the status tool +- one test per configuration case in the PRD +""" + +from __future__ import annotations + +import logging + +import pytest + +from cb_mcp.audit.config import ( + ResolvedAuditConfig, + parse_audit_sinks, + parse_disabled_events, + parse_rotation_interval, + resolve_audit_config, +) +from cb_mcp.utils.constants import BYTES_PER_MB + +DAY = 86_400 +WEEK = 604_800 + + +def _resolve(**overrides) -> ResolvedAuditConfig: + options = { + "enabled": None, + "sinks": None, + "file": None, + "rotation_max_size_mb": None, + "rotation_interval": None, + "max_backups": None, + "tool_args": None, + "disabled_events": None, + } + options.update(overrides) + return resolve_audit_config(**options) + + +def _file_audit(tmp_path, **overrides) -> ResolvedAuditConfig: + """An enabled, file-sinked configuration — the shape a deployment uses.""" + options = { + "enabled": True, + "sinks": "file", + "file": str(tmp_path / "audit.log"), + } + options.update(overrides) + return _resolve(**options) + + +# --------------------------------------------------------------------------- +# defaults +# --------------------------------------------------------------------------- + + +def test_defaults_are_off_and_match_the_prd(): + config = _resolve() + assert config.enabled is False + assert config.sinks == ("console",) + assert config.file is None + assert config.process_file is None + assert config.rotation_max_size_mb == 10.0 + assert config.rotation_interval == "1d" + assert config.rotation_interval_seconds == DAY + assert config.max_backups == 10 + assert config.tool_args is False + assert config.disabled_events == () + + +def test_enabled_with_a_file_resolves_the_process_scoped_path(tmp_path): + config = _file_audit(tmp_path) + assert config.enabled is True + assert config.writes_file is True + assert config.writes_console is False + assert config.process_file is not None + assert config.process_file.endswith(".log") + assert "audit." in config.process_file + + +def test_mb_is_converted_to_bytes(tmp_path): + config = _file_audit(tmp_path, rotation_max_size_mb=2.5) + assert config.max_bytes == int(2.5 * BYTES_PER_MB) + + +# --------------------------------------------------------------------------- +# sinks +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("console", ("console",)), + ("file", ("file",)), + ("console,file", ("console", "file")), + # Order, case and spacing are the operator's; the result is canonical. + ("FILE, console", ("console", "file")), + ("file,file", ("file",)), + ], +) +def test_sinks_parse_like_the_logging_sinks(raw, expected): + assert parse_audit_sinks(raw) == expected + + +def test_invalid_sink_tokens_are_warned_about_and_ignored(caplog): + with caplog.at_level(logging.WARNING): + assert parse_audit_sinks("file,syslog") == ("file",) + assert "invalid audit sink" in caplog.text.lower() + + +@pytest.mark.parametrize("raw", ["", " ", ",", "nowhere"]) +def test_no_valid_sink_falls_back_to_the_default(raw, caplog): + """Enabling auditing must always produce output somewhere.""" + with caplog.at_level(logging.WARNING): + assert parse_audit_sinks(raw) == ("console",) + + +def test_console_only_needs_no_file(caplog): + with caplog.at_level(logging.ERROR): + config = _resolve(enabled=True, sinks="console") + assert config.enabled is True + assert config.writes_console is True + assert config.writes_file is False + assert caplog.text == "" + + +# --------------------------------------------------------------------------- +# error and fallback handling +# --------------------------------------------------------------------------- + + +def test_file_sink_without_a_path_disables_only_that_sink(caplog): + """The PRD is explicit: the server must still start.""" + with caplog.at_level(logging.ERROR): + config = _resolve(enabled=True, sinks="console,file", file=None) + # Console was also selected, so records still go somewhere. + assert config.enabled is True + assert config.sinks == ("console",) + assert config.process_file is None + assert "no audit file path is configured" in caplog.text + + +def test_file_only_without_a_path_leaves_auditing_off(caplog): + with caplog.at_level(logging.ERROR): + config = _resolve(enabled=True, sinks="file", file=None) + assert config.enabled is False + assert config.sinks == () + assert "audit logging disabled" in caplog.text + + +def test_blank_file_is_treated_as_absent(caplog): + with caplog.at_level(logging.ERROR): + config = _resolve(enabled=True, sinks="file", file=" ") + assert config.enabled is False + assert config.file is None + + +def test_a_path_with_no_file_sink_is_warned_about(tmp_path, caplog): + """The quiet mistake: a path is set, nothing is written to it.""" + with caplog.at_level(logging.WARNING): + config = _resolve( + enabled=True, sinks="console", file=str(tmp_path / "audit.log") + ) + assert config.writes_file is False + assert config.process_file is None + assert "no audit file will be written" in caplog.text + + +def test_zero_rotation_size_turns_size_rotation_off(tmp_path, caplog): + """0 is a documented instruction, not invalid input.""" + with caplog.at_level(logging.WARNING): + config = _file_audit(tmp_path, rotation_max_size_mb=0) + assert config.rotation_max_size_mb == 0 + assert config.max_bytes == 0 + assert "falling back" not in caplog.text + + +def test_negative_rotation_size_falls_back_with_a_warning(tmp_path, caplog): + with caplog.at_level(logging.WARNING): + config = _file_audit(tmp_path, rotation_max_size_mb=-5) + assert config.rotation_max_size_mb == 10.0 + assert "falling back to the default" in caplog.text + + +def test_negative_max_backups_falls_back_with_a_warning(tmp_path, caplog): + with caplog.at_level(logging.WARNING): + config = _file_audit(tmp_path, max_backups=-3) + assert config.max_backups == 10 + assert "max backups" in caplog.text + + +def test_zero_max_backups_is_honoured(tmp_path): + assert _file_audit(tmp_path, max_backups=0).max_backups == 0 + + +def test_tool_args_warns_only_when_auditing_is_active(tmp_path, caplog): + with caplog.at_level(logging.WARNING): + _file_audit(tmp_path, tool_args=True) + assert "recorded verbatim" in caplog.text + + caplog.clear() + with caplog.at_level(logging.WARNING): + _resolve(enabled=False, tool_args=True) + # Nothing is being written, so there is nothing to warn about. + assert "recorded verbatim" not in caplog.text + + +# --------------------------------------------------------------------------- +# rotation interval +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "seconds"), + [ + ("1d", DAY), + ("30d", 30 * DAY), + ("1w", WEEK), + ("8w", 8 * WEEK), + ("1W", WEEK), + (" 1d ", DAY), + ("0", 0), + ("0d", 0), + ], +) +def test_interval_parsing_accepts_the_prd_vocabulary(raw, seconds): + assert parse_rotation_interval(raw) == seconds + + +@pytest.mark.parametrize( + "raw", + [ + "1h", # hours are not a unit: the PRD defines d and w only + "1m", + "d", + "1", # a bare number other than 0 has no unit to apply + "1d2h", + "-1d", + "one day", + "", + ], +) +def test_interval_parsing_rejects_everything_else(raw): + assert parse_rotation_interval(raw) is None + + +def test_invalid_interval_falls_back_to_the_default_with_a_warning(tmp_path, caplog): + with caplog.at_level(logging.WARNING): + config = _file_audit(tmp_path, rotation_interval="1h") + assert config.rotation_interval == "1d" + assert config.rotation_interval_seconds == DAY + assert "Invalid audit rotation interval" in caplog.text + + +def test_zero_interval_turns_interval_rotation_off(tmp_path, caplog): + with caplog.at_level(logging.WARNING): + config = _file_audit(tmp_path, rotation_interval="0") + assert config.rotation_interval == "0" + assert config.rotation_interval_seconds == 0 + assert "Invalid" not in caplog.text + + +# --------------------------------------------------------------------------- +# disabled events +# --------------------------------------------------------------------------- + + +def test_disabled_events_accepts_numeric_ids(): + assert parse_disabled_events("61490") == {61490} + assert parse_disabled_events("61490, 61491") == {61490, 61491} + assert parse_disabled_events(" 61490 ,61491 ") == {61490, 61491} + + +def test_catalogue_names_are_not_accepted(caplog): + """Names were accepted once and were withdrawn deliberately. + + The id is the wire contract; the name is prose. Names carry spaces and + parentheses, which makes them awkward to quote in a shell or a Docker -e + value, and a name may be reworded for clarity in a way an id never is — so + a filter written against one could silently stop matching. A name is now + treated as an unknown entry and warned about, rather than half-working. + """ + with caplog.at_level(logging.WARNING): + assert parse_disabled_events("document read") == set() + assert parse_disabled_events("61490, query read") == {61490} + assert "unknown audit event" in caplog.text.lower() + + +@pytest.mark.parametrize("raw", [None, "", " ", ","]) +def test_empty_disabled_events_is_empty(raw): + assert parse_disabled_events(raw) == set() + + +def test_disabled_events_from_a_file_ignores_comments_and_blanks(tmp_path): + path = tmp_path / "filters.txt" + path.write_text( + "\n".join( + [ + "# suppress the noisy reads", + "61490", + "", + " 61491 ", + "# trailing comment", + ] + ), + encoding="utf-8", + ) + assert parse_disabled_events(str(path)) == {61490, 61491} + + +def test_non_filterable_events_cannot_be_suppressed(caplog): + with caplog.at_level(logging.WARNING): + # A write id, a security event and a lifecycle event, all by id. + result = parse_disabled_events("61522, 57377, 57344") + assert result == set() + assert "Refused to disable non-filterable" in caplog.text + + +def test_unknown_events_are_ignored_with_a_warning(caplog): + with caplog.at_level(logging.WARNING): + result = parse_disabled_events("99999, not an event, 61490") + assert result == {61490} + assert "unknown audit event" in caplog.text.lower() + + +def test_filters_are_not_parsed_when_auditing_is_disabled(): + config = _resolve(enabled=False, disabled_events="61490") + assert config.disabled_events == () + + +def test_session_initialized_is_filterable(tmp_path): + config = _file_audit(tmp_path, disabled_events="57360") + assert config.disabled_events == (57360,) + + +# --------------------------------------------------------------------------- +# the PRD's configuration cases +# --------------------------------------------------------------------------- + + +def test_case_1_audit_logs_switched_off(): + config = _resolve(enabled=False) + assert config.enabled is False + assert config.writes_console is False + assert config.writes_file is False + + +def test_case_2_single_file_with_no_limits(tmp_path): + """'I don't care about the size. I want to store them all.'""" + config = _file_audit( + tmp_path, rotation_max_size_mb=0, rotation_interval="0", max_backups=0 + ) + assert config.max_bytes == 0 + assert config.rotation_interval_seconds == 0 + assert config.max_backups == 0 + + +def test_case_3_single_file_with_limited_space(tmp_path): + config = _file_audit( + tmp_path, rotation_max_size_mb=10, rotation_interval="0", max_backups=0 + ) + assert config.max_bytes == 10 * BYTES_PER_MB + assert config.rotation_interval_seconds == 0 + assert config.max_backups == 0 + + +def test_case_4_limited_space_across_multiple_files(tmp_path): + """10 MB a file and 9 backups: 100 MB in total, as the PRD computes it.""" + config = _file_audit( + tmp_path, rotation_max_size_mb=10, rotation_interval="0", max_backups=9 + ) + assert config.max_bytes == 10 * BYTES_PER_MB + assert config.rotation_interval_seconds == 0 + assert config.max_backups == 9 + assert config.max_bytes * (config.max_backups + 1) == 100 * BYTES_PER_MB + + +def test_case_5_ninety_days_of_daily_logs(tmp_path): + config = _file_audit( + tmp_path, rotation_max_size_mb=0, rotation_interval="1d", max_backups=90 + ) + assert config.max_bytes == 0 + assert config.rotation_interval_seconds == DAY + assert config.max_backups == 90 + + +def test_case_6_ten_weeks_of_weekly_logs(tmp_path): + config = _file_audit( + tmp_path, rotation_max_size_mb=0, rotation_interval="1W", max_backups=9 + ) + assert config.max_bytes == 0 + assert config.rotation_interval_seconds == WEEK + assert config.max_backups == 9 + + +# --------------------------------------------------------------------------- +# snapshot +# --------------------------------------------------------------------------- + + +def test_as_dict_is_json_friendly_and_complete(tmp_path): + config = _file_audit( + tmp_path, + sinks="console,file", + rotation_max_size_mb=4, + rotation_interval="2w", + max_backups=7, + tool_args=True, + disabled_events="61490", + ) + snapshot = config.as_dict() + assert snapshot == { + "enabled": True, + "sinks": ["console", "file"], + "file": str(tmp_path / "audit.log"), + "process_file": config.process_file, + "rotation_max_size_mb": 4.0, + "rotation_interval": "2w", + "max_backups": 7, + "tool_args": True, + "disabled_events": [61490], + } diff --git a/tests/unit/test_audit_integration_points.py b/tests/unit/test_audit_integration_points.py new file mode 100644 index 00000000..7c126e13 --- /dev/null +++ b/tests/unit/test_audit_integration_points.py @@ -0,0 +1,808 @@ +"""Tests for the audit hooks added to existing gates. + +Auditing required narrowing three exception types that already existed. Every +new class subclasses the type the call site raised before, so existing callers +and tests are unaffected — these tests assert that backwards compatibility +explicitly, because it is the whole reason the refactor was safe. + +They also assert the other half of the contract: each gate records a structured +refusal into the per-request audit state *before* raising, since FastMCP wraps +the exception and the middleware could not otherwise tell a scope denial from a +read-only block from a genuine error. + +Coverage map: +- exception subclassing preserves PermissionError / ValueError contracts +- scope check records a refusal and raises ScopeDeniedError +- confirmation decline records a refusal and raises ConfirmationDeclinedError +- confirmation skip records the skipped disposition +- confirmation accept records the accepted disposition +- read-only SQL++ block records a refusal and raises ReadOnlyWriteBlockedError +- SQL++ statement class is recorded for audit when auditing is active +- SQL++ classification is not computed when auditing is off (no added cost) +- identity resolution across the three domains +- the local identity comes from the kernel, not the caller's environment +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import pwd +from types import SimpleNamespace +from typing import cast +from unittest.mock import MagicMock, patch + +import pytest +from fastmcp import Context + +from cb_mcp.audit import state as audit_state +from cb_mcp.audit.config import resolve_audit_config +from cb_mcp.audit.emitter import get_audit_logger +from cb_mcp.audit.exceptions import ( + ConfirmationDeclinedError, + ReadOnlyWriteBlockedError, + ScopeDeniedError, + StatementScopeDeniedError, +) +from cb_mcp.audit.identity import ( + DOMAIN_ANONYMOUS, + DOMAIN_LOCAL, + DOMAIN_OAUTH, + resolve_real_userid, + warn_on_unauthenticated_http, +) +from cb_mcp.core.app import build_app +from cb_mcp.servers.operational.spec import SPEC +from cb_mcp.tools.operational.query import ( + _query_correlation_options, + run_cluster_query, + run_sql_plus_plus_query, +) +from cb_mcp.utils.constants import SCOPE_READ, SCOPE_WRITE +from cb_mcp.utils.elicitation import ConfirmationResult, wrap_with_confirmation +from cb_mcp.utils.scope_enforcement import wrap_with_scope_check + +# --------------------------------------------------------------------------- +# backwards compatibility of the narrowed exception types +# --------------------------------------------------------------------------- + + +def test_exceptions_preserve_their_original_base_types(): + """Existing ``pytest.raises`` assertions must keep passing unchanged.""" + assert issubclass(ScopeDeniedError, PermissionError) + assert issubclass(StatementScopeDeniedError, PermissionError) + assert issubclass(ConfirmationDeclinedError, PermissionError) + assert issubclass(ReadOnlyWriteBlockedError, ValueError) + + +@pytest.mark.parametrize( + "exception_class", + [ + ScopeDeniedError, + StatementScopeDeniedError, + ConfirmationDeclinedError, + ReadOnlyWriteBlockedError, + ], +) +def test_exceptions_carry_their_audit_classification(exception_class): + assert exception_class.audit_event.id + assert exception_class.audit_outcome in ("denied", "blocked") + assert exception_class.audit_reason + + +# --------------------------------------------------------------------------- +# scope enforcement +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_scope_check_records_a_refusal_and_raises_scope_denied(): + async def tool(ctx=None): + return "should not run" + + tool.__name__ = "upsert_document_by_id" + wrapped = wrap_with_scope_check(tool, {SCOPE_WRITE}) + token = audit_state.install() + try: + with ( + patch( + "cb_mcp.utils.scope_enforcement.get_access_token", + return_value=SimpleNamespace(scopes=[SCOPE_READ]), + ), + pytest.raises(ScopeDeniedError), + ): + await wrapped() + refusal = audit_state.get_refusal() + assert refusal is not None + assert refusal["event_id"] == 57377 + assert refusal["outcome"] == "denied" + assert refusal["reason"] == "missing_scope" + # The gate records what it knows: which labels were missing. The + # short ``required_scope`` is added by the middleware, so event + # 57377 carries one JSON shape whichever gate refused the call. + assert refusal["missing_scopes"] == [SCOPE_WRITE] + assert "required_scope" not in refusal + finally: + audit_state.reset(token) + + +@pytest.mark.asyncio +async def test_scope_check_records_nothing_when_the_token_is_sufficient(): + async def tool(ctx=None): + return "ran" + + tool.__name__ = "upsert_document_by_id" + wrapped = wrap_with_scope_check(tool, {SCOPE_WRITE}) + token = audit_state.install() + try: + with patch( + "cb_mcp.utils.scope_enforcement.get_access_token", + return_value=SimpleNamespace(scopes=[SCOPE_WRITE]), + ): + assert await wrapped() == "ran" + assert audit_state.get_refusal() is None + finally: + audit_state.reset(token) + + +# --------------------------------------------------------------------------- +# confirmation +# --------------------------------------------------------------------------- + + +def _ctx_with_elicitation( + *, supported: bool, action: str = "accept", confirm: bool = True +) -> Context: + async def elicit(message, response_type): + return SimpleNamespace(action=action, data=ConfirmationResult(confirm=confirm)) + + session = SimpleNamespace(check_client_capability=lambda _caps: supported) + return SimpleNamespace( # type: ignore[return-value] + request_context=SimpleNamespace(session=session), elicit=elicit + ) + + +@pytest.mark.asyncio +async def test_declined_confirmation_records_a_refusal(): + async def tool(ctx=None): + return "should not run" + + tool.__name__ = "delete_document_by_id" + wrapped = wrap_with_confirmation(tool) + token = audit_state.install() + try: + with pytest.raises(ConfirmationDeclinedError): + await wrapped(ctx=_ctx_with_elicitation(supported=True, action="decline")) + refusal = audit_state.get_refusal() + assert refusal["event_id"] == 57490 + assert refusal["outcome"] == "blocked" + assert refusal["reason"] == "confirmation_declined" + assert audit_state.get_confirmation() == "declined" + finally: + audit_state.reset(token) + + +@pytest.mark.asyncio +async def test_skipped_confirmation_is_recorded_and_the_tool_still_runs(): + """The pre-audit behaviour left this bypass invisible above DEBUG.""" + + async def tool(ctx=None): + return "ran without confirmation" + + tool.__name__ = "delete_document_by_id" + wrapped = wrap_with_confirmation(tool) + token = audit_state.install() + try: + result = await wrapped(ctx=_ctx_with_elicitation(supported=False)) + assert result == "ran without confirmation" + assert audit_state.get_confirmation() == "skipped" + # Not a refusal: the call proceeded. + assert audit_state.get_refusal() is None + finally: + audit_state.reset(token) + + +@pytest.mark.asyncio +async def test_accepted_confirmation_is_recorded(): + async def tool(ctx=None): + return "ran" + + tool.__name__ = "delete_document_by_id" + wrapped = wrap_with_confirmation(tool) + token = audit_state.install() + try: + assert await wrapped(ctx=_ctx_with_elicitation(supported=True)) == "ran" + assert audit_state.get_confirmation() == "accepted" + finally: + audit_state.reset(token) + + +# --------------------------------------------------------------------------- +# SQL++ read-only guard and statement classification +# --------------------------------------------------------------------------- + + +def _query_ctx(*, read_only_mode: bool): + scope = SimpleNamespace(query=lambda *a, **k: iter(())) + bucket = SimpleNamespace(scope=lambda _name: scope) + cluster = SimpleNamespace(bucket=lambda _name: bucket) + ctx = SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=SimpleNamespace( + cluster_provider=SimpleNamespace(get_cluster=lambda _ctx: cluster), + read_only_mode=read_only_mode, + ) + ) + ) + return ctx + + +def test_read_only_block_records_a_refusal_and_raises_a_value_error(): + ctx = _query_ctx(read_only_mode=True) + token = audit_state.install() + try: + # Still a ValueError, so pre-existing callers are unaffected. + with pytest.raises(ValueError, match="not allowed in read-only mode"): + run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") + refusal = audit_state.get_refusal() + assert refusal["event_id"] == 57488 + assert refusal["outcome"] == "blocked" + assert refusal["reason"] == "read_only_mode" + assert refusal["statement_kind"] == "data" + finally: + audit_state.reset(token) + + +def test_statement_class_is_recorded_when_auditing_is_active(): + """An *allowed* DML must still be classified, or it would be filed as a read.""" + ctx = _query_ctx(read_only_mode=False) + token = audit_state.install() + try: + with patch( + "cb_mcp.tools.operational.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ): + run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") + assert audit_state.get_statement_class() == "write" + finally: + audit_state.reset(token) + + +def test_select_is_classified_read_when_auditing_is_active(): + ctx = _query_ctx(read_only_mode=False) + token = audit_state.install() + try: + with patch( + "cb_mcp.tools.operational.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ): + run_sql_plus_plus_query(ctx, "b", "s", "SELECT * FROM users") + assert audit_state.get_statement_class() == "read" + finally: + audit_state.reset(token) + + +def test_explain_is_classified_read_without_parsing(): + ctx = _query_ctx(read_only_mode=True) + token = audit_state.install() + try: + with patch( + "cb_mcp.tools.operational.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ): + run_sql_plus_plus_query(ctx, "b", "s", "EXPLAIN SELECT * FROM users") + assert audit_state.get_statement_class() == "read" + finally: + audit_state.reset(token) + + +def test_no_statement_parse_when_auditing_is_off_and_writes_are_allowed(): + """Auditing off must not add cost to the busiest tool in the server.""" + ctx = _query_ctx(read_only_mode=False) + token = audit_state.install() + try: + with patch("cb_mcp.tools.operational.query.parse_sqlpp") as parse: + with patch( + "cb_mcp.tools.operational.query.get_audit_logger", + return_value=SimpleNamespace(active=False), + ): + run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") + parse.assert_not_called() + assert audit_state.get_statement_class() is None + finally: + audit_state.reset(token) + + +def test_unparseable_statement_still_runs_when_writes_are_allowed_and_auditing_on(): + """Enabling auditing must not fail a call the server would otherwise run. + + lark_sqlpp is a partial grammar: valid statements it cannot parse (MERGE, a + leading block comment, newer syntax) raise from ``parse_sqlpp``. With writes + allowed the parse is only for audit classification, so a parse failure must + degrade to the fail-closed 'write' class and let the query execute — not + surface as a tool-call failure that appears only when auditing is on. + """ + ctx = _query_ctx(read_only_mode=False) + token = audit_state.install() + try: + with ( + patch( + "cb_mcp.tools.operational.query.parse_sqlpp", + side_effect=Exception("unparseable by the partial grammar"), + ), + patch( + "cb_mcp.tools.operational.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ), + ): + result = run_sql_plus_plus_query( + ctx, "b", "s", "MERGE INTO a USING b ON a.id = b.id" + ) + # Executed rather than raised, and booked against the never-filtered + # query write id so a possible mutation is never dropped from the log. + # Asserted on the outcome rather than the whole payload: what this test + # is about is that the call ran, not what the query tool's result looks + # like, and pinning the full shape made it a casualty of an unrelated + # change to that shape. + assert result["success"] is True + assert result["rows"] == [] + assert audit_state.get_statement_class() == "write" + finally: + audit_state.reset(token) + + +def test_unparseable_statement_still_fails_safe_when_writes_are_blocked(): + """The security write guard must keep failing closed on an unparseable + statement: without a definitive read/write verdict it cannot prove the + statement is a read, so it must refuse rather than execute. This is the + pre-audit behaviour and must be preserved exactly.""" + ctx = _query_ctx(read_only_mode=True) + token = audit_state.install() + try: + with ( + patch( + "cb_mcp.tools.operational.query.parse_sqlpp", + side_effect=Exception("unparseable by the partial grammar"), + ), + patch( + "cb_mcp.tools.operational.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ), + pytest.raises(Exception, match="unparseable"), + ): + run_sql_plus_plus_query( + ctx, "b", "s", "MERGE INTO a USING b ON a.id = b.id" + ) + finally: + audit_state.reset(token) + + +def test_statement_is_classified_before_the_cluster_is_touched(): + """A failed DML attempt must not be booked against the filterable read id. + + Connecting first meant an unreachable cluster raised before the statement + was ever inspected, so "someone tried to UPDATE and it failed" was recorded + as a query *read* — and read ids are filterable, so an operator turning + read noise down would lose it. + """ + + def unreachable(_ctx): + raise RuntimeError("cluster unreachable") + + ctx = SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=SimpleNamespace( + cluster_provider=SimpleNamespace(get_cluster=unreachable), + read_only_mode=False, + ) + ) + ) + token = audit_state.install() + try: + with ( + patch( + "cb_mcp.tools.operational.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ), + pytest.raises(RuntimeError, match="unreachable"), + ): + run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") + assert audit_state.get_statement_class() == "write" + finally: + audit_state.reset(token) + + +def test_read_only_block_does_not_need_a_reachable_cluster(): + """The guard is a policy decision, so it answers before any connection. + + The caller now gets the accurate reason — blocked — rather than whichever + connection error happened to come first. + """ + + def unreachable(_ctx): + raise RuntimeError("cluster unreachable") + + ctx = SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=SimpleNamespace( + cluster_provider=SimpleNamespace(get_cluster=unreachable), + read_only_mode=True, + ) + ) + ) + token = audit_state.install() + try: + with pytest.raises(ReadOnlyWriteBlockedError): + run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") + finally: + audit_state.reset(token) + + +# --------------------------------------------------------------------------- +# identity +# --------------------------------------------------------------------------- + + +def test_stdio_resolves_to_the_local_process_owner(): + with patch("cb_mcp.audit.identity.get_access_token", return_value=None): + identity = resolve_real_userid("stdio") + assert identity["domain"] == DOMAIN_LOCAL + assert identity["user"] + + +def test_local_identity_ignores_the_caller_controlled_environment(): + """``real_userid`` must not be settable by whoever launches the server. + + Under stdio the client *is* the launcher — Claude Desktop, Cursor, a shell + script — so it controls the process environment. ``getpass.getuser()`` + reads ``LOGNAME``/``USER``/``LNAME``/``USERNAME`` before the password + database, which would let that launcher write any name it liked into the + access-decision field of every record. The uid comes from the kernel. + """ + real_name = pwd.getpwuid(os.geteuid()).pw_name + spoofed = { + "LOGNAME": "alice", + "USER": "alice", + "LNAME": "alice", + "USERNAME": "alice", + } + with ( + patch.dict(os.environ, spoofed), + patch("cb_mcp.audit.identity.get_access_token", return_value=None), + ): + identity = resolve_real_userid("stdio") + assert identity == {"domain": DOMAIN_LOCAL, "user": real_name} + assert identity["user"] != "alice" + + +def test_local_identity_falls_back_to_the_numeric_uid_without_a_passwd_entry(): + """Containers routinely run as a uid with no passwd entry. + + A number is a worse identity than a name but an honest one; failing the + call, or silently taking the environment's word for it, would both be + worse. + """ + with ( + patch("cb_mcp.audit.identity.pwd.getpwuid", side_effect=KeyError(1001)), + patch("cb_mcp.audit.identity.get_access_token", return_value=None), + ): + identity = resolve_real_userid("stdio") + assert identity == {"domain": DOMAIN_LOCAL, "user": str(os.geteuid())} + + +def test_http_without_a_token_is_anonymous(): + with patch("cb_mcp.audit.identity.get_access_token", return_value=None): + identity = resolve_real_userid("http") + assert identity == {"domain": DOMAIN_ANONYMOUS, "user": DOMAIN_ANONYMOUS} + + +def test_oauth_prefers_the_token_subject(): + token = SimpleNamespace( + subject="agent-svc-7", + claims={"sub": "ignored"}, + client_id="client-1", + scopes=[], + ) + with patch("cb_mcp.audit.identity.get_access_token", return_value=token): + identity = resolve_real_userid("http") + assert identity == {"domain": DOMAIN_OAUTH, "user": "agent-svc-7"} + + +def test_oauth_falls_back_to_the_sub_claim_then_client_id(): + sub_only = SimpleNamespace( + subject=None, claims={"sub": "from-claim"}, client_id="client-1", scopes=[] + ) + with patch("cb_mcp.audit.identity.get_access_token", return_value=sub_only): + assert resolve_real_userid("http")["user"] == "from-claim" + + client_only = SimpleNamespace( + subject=None, claims={}, client_id="client-1", scopes=[] + ) + with patch("cb_mcp.audit.identity.get_access_token", return_value=client_only): + assert resolve_real_userid("http")["user"] == "client-1" + + +def test_token_with_no_usable_identity_is_not_reported_as_oauth(): + """Recording an empty subject under the oauth domain would be misleading.""" + empty = SimpleNamespace(subject=None, claims={}, client_id=None, scopes=[]) + with patch("cb_mcp.audit.identity.get_access_token", return_value=empty): + assert resolve_real_userid("http")["domain"] == DOMAIN_ANONYMOUS + + +def test_unauthenticated_http_warns_at_startup(caplog): + with caplog.at_level(logging.WARNING): + warn_on_unauthenticated_http("http", oauth_enabled=False) + assert "anonymous" in caplog.text + + caplog.clear() + with caplog.at_level(logging.WARNING): + warn_on_unauthenticated_http("http", oauth_enabled=True) + warn_on_unauthenticated_http("stdio", oauth_enabled=False) + assert caplog.text == "" + + +# --------------------------------------------------------------------------- +# SQL++ correlation id propagation +# --------------------------------------------------------------------------- + + +def test_query_carries_no_correlation_id_when_auditing_is_off(): + """An unaudited query must be byte-for-byte the request it was before. + + The options dict is spread into the SDK call, so an empty dict means the + call site is unchanged from the pre-audit behaviour. + """ + assert audit_state.current() is None + assert _query_correlation_options() == {} + + +def test_query_carries_the_requests_correlation_id_when_auditing_is_on(): + """The id we stamp on the audit record is the one Server will see. + + Couchbase Server's SQL++ audit record carries ``clientContextId``, taken + from this parameter. Sending the same ``cid`` is what makes an MCP record + and a Server record joinable by equality rather than by timestamp. + """ + token = audit_state.install(cid="cid-abc-123") + try: + assert _query_correlation_options() == {"client_context_id": "cid-abc-123"} + finally: + audit_state.reset(token) + + +def test_query_correlation_is_omitted_when_state_carries_no_cid(): + """State can exist without a cid — a hook invoked outside on_message.""" + token = audit_state.install() + try: + assert _query_correlation_options() == {} + finally: + audit_state.reset(token) + + +# --------------------------------------------------------------------------- +# lifecycle +# --------------------------------------------------------------------------- + + +def test_audit_is_shut_down_when_provider_startup_fails(tmp_path): + """A failed startup is exactly when the audit trail matters. + + ``_start_audit`` runs first, so by the time the provider is constructed the + sink is open, the writer thread is running and the server-started record is + queued. Building the AppContext outside the try meant a provider failure + skipped the cleanup entirely: the queued record was lost and the sink stayed + open for the life of the process. + """ + audit_config = resolve_audit_config( + enabled=True, + sinks="file", + file=str(tmp_path / "audit.log"), + rotation_max_size_mb=None, + max_backups=None, + tool_args=None, + disabled_events=None, + ) + captured: dict = {} + + def capture(*_args, **kwargs): + captured["lifespan"] = kwargs.get("lifespan") + return MagicMock() + + def exploding_provider(): + raise RuntimeError("cannot reach the cluster") + + with patch("cb_mcp.core.app.FastMCP", side_effect=capture): + build_app( + SPEC, + tools=list(SPEC.tools.all_tools)[:2], + settings={ + "transport": "stdio", + "oauth_enabled": False, + "disabled_tools": set(), + "confirmation_required_tools": set(), + }, + provider_factory=exploding_provider, + read_only_mode=True, + audit_config=audit_config, + ) + + async def drive(): + async with captured["lifespan"](SimpleNamespace()): + pass # pragma: no cover - startup raises before the body runs + + with pytest.raises(RuntimeError, match="cannot reach the cluster"): + asyncio.run(drive()) + + # The sink was closed rather than left open for the process lifetime. + assert not get_audit_logger().active + + written = [ + json.loads(line) + for path in tmp_path.iterdir() + if path.suffix == ".log" + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + by_name = {record["name"]: record for record in written} + assert "server started" in by_name, "queued records were lost on the failed startup" + assert "server stopped" in by_name, "no shutdown record for the failed startup" + + # The catalogue describes this event as "shut down cleanly", so a lifespan + # that exited by raising must not claim success. "server started" followed + # by "success" would read as a healthy run in an audit review. + stopped = by_name["server stopped"] + assert stopped["outcome"] == "error", stopped + assert stopped["reason"] == "lifespan_error", stopped + + +# --------------------------------------------------------------------------- +# the correlation id must actually reach the SDK +# --------------------------------------------------------------------------- + + +def _ctx_with(**lifespan) -> Context: + """A Context exposing just the lifespan attributes the query tool reads.""" + return cast( + Context, + SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=SimpleNamespace(**lifespan) + ) + ), + ) + + +def test_scope_query_is_given_the_requests_correlation_id(): + """The MCP-to-Server audit join depends on this argument being passed. + + ``_query_correlation_options`` was tested in isolation, so nothing noticed + whether the tool handed the result to the SDK. Replacing the call with + ``options = {}`` passed the whole suite while the join advertised in + README silently stopped working — and the correlation only exists in + historical data if it was written at the time. + """ + captured: dict = {} + + class FakeScope: + def query(self, statement, **kwargs): + captured["statement"] = statement + captured["kwargs"] = kwargs + return [] + + class FakeBucket: + def scope(self, _name): + return FakeScope() + + cid = "11111111-2222-3333-4444-555555555555" + state_dict = {"cid": cid} + token = audit_state._AUDIT_STATE.set(state_dict) + try: + with ( + patch( + "cb_mcp.tools.operational.query.get_cluster_connection", + return_value=SimpleNamespace(), + ), + patch( + "cb_mcp.tools.operational.query.connect_to_bucket", + return_value=FakeBucket(), + ), + patch( + "cb_mcp.tools.operational.query.get_audit_logger", + return_value=SimpleNamespace(active=False), + ), + patch( + "cb_mcp.tools.operational.query.get_access_token", + return_value=None, + ), + ): + run_sql_plus_plus_query( + _ctx_with(read_only_mode=False), + bucket_name="travel-sample", + scope_name="inventory", + query="SELECT 1", + ) + finally: + audit_state._AUDIT_STATE.reset(token) + + assert captured["kwargs"].get("client_context_id") == cid, captured + + +def test_an_explicit_client_context_id_is_not_overwritten(): + """A caller passing their own id keeps it, as the comment promises.""" + captured: dict = {} + + class FakeCluster: + def query(self, statement, **kwargs): + captured["kwargs"] = kwargs + return [] + + state_dict = {"cid": "audit-minted-id"} + token = audit_state._AUDIT_STATE.set(state_dict) + try: + with patch( + "cb_mcp.tools.operational.query.get_cluster_connection", + return_value=FakeCluster(), + ): + run_cluster_query( + _ctx_with(read_only_mode=False), + "SELECT 1", + client_context_id="callers-own-id", + ) + finally: + audit_state._AUDIT_STATE.reset(token) + + assert captured["kwargs"]["client_context_id"] == "callers-own-id" + + +# --------------------------------------------------------------------------- +# statement-level scope gate +# --------------------------------------------------------------------------- + + +def test_statement_scope_denial_records_a_refusal(): + """The only place a statement-level scope denial is decided. + + It is also the only refusal that passes an explicit ``required_scope`` into + the state channel, because the class depends on the statement rather than + on the tool. Without this test, deleting the ``record_refusal`` call leaves + the suite green while a policy denial is recorded as + ``query write / error / execution_error`` -- an execution failure, not a + refusal, and filterable rather than a security event. + """ + token = SimpleNamespace(scopes=["couchbase-mcp:read"], claims={}, client_id="c") + ctx = SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=SimpleNamespace( + cluster_provider=SimpleNamespace( + get_cluster=lambda _c: pytest.fail("must refuse before connecting") + ), + read_only_mode=False, + ) + ) + ) + state_token = audit_state.install() + try: + with ( + patch( + "cb_mcp.tools.operational.query.get_access_token", return_value=token + ), + patch( + "cb_mcp.tools.operational.query.get_audit_logger", + return_value=SimpleNamespace(active=True), + ), + pytest.raises(StatementScopeDeniedError), + ): + run_sql_plus_plus_query(ctx, "b", "s", "UPDATE users SET age = 25") + + refusal = audit_state.current()["refusal"] + finally: + audit_state.reset(state_token) + + assert refusal["event_id"] == StatementScopeDeniedError.audit_event.id + assert refusal["outcome"] == StatementScopeDeniedError.audit_outcome + assert refusal["reason"] == StatementScopeDeniedError.audit_reason + # The two fields only this gate contributes. + assert refusal["required_scope"] == "write" + assert refusal["statement_kind"] diff --git a/tests/unit/test_audit_middleware.py b/tests/unit/test_audit_middleware.py new file mode 100644 index 00000000..f32e9fce --- /dev/null +++ b/tests/unit/test_audit_middleware.py @@ -0,0 +1,998 @@ +"""End-to-end tests for the audit middleware. + +These run a real ``FastMCP`` server with the middleware installed and a real +:class:`AuditSink` writing to a temporary file, then read the file back. Nothing +is mocked between the tool call and the record on disk, because the behaviours +that matter here are exactly the ones a mock would paper over: + +* FastMCP wraps a tool's exception in ``ToolError``, so a refusal has to travel + to the middleware through the per-request audit state rather than through the + exception. +* Synchronous tools run on a worker thread, so that state has to be a mutable + object shared across a copied context rather than a rebound contextvar. + +Coverage map: +- session-initialized record from the initialize handshake +- tool-call read and write records, ids and required_scope +- keyspace extraction from tool arguments +- success, execution error and result-reported error outcomes +- scope denial recorded as a core authorization event, with no tool-call event +- read-only SQL++ block recorded as a guardrail event +- confirmation declined and confirmation skipped +- SQL++ statement-driven read/write classification +- argument capture on and off +- per-event filtering +- correlation: one ``cid`` per request, readable from tool code, over the + in-memory and real HTTP transports, and no session identifier anywhere +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from dataclasses import dataclass, field +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +import uvicorn +from fastmcp import Client, Context, FastMCP +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.exceptions import ToolError + +from cb_mcp.audit import state as audit_state +from cb_mcp.audit.catalog import AuditEvent +from cb_mcp.audit.classification import resolve_tool_call_event +from cb_mcp.audit.config import resolve_audit_config +from cb_mcp.audit.emitter import AuditLogger +from cb_mcp.audit.exceptions import ReadOnlyWriteBlockedError, ScopeDeniedError +from cb_mcp.audit.identity import resolve_real_userid +from cb_mcp.audit.middleware import AuditMiddleware, extract_keyspace +from cb_mcp.audit.record import OUTCOME_ERROR +from cb_mcp.audit.sink import AuditSink + + +def _build_logger(tmp_path: Path, **overrides) -> AuditLogger: + """An active AuditLogger writing to a real file under ``tmp_path``.""" + options = { + "enabled": True, + "file": str(tmp_path / "audit.log"), + "sinks": "file", + "rotation_max_size_mb": 8.0, + "rotation_interval": "0", + "max_backups": 2, + "tool_args": False, + "disabled_events": None, + } + options.update(overrides) + config = resolve_audit_config(**options) + sink = AuditSink( + config.file, + max_bytes=config.max_bytes, + max_backups=config.max_backups, + interval_seconds=config.rotation_interval_seconds, + ) + sink.start() + return AuditLogger(config, sink) + + +def _records(audit: AuditLogger) -> list[dict]: + """Close the sink so the writer thread drains, then parse every line.""" + path = audit._sink.path + audit.close() + if not path.exists(): + return [] + return [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + +def _server(audit: AuditLogger, transport: str = "stdio") -> FastMCP: + mcp = FastMCP("audit-test") + mcp.add_middleware( + AuditMiddleware( + transport=transport, + cb_userid="mcp_service", + audit_logger=audit, + service_package="operational", + ) + ) + return mcp + + +# --------------------------------------------------------------------------- +# keyspace extraction +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("arguments", "expected"), + [ + (None, None), + ({}, None), + ({"limit": 5}, None), + ({"bucket_name": "travel-sample"}, "travel-sample"), + ({"bucket_name": "b", "scope_name": "s"}, "b.s"), + ({"bucket_name": "b", "scope_name": "s", "collection_name": "c"}, "b.s.c"), + # A gap truncates rather than producing a misleading dotted name. + ({"bucket_name": "b", "collection_name": "c"}, "b"), + ({"scope_name": "s", "collection_name": "c"}, None), + ({"bucket_name": "", "scope_name": "s"}, None), + ], +) +def test_extract_keyspace(arguments, expected): + assert extract_keyspace(arguments) == expected + + +# --------------------------------------------------------------------------- +# session + tool calls +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_session_initialized_is_recorded(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + async with Client(mcp): + pass + + records = _records(audit) + sessions = [r for r in records if r["id"] == AuditEvent.SESSION_INITIALIZED.id] + assert len(sessions) == 1 + record = sessions[0] + assert record["name"] == "session initialized" + assert record["outcome"] == "success" + assert record["client_name"] + assert record["protocol_version"] + assert record["real_userid"]["domain"] == "local" + assert record["cid"] + + +@pytest.mark.asyncio +async def test_read_tool_call_record(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id( + ctx: Context, bucket_name: str, scope_name: str, collection_name: str + ) -> str: + """read a document""" + return "doc" + + async with Client(mcp) as client: + await client.call_tool( + "get_document_by_id", + { + "bucket_name": "travel-sample", + "scope_name": "inventory", + "collection_name": "airline", + }, + ) + + calls = [r for r in _records(audit) if r["id"] == 61490] + assert len(calls) == 1 + record = calls[0] + assert record["name"] == "document read" + assert record["outcome"] == "success" + assert record["tool_name"] == "get_document_by_id" + assert record["required_scope"] == "read" + assert record["ks"] == "travel-sample.inventory.airline" + assert record["cb_userid"] == "mcp_service" + assert "reason" not in record + # Arguments are not captured unless explicitly enabled. + assert "args" not in record + + +@pytest.mark.asyncio +async def test_write_tool_call_record_is_a_write_id(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def upsert_document_by_id( + ctx: Context, bucket_name: str, scope_name: str, collection_name: str + ) -> str: + """write a document""" + return "ok" + + async with Client(mcp) as client: + await client.call_tool( + "upsert_document_by_id", + {"bucket_name": "b", "scope_name": "s", "collection_name": "c"}, + ) + + calls = [ + r for r in _records(audit) if r.get("tool_name") == "upsert_document_by_id" + ] + assert [r["id"] for r in calls] == [61522] + assert calls[0]["required_scope"] == "write" + + +@pytest.mark.asyncio +async def test_a_cancelled_call_is_still_recorded(tmp_path): + """A caller must not be able to erase a write record by cancelling. + + ``asyncio.CancelledError`` is a ``BaseException``, so an ``except + Exception`` around the tool let it escape with no record at all. The tool + may already have mutated the cluster by then — a client timeout, a dropped + HTTP connection or a shutdown mid-call would leave the write invisible, + and document-write ids are non-filterable precisely so they cannot be + suppressed. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + mutated = {"done": False} + + @mcp.tool + async def upsert_document_by_id( + ctx: Context, bucket_name: str, scope_name: str, collection_name: str + ) -> str: + """mutates, then is slow to answer""" + mutated["done"] = True + await asyncio.sleep(30) + return "never reached" + + async with Client(mcp) as client: + with contextlib.suppress(Exception, asyncio.CancelledError): + await asyncio.wait_for( + client.call_tool( + "upsert_document_by_id", + { + "bucket_name": "travel-sample", + "scope_name": "inventory", + "collection_name": "airline", + }, + ), + timeout=0.5, + ) + await asyncio.sleep(0.2) + + assert mutated["done"], "the probe never reached the mutation" + records = _records(audit) + writes = [r for r in records if r.get("tool_name") == "upsert_document_by_id"] + assert writes, f"the cancelled write left no record: {[r['name'] for r in records]}" + assert writes[0]["id"] == 61522, writes[0] + assert writes[0]["outcome"] == OUTCOME_ERROR + # Write ids are never filterable, so the record cannot be configured away + # either. + assert ( + resolve_tool_call_event( + "upsert_document_by_id", package="operational" + ).filterable + is False + ) + + +@pytest.mark.asyncio +async def test_malformed_arguments_are_recorded_as_invalid_arguments(tmp_path): + """Bad input and a failed execution are different facts. + + FastMCP validates arguments against the tool's schema before the body runs, + so ``execution_error`` on one of those asserts an execution that never + began — on a record that also carries a keyspace and a required scope taken + from arguments the server refused. The PRD defines ``invalid_arguments`` + for exactly this; it was defined in the code and never emitted. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id( + ctx: Context, bucket_name: str, scope_name: str, collection_name: str + ) -> str: + """never reached""" + raise AssertionError("the tool body must not run") + + async with Client(mcp) as client: + with contextlib.suppress(Exception): + # scope_name and collection_name missing, bucket_name the wrong type + await client.call_tool("get_document_by_id", {"bucket_name": 123}) + + records = [r for r in _records(audit) if r.get("tool_name") == "get_document_by_id"] + assert records, "a rejected call left no record" + assert records[0]["outcome"] == OUTCOME_ERROR + assert records[0]["reason"] == "invalid_arguments", records[0] + + +@pytest.mark.asyncio +async def test_execution_error_outcome(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """raises""" + raise RuntimeError("cluster unreachable") + + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + + calls = [r for r in _records(audit) if r["id"] == 61490] + assert len(calls) == 1 + assert calls[0]["outcome"] == "error" + assert calls[0]["reason"] == "execution_error" + + +@pytest.mark.asyncio +async def test_argument_capture_when_enabled(tmp_path): + audit = _build_logger(tmp_path, tool_args=True) + mcp = _server(audit) + + @mcp.tool + def upsert_document_by_id( + ctx: Context, bucket_name: str, document_content: dict + ) -> str: + """write""" + return "ok" + + async with Client(mcp) as client: + await client.call_tool( + "upsert_document_by_id", + {"bucket_name": "b", "document_content": {"secret": "value"}}, + ) + + calls = [ + r for r in _records(audit) if r.get("tool_name") == "upsert_document_by_id" + ] + # No redaction exists in this release: the body is recorded verbatim. This + # asserts the documented behaviour so a future redaction change is a + # deliberate, visible test update rather than a silent one. + assert calls[0]["args"]["document_content"] == {"secret": "value"} + + +@pytest.mark.asyncio +async def test_unclassified_tool_fails_closed_to_a_write_id(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def brand_new_tool(ctx: Context) -> str: + """not in the classification map""" + return "ok" + + async with Client(mcp) as client: + await client.call_tool("brand_new_tool", {}) + + calls = [r for r in _records(audit) if r.get("tool_name") == "brand_new_tool"] + assert calls[0]["required_scope"] == "write" + assert calls[0]["unclassified"] is True + + +# --------------------------------------------------------------------------- +# refusals +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_scope_denial_emits_authz_event_and_no_tool_call(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def upsert_document_by_id(ctx: Context, bucket_name: str) -> str: + """denied before it runs""" + audit_state.record_refusal( + event_id=ScopeDeniedError.audit_event.id, + event_name=ScopeDeniedError.audit_event.event_name, + outcome=ScopeDeniedError.audit_outcome, + reason=ScopeDeniedError.audit_reason, + required_scope=["couchbase-mcp:write"], + ) + raise ScopeDeniedError("missing scope") + + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("upsert_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + authz = [r for r in records if r["id"] == AuditEvent.SCOPE_CHECK_DENIED.id] + assert len(authz) == 1 + assert authz[0]["outcome"] == "denied" + assert authz[0]["reason"] == "missing_scope" + assert authz[0]["tool_name"] == "upsert_document_by_id" + # The tool never ran, so there is no tool-call event to double-count it. + assert not [r for r in records if r["id"] == 61522] + + +@pytest.mark.asyncio +async def test_read_only_block_emits_guardrail_event(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def run_sql_plus_plus_query(ctx: Context, bucket_name: str, query: str) -> str: + """blocked by read-only mode""" + audit_state.record_statement_class("write") + audit_state.record_refusal( + event_id=ReadOnlyWriteBlockedError.audit_event.id, + event_name=ReadOnlyWriteBlockedError.audit_event.event_name, + outcome=ReadOnlyWriteBlockedError.audit_outcome, + reason=ReadOnlyWriteBlockedError.audit_reason, + statement_kind="data", + ) + raise ReadOnlyWriteBlockedError("not allowed in read-only mode") + + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool( + "run_sql_plus_plus_query", {"bucket_name": "b", "query": "DELETE ..."} + ) + + records = _records(audit) + guardrail = [r for r in records if r["id"] == AuditEvent.WRITE_BLOCKED_READ_ONLY.id] + assert len(guardrail) == 1 + assert guardrail[0]["outcome"] == "blocked" + assert guardrail[0]["reason"] == "read_only_mode" + assert guardrail[0]["statement_kind"] == "data" + + +@pytest.mark.asyncio +async def test_confirmation_skipped_emits_guardrail_and_tool_call(tmp_path): + """The tool *did* run, so both records are produced and they share a cid.""" + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def delete_document_by_id(ctx: Context, bucket_name: str) -> str: + """runs without confirmation because the client cannot elicit""" + audit_state.record_confirmation(audit_state.CONFIRMATION_SKIPPED) + return "deleted" + + async with Client(mcp) as client: + await client.call_tool("delete_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + skipped = [r for r in records if r["id"] == AuditEvent.CONFIRMATION_SKIPPED.id] + calls = [r for r in records if r["id"] == 61522] + assert len(skipped) == 1 + assert len(calls) == 1 + assert skipped[0]["reason"] == "confirmation_unsupported" + assert calls[0]["confirmation"] == "skipped" + assert calls[0]["outcome"] == "success" + # Same request, so an investigator can join the two records. + assert skipped[0]["cid"] == calls[0]["cid"] + + +@pytest.mark.asyncio +async def test_confirmation_skipped_is_not_claimed_when_the_call_was_blocked(tmp_path): + """57491 asserts the tool executed. It must not fire when a gate stopped it. + + The confirmation wrapper sits outside the tool and the SQL++ gates sit + inside it, so a call can skip confirmation and then be refused anyway. + Emitting both left the trail saying a destructive tool ran unconfirmed + *and* was blocked — and 57491 carries ``outcome=success``, so a reviewer + counting unconfirmed executions would count one that never happened. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def run_sql_plus_plus_query(ctx: Context, bucket_name: str, query: str) -> str: + """skips confirmation, then the read-only gate refuses it""" + audit_state.record_confirmation(audit_state.CONFIRMATION_SKIPPED) + audit_state.record_statement_class("write") + audit_state.record_refusal( + event_id=ReadOnlyWriteBlockedError.audit_event.id, + event_name=ReadOnlyWriteBlockedError.audit_event.event_name, + outcome=ReadOnlyWriteBlockedError.audit_outcome, + reason=ReadOnlyWriteBlockedError.audit_reason, + statement_kind="data", + ) + raise ReadOnlyWriteBlockedError("not allowed in read-only mode") + + async with Client(mcp) as client: + with contextlib.suppress(ToolError): + await client.call_tool( + "run_sql_plus_plus_query", + {"bucket_name": "b", "query": "DELETE FROM airline"}, + ) + + records = _records(audit) + skipped = [r for r in records if r["id"] == AuditEvent.CONFIRMATION_SKIPPED.id] + blocked = [r for r in records if r["id"] == AuditEvent.WRITE_BLOCKED_READ_ONLY.id] + + assert blocked, "the refusal itself must still be recorded" + assert blocked[0]["outcome"] == "blocked" + assert skipped == [], ( + "57491 claims the tool executed without confirmation, but it was blocked" + ) + + +@pytest.mark.asyncio +async def test_sqlpp_write_statement_uses_the_query_write_id(tmp_path): + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def run_sql_plus_plus_query(ctx: Context, bucket_name: str, query: str) -> str: + """an allowed DML statement""" + audit_state.record_statement_class("write") + return "ok" + + async with Client(mcp) as client: + await client.call_tool( + "run_sql_plus_plus_query", {"bucket_name": "b", "query": "UPDATE ..."} + ) + + calls = [ + r for r in _records(audit) if r.get("tool_name") == "run_sql_plus_plus_query" + ] + # 61523, not the 61491 read id: a successful mutation must not be filed as + # a read, because read ids are filterable and could be suppressed. + assert [r["id"] for r in calls] == [61523] + assert calls[0]["required_scope"] == "write" + + +@pytest.mark.asyncio +async def test_disabled_event_is_not_written(tmp_path): + audit = _build_logger(tmp_path, disabled_events="61490") # document read + mcp = _server(audit) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """filtered out""" + return "doc" + + async with Client(mcp) as client: + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + + assert not [r for r in _records(audit) if r["id"] == 61490] + + +@pytest.mark.asyncio +async def test_inactive_logger_writes_nothing_and_does_not_break_calls(tmp_path): + """With auditing off the middleware must be transparent.""" + config = resolve_audit_config( + enabled=False, + file=None, + rotation_max_size_mb=None, + max_backups=None, + tool_args=None, + disabled_events=None, + ) + audit = AuditLogger(config, sink=None) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """still works""" + return "doc" + + async with Client(mcp) as client: + result = await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + + assert result is not None + assert not audit.active + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.asyncio +async def test_withheld_or_unknown_tool_produces_no_record(tmp_path): + """A tool that was never registered must not be audited. + + Read-only mode and the disabled set both work by withholding the tool from + registration, so a client is never told it exists and there is no refusal to + record — which is why event 57489 does not exist. Emitting + a tool-call record here would also assert an ``execution_error`` that never + happened, because nothing ran. The enforced tool surface is captured once by + the server-configuration record at startup instead. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """the only registered tool""" + return "doc" + + async with Client(mcp) as client: + with pytest.raises(Exception, match="Unknown tool"): + # Withheld in read-only mode, so absent from the registry. + await client.call_tool("upsert_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + assert not [r for r in records if r.get("tool_name") == "upsert_document_by_id"] + assert not [r for r in records if r["id"] == 61522] + # The session record still lands, so the sink itself was working. + assert [r for r in records if r["id"] == AuditEvent.SESSION_INITIALIZED.id] + + +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# correlation: one cid per request, and no session identifier at all +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_each_request_gets_its_own_cid(tmp_path): + """A ``cid`` identifies one request, so two calls must never share one.""" + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id( + ctx: Context, bucket_name: str, scope_name: str, collection_name: str + ) -> str: + """read a document""" + return "doc" + + async with Client(mcp) as client: + for _ in range(2): + await client.call_tool( + "get_document_by_id", + {"bucket_name": "b", "scope_name": "s", "collection_name": "c"}, + ) + + records = _records(audit) + session = next(r for r in records if r["id"] == AuditEvent.SESSION_INITIALIZED.id) + calls = [r for r in records if r["id"] == 61490] + assert len(calls) == 2 + + cids = [session["cid"], calls[0]["cid"], calls[1]["cid"]] + assert all(cids), "every record must carry a cid" + assert len(set(cids)) == 3, "three requests, three distinct cids" + + +@pytest.mark.asyncio +async def test_no_record_carries_a_session_identifier(tmp_path): + """The audit log deliberately claims no session scope. + + MCP is moving to a stateless model in which a server must not treat + connection or process identity as session continuity, so an id derived from + it would group unrelated interleaved conversations. Asserted across a whole + run rather than one record, so no emission path can reintroduce one. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def upsert_document_by_id(ctx: Context, bucket_name: str) -> str: + """write a document""" + return "ok" + + async with Client(mcp) as client: + await client.call_tool("upsert_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + assert records + for record in records: + assert "sid" not in record + assert not [key for key in record if "session" in key.lower()] + + +@pytest.mark.asyncio +async def test_session_initialized_is_a_standalone_record(tmp_path): + """It still says what connected; it just does not join to anything. + + The value of 57360 is the statement that a client of this name and version + connected at this time under this identity — not a correlation key. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """read a document""" + return "doc" + + async with Client(mcp) as client: + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + session = next(r for r in records if r["id"] == AuditEvent.SESSION_INITIALIZED.id) + assert session["cid"] + assert session["client_name"] + assert session["protocol_version"] + # Nothing else shares its cid. + assert [r for r in records if r["cid"] == session["cid"]] == [session] + + +@pytest.mark.asyncio +async def test_guardrail_and_tool_call_records_share_one_cid(tmp_path): + """Correlation must survive the path that travels through the state dict. + + A refusal is recorded by the gate and emitted by the middleware, so this is + the case where a broken hand-off would show up as a mismatched id rather + than as a missing record. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def delete_document_by_id(ctx: Context, bucket_name: str) -> str: + """runs without confirmation because the client cannot elicit""" + audit_state.record_confirmation(audit_state.CONFIRMATION_SKIPPED) + return "deleted" + + async with Client(mcp) as client: + await client.call_tool("delete_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + skipped = [r for r in records if r["id"] == AuditEvent.CONFIRMATION_SKIPPED.id] + calls = [r for r in records if r["id"] == 61522] + assert len(skipped) == 1 + assert len(calls) == 1 + assert skipped[0]["cid"] == calls[0]["cid"] + + +@pytest.mark.asyncio +async def test_scope_denial_record_carries_a_cid(tmp_path): + """A refused call produces one record, and it is still correlatable.""" + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def upsert_document_by_id(ctx: Context, bucket_name: str) -> str: + """refused before doing anything""" + audit_state.record_refusal( + event_id=ScopeDeniedError.audit_event.id, + event_name=ScopeDeniedError.audit_event.event_name, + outcome=ScopeDeniedError.audit_outcome, + reason=ScopeDeniedError.audit_reason, + required_scope="couchbase-mcp:write", + ) + raise ScopeDeniedError("missing scope") + + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("upsert_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + denied = [r for r in records if r["id"] == AuditEvent.SCOPE_CHECK_DENIED.id] + assert len(denied) == 1 + assert denied[0]["cid"] + + +@pytest.mark.asyncio +async def test_tool_code_can_read_the_requests_cid(tmp_path): + """The hook a later phase needs to reach Couchbase Server audit. + + Propagating the ``cid`` to a SQL++ query as ``client_context_id`` requires + tool code — running on a worker thread, for a sync tool — to see the same + ``cid`` the middleware will stamp on the record. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + seen: dict[str, str | None] = {} + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """read a document""" + seen["cid"] = audit_state.get_cid() + return "doc" + + async with Client(mcp) as client: + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + + records = _records(audit) + call = next(r for r in records if r["id"] == 61490) + assert seen["cid"] == call["cid"] + + +@pytest.mark.asyncio +async def test_correlation_survives_without_on_message(tmp_path): + """The fallback: a hook invoked with no request state still correlates. + + ``on_message`` normally installs the state. A host that dispatches straight + to ``on_call_tool`` must still produce joinable records rather than records + with no ``cid`` at all. + """ + audit = _build_logger(tmp_path) + middleware = AuditMiddleware( + transport="stdio", + cb_userid="mcp_service", + audit_logger=audit, + service_package="operational", + ) + + @dataclass + class _Message: + name: str = "get_document_by_id" + arguments: dict[str, str] = field(default_factory=lambda: {"bucket_name": "b"}) + + @dataclass + class _Context: + message: _Message = field(default_factory=_Message) + fastmcp_context: None = None + + async def _call_next(_context): + return "doc" + + assert audit_state.current() is None + await middleware.on_call_tool(_Context(), _call_next) + # State is torn down again, so nothing leaks into the next request. + assert audit_state.current() is None + + records = _records(audit) + call = next(r for r in records if r["id"] == 61490) + assert call["cid"] + + +@pytest.mark.asyncio +async def test_cid_is_per_request_over_a_real_http_transport(tmp_path): + """stdio and in-memory share a code path; HTTP does not. + + Streamable HTTP assigns its own transport-level ``Mcp-Session-Id``, which + must never leak into a record — the audit format has no session field. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit, transport="streamable-http") + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """read a document""" + return "doc" + + # Port 0 so a busy port cannot make this test flaky; the kernel picks one + # and we read it back off the bound socket. + config = uvicorn.Config( + mcp.http_app(transport="http"), host="127.0.0.1", port=0, log_level="error" + ) + server = uvicorn.Server(config) + serving = asyncio.create_task(server.serve()) + try: + while not server.started: + await asyncio.sleep(0.05) + port = server.servers[0].sockets[0].getsockname()[1] + transport = StreamableHttpTransport(url=f"http://127.0.0.1:{port}/mcp/") + async with Client(transport) as client: + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + await client.call_tool("get_document_by_id", {"bucket_name": "b2"}) + finally: + server.should_exit = True + await serving + + records = _records(audit) + calls = [r for r in records if r["id"] == 61490] + assert len(calls) == 2 + assert calls[0]["cid"] != calls[1]["cid"] + for record in records: + assert "sid" not in record + + +# --------------------------------------------------------------------------- +# identity on the records that carry it +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_every_record_carries_the_resolved_caller_identity(tmp_path): + """``real_userid`` is the field the whole subsystem exists for. + + The cluster sees one service account for every caller, so this is the only + place the actual caller is recorded. It was asserted on ``session + initialized`` and ``token rejected`` and nowhere else — replacing the + resolved identity with a hardcoded fake passed the entire suite, on every + tool-call and refusal record. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """a plain read""" + return "doc" + + @mcp.tool + def run_sql_plus_plus_query(ctx: Context, bucket_name: str, query: str) -> str: + """refused by the read-only gate""" + audit_state.record_statement_class("write") + audit_state.record_refusal( + event_id=ReadOnlyWriteBlockedError.audit_event.id, + event_name=ReadOnlyWriteBlockedError.audit_event.event_name, + outcome=ReadOnlyWriteBlockedError.audit_outcome, + reason=ReadOnlyWriteBlockedError.audit_reason, + ) + raise ReadOnlyWriteBlockedError("not allowed in read-only mode") + + async with Client(mcp) as client: + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + with contextlib.suppress(ToolError): + await client.call_tool( + "run_sql_plus_plus_query", + {"bucket_name": "b", "query": "UPDATE airline SET x = 1"}, + ) + + records = _records(audit) + # stdio has no authenticated caller, so the honest answer is the OS process + # owner in the ``local`` domain — resolved, not assumed. + expected = resolve_real_userid("stdio") + assert expected["domain"] == "local" + + carriers = [r for r in records if r["id"] in (61490, 57488)] + assert len(carriers) == 2, [r["name"] for r in records] + for record in carriers: + assert record["real_userid"] == expected, record + + +@pytest.mark.asyncio +async def test_an_oauth_caller_is_named_on_the_tool_call_record(tmp_path): + """The HTTP case: the record must name the token's subject, not the server.""" + audit = _build_logger(tmp_path) + mcp = FastMCP("audit-test") + mcp.add_middleware( + AuditMiddleware( + transport="http", + cb_userid="mcp_service", + audit_logger=audit, + service_package="operational", + ) + ) + + @mcp.tool + def get_document_by_id(ctx: Context, bucket_name: str) -> str: + """a plain read""" + return "doc" + + token = SimpleNamespace( + claims={"sub": "agent-svc-7"}, client_id="agent-svc-7", scopes=["read"] + ) + with patch("cb_mcp.audit.identity.get_access_token", return_value=token): + async with Client(mcp) as client: + await client.call_tool("get_document_by_id", {"bucket_name": "b"}) + + calls = [r for r in _records(audit) if r["id"] == 61490] + assert calls, "no tool-call record" + assert calls[0]["real_userid"] == {"domain": "oauth", "user": "agent-svc-7"} + # The cluster-side account is recorded separately and must not be mistaken + # for the caller. + assert calls[0]["cb_userid"] == "mcp_service" + + +@pytest.mark.asyncio +async def test_scope_denial_record_carries_client_id_scopes_and_required_scope( + tmp_path, +): + """The three fields a SIEM rule reads off event 57377. + + The sibling test above runs under stdio, where both token helpers return + None and ``as_dict`` drops the fields, and it passes its own + ``required_scope``, which suppresses the ``setdefault`` under test. All + three ``setdefault`` lines in the middleware could be deleted and it would + still pass. This one patches the token helpers and passes no + ``required_scope``, so each field has to be supplied by the middleware. + """ + audit = _build_logger(tmp_path) + mcp = _server(audit) + + @mcp.tool + def upsert_document_by_id(ctx: Context, bucket_name: str) -> str: + """denied before it runs""" + audit_state.record_refusal( + event_id=ScopeDeniedError.audit_event.id, + event_name=ScopeDeniedError.audit_event.event_name, + outcome=ScopeDeniedError.audit_outcome, + reason=ScopeDeniedError.audit_reason, + ) + raise ScopeDeniedError("missing scope") + + with ( + patch( + "cb_mcp.audit.middleware.client_id_of_current_token", + return_value="agent-svc-7", + ), + patch( + "cb_mcp.audit.middleware.scopes_of_current_token", + return_value=["couchbase-mcp:read"], + ), + ): + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("upsert_document_by_id", {"bucket_name": "b"}) + + denials = [ + r for r in _records(audit) if r["id"] == AuditEvent.SCOPE_CHECK_DENIED.id + ] + assert len(denials) == 1 + record = denials[0] + assert record["client_id"] == "agent-svc-7" + assert record["scopes"] == ["couchbase-mcp:read"] + # Derived from the tool's own class, not passed in: upsert is a write. + assert record["required_scope"] == "write" diff --git a/tests/unit/test_audit_record.py b/tests/unit/test_audit_record.py new file mode 100644 index 00000000..40fed8be --- /dev/null +++ b/tests/unit/test_audit_record.py @@ -0,0 +1,294 @@ +"""Tests for the audit record and its serialisation. + +Coverage map: +- timestamp format is the PRD's millisecond UTC form +- fixed key order in the wire document +- absent fields are omitted rather than serialised as null +- reason appears only on non-success outcomes +- payload keys with a None value are dropped +- non-serialisable argument values never lose the whole record +- one JSON object per line, newline terminated +- the PRD's own sample records round-trip +""" + +from __future__ import annotations + +import json +import re +from datetime import datetime + +import pytest + +from cb_mcp.audit.record import ( + OUTCOME_DENIED, + OUTCOME_SUCCESS, + AuditRecord, + ServerContext, + utc_timestamp, +) + +SERVER = ServerContext(version="1.1.0", host="mcp-node-3") + + +def test_timestamp_is_millisecond_utc_with_a_literal_z(): + stamp = utc_timestamp() + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", stamp), stamp + # Parseable back to a datetime, so downstream tooling can rely on it. + datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%S.%fZ") + + +def test_key_order_is_stable_and_readable(): + record = AuditRecord( + id=61490, + name="document read", + description="A document read operation was requested through a tool", + outcome=OUTCOME_SUCCESS, + real_userid={"domain": "local", "user": "cbmcp"}, + cid="req-1", + payload={"tool_name": "get_document_by_id", "ks": "b.s.c"}, + ) + keys = list(record.as_dict(SERVER)) + assert keys == [ + "id", + "name", + "description", + "timestamp", + "real_userid", + "server", + "cid", + "outcome", + "tool_name", + "ks", + ] + + +def test_absent_fields_are_omitted_not_null(): + record = AuditRecord( + id=57344, name="server started", description="d", outcome=OUTCOME_SUCCESS + ) + document = record.as_dict(SERVER) + assert "real_userid" not in document + assert "cid" not in document + assert "reason" not in document + assert document["server"] == {"version": "1.1.0", "host": "mcp-node-3"} + + +def test_no_session_identifier_is_ever_written(): + """The audit format carries no session id, by design. + + MCP is moving to a stateless model in which connection identity must not be + read as session continuity, so a request is the only correlation scope this + format claims. Pinned as a test so a future field cannot be added without + a deliberate, visible change here. + """ + record = AuditRecord( + id=57360, + name="session initialized", + description="An MCP session was initialized", + outcome="success", + real_userid={"domain": "local", "user": "cbmcp"}, + cid="req-9", + ) + document = record.as_dict(SERVER) + assert document["cid"] == "req-9" + assert "sid" not in document + assert not [key for key in document if "session" in key.lower()] + + +def test_reason_is_present_only_on_failure(): + denied = AuditRecord( + id=57377, + name="scope check denied", + description="d", + outcome=OUTCOME_DENIED, + reason="missing_scope", + ) + assert denied.as_dict(SERVER)["reason"] == "missing_scope" + + ok = AuditRecord( + id=61490, name="document read", description="d", outcome=OUTCOME_SUCCESS + ) + assert "reason" not in ok.as_dict(SERVER) + + +def test_none_payload_values_are_dropped(): + record = AuditRecord( + id=61488, + name="cluster read", + description="d", + outcome=OUTCOME_SUCCESS, + payload={ + "tool_name": "test_cluster_connection", + "ks": None, + "cb_userid": None, + "confirmation": None, + }, + ) + document = record.as_dict(SERVER) + assert document["tool_name"] == "test_cluster_connection" + # A cluster-level tool addresses no keyspace; the key simply does not appear. + assert "ks" not in document + assert "cb_userid" not in document + assert "confirmation" not in document + + +def test_to_json_line_is_one_object_terminated_by_a_newline(): + record = AuditRecord(id=1, name="n", description="d", outcome=OUTCOME_SUCCESS) + line = record.to_json_line(SERVER) + assert line.endswith("\n") + assert "\n" not in line[:-1], "a record must never span lines" + assert json.loads(line)["id"] == 1 + + +def test_unserialisable_values_do_not_lose_the_record(): + """Caller-supplied arguments can contain anything. + + A record that failed to serialise would be a silently missing audit entry, + which is strictly worse than a stringified value. + """ + + class Opaque: + def __repr__(self) -> str: + return "" + + record = AuditRecord( + id=61522, + name="document write", + description="d", + outcome=OUTCOME_SUCCESS, + payload={"args": {"document_content": Opaque()}}, + ) + document = json.loads(record.to_json_line(SERVER)) + assert document["args"]["document_content"] == "" + + +def test_non_ascii_values_are_preserved_not_escaped(): + record = AuditRecord( + id=61490, + name="document read", + description="d", + outcome=OUTCOME_SUCCESS, + payload={"ks": "bücher.inventar.artikel"}, + ) + line = record.to_json_line(SERVER) + assert "bücher" in line + assert json.loads(line)["ks"] == "bücher.inventar.artikel" + + +def test_server_context_detect_populates_both_fields(): + detected = ServerContext.detect() + assert detected.version + assert detected.host + + +@pytest.mark.parametrize( + ("record", "expected_subset"), + [ + ( + # The PRD's "successful document read" sample. + AuditRecord( + id=61490, + name="document read", + description="A document was read by ID", + outcome="success", + real_userid={"domain": "local", "user": "cbmcp"}, + cid="req-5f2a", + payload={ + "tool_name": "get_document_by_id", + "required_scope": "read", + "ks": "travel-sample.inventory.airline", + }, + ), + { + "id": 61490, + "outcome": "success", + "tool_name": "get_document_by_id", + "required_scope": "read", + "ks": "travel-sample.inventory.airline", + }, + ), + ( + # The PRD's "denied write" sample. + AuditRecord( + id=57377, + name="scope check denied", + description="A write tool was requested without write scope", + outcome="denied", + real_userid={"domain": "oauth", "user": "agent-svc-7"}, + cid="req-7c19", + reason="missing_scope", + payload={ + "client_id": "agent-svc-7", + "scopes": ["couchbase-mcp:read"], + "required_scope": "write", + }, + ), + { + "id": 57377, + "outcome": "denied", + "reason": "missing_scope", + "client_id": "agent-svc-7", + "scopes": ["couchbase-mcp:read"], + }, + ), + ], +) +def test_prd_sample_records_serialise(record, expected_subset): + document = json.loads(record.to_json_line(SERVER)) + for key, value in expected_subset.items(): + assert document[key] == value + + +# --------------------------------------------------------------------------- +# one record is one line, whatever a caller puts in it +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("char", "name"), + [ + # The three that ``json.dumps`` leaves raw. Everything else + # ``str.splitlines`` splits on is below 0x20, which it already escapes. + ("\u2028", "LINE SEPARATOR"), + ("\u2029", "PARAGRAPH SEPARATOR"), + ("\x85", "NEXT LINE"), + ], +) +def test_a_caller_cannot_split_a_record_into_two_lines(char, name): + """JSON Lines means one record per line, and a caller must not break it. + + ``ensure_ascii=False`` leaves these as raw bytes, and Python's + ``str.splitlines`` — used by this repo's own readers and by many log + shippers — treats every one of them as a line terminator. A bucket or + document name carrying one would split its own record into unparseable + fragments, destroying the entry that recorded the call. Reachable without + any cluster access: the record is written even when the tool fails. + """ + record = AuditRecord( + id=61490, + name="document read", + description="d", + outcome=OUTCOME_SUCCESS, + payload={"ks": f"travel{char}sample.inventory.airline"}, + ) + line = record.to_json_line(ServerContext.detect()) + + assert line.endswith("\n") + assert len(line.splitlines()) == 1, f"{name} split the record" + # Escaped, not stripped: the recorded value still says what the caller sent. + assert json.loads(line)["ks"] == f"travel{char}sample.inventory.airline" + + +def test_escaping_leaves_ordinary_non_ascii_readable(): + """The escape must not undo ``ensure_ascii=False`` for normal text.""" + record = AuditRecord( + id=61490, + name="document read", + description="d", + outcome=OUTCOME_SUCCESS, + payload={"ks": "航空会社.inventory.航空"}, + ) + line = record.to_json_line(ServerContext.detect()) + + assert "航空会社" in line, "non-Latin names must stay readable in the file" + assert json.loads(line)["ks"] == "航空会社.inventory.航空" diff --git a/tests/unit/test_audit_sink.py b/tests/unit/test_audit_sink.py new file mode 100644 index 00000000..c5abe26a --- /dev/null +++ b/tests/unit/test_audit_sink.py @@ -0,0 +1,1136 @@ +"""Tests for the audit sinks. + +The file sink deliberately does not use ``logging.handlers.RotatingFileHandler``. +A single stdio deployment runs one server process per MCP client, and they all +read the same ``CB_MCP_AUDIT_LOG_FILE_PATH``; sharing one rotating file between +them produces interleaved partial lines and rotation races. Each process +therefore writes its own file, with the host and pid inserted before the +extension. Owning the rotation is also what lets the size and interval triggers +both be live at once, which the stdlib handlers cannot do. + +Coverage map: +- per-process filename derivation, with and without a suffix +- lines are written and flushed by the background thread +- rotation on size, on interval, on both, and on neither +- the interval clock is anchored to the live file, not to startup +- an idle server still rotates on the interval +- backups are timestamped, gzipped and pruned to max_backups +- a compression failure keeps the uncompressed backup +- max_backups=0 truncates rather than growing without limit +- an oversized single line is written rather than lost +- constructor validation: zero is accepted, negatives are not +- drop counter when the queue is full +- close() is idempotent and drains +- two concurrent sinks never share a file +- the console sink writes to stderr, and the composite fans out +""" + +from __future__ import annotations + +import functools +import gzip +import io +import json +import os +import re +import sys +import threading +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + +from cb_mcp.audit.sink import ( + _SENTINEL, + AuditSink, + CompositeAuditSink, + ConsoleAuditSink, + process_scoped_path, +) + + +def _drain(sink: AuditSink) -> None: + """Close the sink so the writer thread finishes and flushes.""" + sink.close(timeout=10.0) + + +def _settle(sink: AuditSink, timeout: float = 2.0) -> None: + """Wait for the writer to drain the queue, without closing the sink.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline and not sink._queue.empty(): + time.sleep(0.02) + time.sleep(0.1) + + +# --------------------------------------------------------------------------- +# naming +# --------------------------------------------------------------------------- + + +def test_process_scoped_path_inserts_host_and_pid_before_the_suffix(): + result = process_scoped_path("/var/log/audit.log", pid=4321, host="node-a") + assert result == Path("/var/log/audit.node-a.4321.log") + + +def test_process_scoped_path_appends_when_there_is_no_suffix(): + result = process_scoped_path("/var/log/audit", pid=4321, host="node-a") + assert result == Path("/var/log/audit.node-a.4321") + + +def test_process_scoped_path_uses_the_live_host_and_pid_by_default(): + assert str(os.getpid()) in process_scoped_path("audit.log").name + + +def test_containers_sharing_a_volume_do_not_share_a_file(): + """The reason the host is in the name at all. + + This image's ENTRYPOINT is exec form, so the server is PID 1 in *every* + container. Two containers mounting the same volume would both have written + ``audit.1.log`` — the interleaving and rotation races that per-writer files + exist to prevent, in the configuration DOCKER.md recommends. Docker gives + each container a distinct hostname by default, which restores uniqueness. + """ + first = process_scoped_path("/audit/audit.log", pid=1, host="3f2a91c4b7de") + second = process_scoped_path("/audit/audit.log", pid=1, host="a81ce4470f19") + assert first != second + assert first.name == "audit.3f2a91c4b7de.1.log" + + +def test_host_token_is_filename_safe(): + """An FQDN or an odd hostname must not put dots or separators in the name.""" + assert ( + process_scoped_path("a.log", pid=7, host="node1.example.com").name + == "a.node1.7.log" + ) + assert ( + process_scoped_path("a.log", pid=7, host="we ird/name").name + == "a.we-ird-name.7.log" + ) + assert process_scoped_path("a.log", pid=7, host="").name == "a.unknown.7.log" + + +def test_sink_writes_to_the_process_scoped_path(tmp_path): + sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, max_backups=1) + try: + assert sink.path == process_scoped_path(tmp_path / "audit.log") + assert sink.path.exists() + finally: + _drain(sink) + + +# --------------------------------------------------------------------------- +# writing +# --------------------------------------------------------------------------- + + +def test_lines_are_written_and_flushed(tmp_path): + sink = AuditSink(tmp_path / "audit.log", max_bytes=1_000_000, max_backups=1) + sink.start() + for index in range(50): + sink.emit(json.dumps({"n": index}) + "\n") + _drain(sink) + + lines = sink.path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 50 + assert [json.loads(line)["n"] for line in lines] == list(range(50)) + assert sink.stats["written"] == 50 + assert sink.stats["dropped"] == 0 + assert sink.stats["write_errors"] == 0 + + +def test_existing_file_is_appended_not_truncated(tmp_path): + path = process_scoped_path(tmp_path / "audit.log") + path.write_text('{"pre":true}\n', encoding="utf-8") + + sink = AuditSink(tmp_path / "audit.log", max_bytes=1_000_000, max_backups=1) + sink.start() + sink.emit('{"post":true}\n') + _drain(sink) + + lines = path.read_text(encoding="utf-8").splitlines() + assert json.loads(lines[0]) == {"pre": True} + assert json.loads(lines[1]) == {"post": True} + + +# --------------------------------------------------------------------------- +# rotation and retention +# --------------------------------------------------------------------------- + + +def _backups(sink: AuditSink) -> list[Path]: + """Rotated files beside the live one, oldest first.""" + return sorted( + path + for path in sink.path.parent.iterdir() + if path.name.startswith(sink.path.name + ".") + ) + + +def _all_records(sink: AuditSink) -> str: + """Everything the sink has written, live file and backups, decompressed.""" + text = sink.path.read_text(encoding="utf-8") + for path in _backups(sink): + if path.suffix == ".gz": + with gzip.open(path, "rt", encoding="utf-8") as handle: + text += handle.read() + else: + text += path.read_text(encoding="utf-8") + return text + + +def test_rotation_names_backups_by_time_and_honours_retention(tmp_path): + line = json.dumps({"payload": "x" * 80}) + "\n" + # Room for roughly two lines before rotating. + sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, max_backups=2) + sink.start() + for _ in range(12): + sink.emit(line) + _drain(sink) + + backups = _backups(sink) + assert sink.path.exists() + # Retention is two backups, so a third must never survive. + assert len(backups) == 2, [p.name for p in backups] + for path in backups: + # .[-N].gz — readable without opening the file, + # and sortable because the timestamp is fixed-width. + assert re.fullmatch( + re.escape(sink.path.name) + r"\.\d{8}T\d{12}Z(-\d+)?\.gz", path.name + ), path.name + assert sink.path.stat().st_size <= len(line) * 2 + + +def test_rotated_backups_are_gzipped_and_still_readable(tmp_path): + line = json.dumps({"payload": "compress me"}) + "\n" + sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, max_backups=5) + sink.start() + for _ in range(6): + sink.emit(line) + _drain(sink) + + backups = _backups(sink) + assert backups, "nothing rotated" + for path in backups: + assert path.suffix == ".gz" + # Real gzip, not a renamed plain file: decompression must round-trip. + with gzip.open(path, "rt", encoding="utf-8") as handle: + assert "compress me" in handle.read() + # The live file stays plain text. tail -f and grep on the current file are + # how an incident is actually investigated. + assert "compress me" in sink.path.read_text(encoding="utf-8") + + +def test_compression_failure_keeps_the_uncompressed_backup(tmp_path): + """Losing an audit file to save space would invert the whole point.""" + line = json.dumps({"payload": "k" * 60}) + "\n" + sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, max_backups=3) + sink.start() + with patch( + "cb_mcp.audit.sink.gzip.open", side_effect=OSError("no space for the gzip") + ): + for _ in range(4): + sink.emit(line) + _settle(sink) + _drain(sink) + + backups = _backups(sink) + assert backups, "the rotation did not keep the retired file" + assert all(path.suffix != ".gz" for path in backups) + assert "k" * 60 in _all_records(sink) + + +def test_max_backups_zero_truncates_the_live_file(tmp_path): + line = json.dumps({"payload": "y" * 80}) + "\n" + sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, max_backups=0) + sink.start() + for _ in range(20): + sink.emit(line) + _drain(sink) + + # Retention of zero still means the rotation size caps the live file. + assert sink.path.stat().st_size <= len(line) * 2 + assert _backups(sink) == [] + + +def test_a_single_oversized_line_is_written_not_dropped(tmp_path): + sink = AuditSink(tmp_path / "audit.log", max_bytes=64, max_backups=1) + sink.start() + huge = json.dumps({"payload": "z" * 500}) + "\n" + sink.emit(huge) + _drain(sink) + + # Losing the record would be worse than briefly exceeding the size cap. + assert "z" * 500 in _all_records(sink) + assert sink.stats["dropped"] == 0 + + +# --------------------------------------------------------------------------- +# rotation triggers: size, interval, both, neither +# --------------------------------------------------------------------------- + + +def test_zero_max_bytes_never_rotates_on_size(tmp_path): + """PRD case 2: 'I don't care about the size, store them all.'""" + line = json.dumps({"payload": "q" * 200}) + "\n" + sink = AuditSink(tmp_path / "audit.log", max_bytes=0, max_backups=5) + sink.start() + for _ in range(50): + sink.emit(line) + _drain(sink) + + assert _backups(sink) == [], "size rotation fired with the size trigger off" + assert len(sink.path.read_text(encoding="utf-8").splitlines()) == 50 + + +def test_interval_rotation_fires_when_the_file_is_old_enough(tmp_path): + sink = AuditSink( + tmp_path / "audit.log", max_bytes=0, max_backups=3, interval_seconds=86_400 + ) + sink.start() + sink.emit('{"before":true}\n') + _settle(sink) + assert _backups(sink) == [], "rotated before the interval fell due" + + # Bring the deadline into the past rather than waiting a day for it. + sink._rotate_at = time.time() - 1 + sink.emit('{"after":true}\n') + _settle(sink) + _drain(sink) + + backups = _backups(sink) + assert len(backups) == 1, [p.name for p in backups] + with gzip.open(backups[0], "rt", encoding="utf-8") as handle: + assert "before" in handle.read() + assert "after" in sink.path.read_text(encoding="utf-8") + + +def test_an_idle_server_still_rotates_on_the_interval(tmp_path): + """The reason the writer checks the clock on an empty queue. + + A quiet deployment writes nothing for hours. Rotating only when the next + record arrives would put a day's worth of records in a file stamped for the + previous period, which is precisely what a daily-rotation operator is + trying to avoid. + """ + sink = AuditSink( + tmp_path / "audit.log", max_bytes=0, max_backups=3, interval_seconds=86_400 + ) + sink.start() + sink.emit('{"only":true}\n') + _settle(sink) + + sink._rotate_at = time.time() - 1 + # No further records: the rotation must come from the idle poll alone. + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline and not _backups(sink): + time.sleep(0.05) + _drain(sink) + + assert len(_backups(sink)) == 1, "an idle sink never rotated" + assert sink.path.stat().st_size == 0 + + +def test_both_triggers_are_live_at_once(tmp_path): + """Whichever falls due first rotates: stdlib cannot do this, so we do.""" + line = json.dumps({"payload": "b" * 80}) + "\n" + sink = AuditSink( + tmp_path / "audit.log", + max_bytes=len(line) * 2, + max_backups=10, + interval_seconds=86_400, + ) + sink.start() + # Size alone, with the interval deadline far in the future. + for _ in range(6): + sink.emit(line) + _settle(sink) + after_size = len(_backups(sink)) + assert after_size >= 2, "the size trigger did not fire while an interval was set" + + # Now the interval, with the file nowhere near the size cap. + sink._rotate_at = time.time() - 1 + sink.emit(line) + _settle(sink) + _drain(sink) + assert len(_backups(sink)) > after_size, "the interval trigger never fired" + + +def test_neither_trigger_means_one_unbounded_file(tmp_path): + sink = AuditSink( + tmp_path / "audit.log", max_bytes=0, max_backups=0, interval_seconds=0 + ) + sink.start() + sink._rotate_at = time.time() - 1 # would rotate if the interval were live + for index in range(100): + sink.emit(json.dumps({"n": index}) + "\n") + _drain(sink) + + assert _backups(sink) == [] + assert len(sink.path.read_text(encoding="utf-8").splitlines()) == 100 + + +def test_the_interval_clock_is_anchored_to_the_live_file_not_to_startup(tmp_path): + """A restart must not hand a stale file a fresh interval it has not earned. + + An operator asking for daily files, whose server was down for a week, wants + the week-old file rolled away — not another day of records appended to it. + """ + path = process_scoped_path(tmp_path / "audit.log") + path.write_text('{"old":true}\n', encoding="utf-8") + week_ago = time.time() - 7 * 86_400 + os.utime(path, (week_ago, week_ago)) + + sink = AuditSink( + tmp_path / "audit.log", max_bytes=0, max_backups=2, interval_seconds=86_400 + ) + try: + assert sink._rotate_at < time.time(), ( + "a file older than the interval was given a fresh deadline" + ) + finally: + _drain(sink) + + # ... while a file that did not exist a moment ago gets its full interval. + fresh = AuditSink( + tmp_path / "fresh.log", max_bytes=0, max_backups=2, interval_seconds=86_400 + ) + try: + assert fresh._rotate_at > time.time() + 86_000 + finally: + _drain(fresh) + + +# --------------------------------------------------------------------------- +# validation and failure handling +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"max_bytes": -1, "max_backups": 1}, "max_bytes must not be negative"), + ({"max_bytes": 10, "max_backups": -1}, "max_backups must not be negative"), + ( + {"max_bytes": 10, "max_backups": 1, "interval_seconds": -1}, + "interval_seconds must not be negative", + ), + ], +) +def test_constructor_rejects_negatives_but_not_zero(tmp_path, kwargs, match): + """Zero is an instruction — 'this trigger is off' — and a typo is not.""" + with pytest.raises(ValueError, match=match): + AuditSink(tmp_path / "audit.log", **kwargs) + + # The zero the PRD requires to be accepted, in the same test so a future + # tightening of the validation cannot pass by rejecting both. + _drain( + AuditSink( + tmp_path / "audit.log", max_bytes=0, max_backups=0, interval_seconds=0 + ) + ) + + +def test_parent_directories_are_created(tmp_path): + nested = tmp_path / "deep" / "deeper" / "audit.log" + sink = AuditSink(nested, max_bytes=4096, max_backups=1) + try: + assert sink.path.parent.is_dir() + finally: + _drain(sink) + + +def test_unwritable_path_raises_so_startup_can_report_it(tmp_path): + # A directory where the file should be is the portable way to make open() + # fail without depending on running as an unprivileged user. + collision = process_scoped_path(tmp_path / "audit.log") + collision.mkdir() + with pytest.raises(OSError): + AuditSink(tmp_path / "audit.log", max_bytes=4096, max_backups=1) + + +def test_full_queue_drops_and_counts_rather_than_blocking(tmp_path): + # No writer thread started, so nothing drains the queue. + sink = AuditSink( + tmp_path / "audit.log", max_bytes=1_000_000, max_backups=1, queue_size=4 + ) + for _ in range(10): + sink.emit('{"x":1}\n') + assert sink.stats["dropped"] == 6 + _drain(sink) + + +def test_emit_after_close_is_counted_not_raised(tmp_path): + sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, max_backups=1) + sink.start() + _drain(sink) + sink.emit('{"late":true}\n') + assert sink.stats["dropped"] == 1 + + +def test_close_is_idempotent(tmp_path): + sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, max_backups=1) + sink.start() + sink.close() + sink.close() # must not raise + + +def test_start_is_idempotent(tmp_path): + sink = AuditSink(tmp_path / "audit.log", max_bytes=4096, max_backups=1) + sink.start() + sink.start() + try: + threads = [t for t in threading.enumerate() if t.name == "cb-mcp-audit-writer"] + assert len(threads) == 1 + finally: + _drain(sink) + + +def test_emit_from_many_threads_loses_nothing(tmp_path): + """The producer side must be safe to call from any thread.""" + sink = AuditSink(tmp_path / "audit.log", max_bytes=10_000_000, max_backups=1) + sink.start() + + def worker(worker_id: int) -> None: + for index in range(100): + sink.emit(json.dumps({"w": worker_id, "i": index}) + "\n") + + threads = [threading.Thread(target=worker, args=(w,)) for w in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + _drain(sink) + + lines = sink.path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 800 + # Every line must be complete JSON: no interleaved partial writes. + parsed = [json.loads(line) for line in lines] + assert len({(r["w"], r["i"]) for r in parsed}) == 800 + + +def test_sink_recovers_after_a_failed_rotation(tmp_path): + """A failed reopen must not stop the sink for good. + + Rotation closes the live file before reopening it. If that reopen fails — + full disk, revoked permission, unmounted volume — the sink was left holding + a closed handle and never wrote again, even after the condition cleared. + Silent and permanent is the worst failure mode an audit log can have. + """ + sink = AuditSink(tmp_path / "a.log", max_bytes=200, max_backups=0) + sink.start() + line = '{"pad":"' + "x" * 80 + '"}\n' + + for _ in range(3): + sink.emit(line) + _settle(sink) + before = sink.stats["written"] + + real_open = open + failed = {"done": False} + # The sink writes a process-scoped path (a..log), so match on the + # resolved filename rather than the configured one. + target = str(sink.path) + + def flaky(*args, **kwargs): + # Fail the first reopen attempted during rotation, then behave. + if not failed["done"] and args and str(args[0]) == target: + mode = args[1] if len(args) > 1 else kwargs.get("mode", "r") + if "w" in mode or "a" in mode: + failed["done"] = True + raise OSError("disk full") + return real_open(*args, **kwargs) + + with patch("builtins.open", side_effect=flaky): + for _ in range(3): + sink.emit(line) + _settle(sink) + + assert failed["done"], "the probe never exercised the failing reopen" + assert sink.stats["write_errors"] >= 1, "the failed reopen was not recorded" + + # The measurement that matters: writes attempted *after* the failure. The + # count is taken here, once the stream is known to be closed, so a record + # that happened to land before the failing rotation cannot mask a sink that + # never recovered. + stalled_at = sink.stats["written"] + for _ in range(3): + sink.emit(line) + _settle(sink) + sink.close() + + assert sink.stats["written"] > stalled_at, ( + "sink never wrote again after the failed rotation: " + f"written stayed at {stalled_at}" + ) + assert before <= stalled_at + + +def test_writer_exits_when_the_queue_is_full_at_close(tmp_path): + """close() cannot post the sentinel into a full queue. + + Dropping a queued record to make room would lose an audit line purely to + shut down faster, so the writer polls the closed flag instead. Before that, + it blocked on an empty get() forever and the thread leaked. + """ + sink = AuditSink(tmp_path / "b.log", max_bytes=10_000, max_backups=1, queue_size=4) + sink.start() + time.sleep(0.05) + + with patch.object(sink, "_write_batch", side_effect=lambda lines: time.sleep(0.5)): + for index in range(20): + sink.emit(f'{{"i":{index}}}\n') + sink.close(timeout=3.0) + + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline: + if not any( + t.name == "cb-mcp-audit-writer" and t.is_alive() + for t in threading.enumerate() + ): + break + time.sleep(0.05) + assert not any( + t.name == "cb-mcp-audit-writer" and t.is_alive() for t in threading.enumerate() + ), "writer thread did not exit" + + +def test_a_draining_writer_does_not_reopen_the_file_after_close(tmp_path): + """``close`` joins with a timeout, then closes the stream. + + A writer still draining past that timeout — the wedged filesystem the + timeout exists for — would otherwise reopen a handle nobody closes again, + and append records dated after "server stopped". It must drop them instead. + """ + sink = AuditSink(tmp_path / "c.log", max_bytes=100_000, max_backups=1) + sink.start() + released = threading.Event() + real_write = sink._write_batch + + def slow(lines): + released.wait(timeout=5.0) + return real_write(lines) + + with patch.object(sink, "_write_batch", side_effect=slow): + sink.emit('{"first":1}\n') + time.sleep(0.2) + sink.emit('{"second":2}\n') + sink.close(timeout=0.2) # returns while the batch is still in flight + released.set() + time.sleep(0.4) + + assert sink._stream.closed, "the writer reopened the stream after close" + + +# --------------------------------------------------------------------------- +# console and composite sinks +# --------------------------------------------------------------------------- + + +class _BrokenWrite(io.StringIO): + """Raises on write, optionally after accepting a few lines first.""" + + def __init__(self, fail_after: int = 0) -> None: + super().__init__() + self._fail_after = fail_after + self._accepted = 0 + + def write(self, text): + if self._accepted >= self._fail_after: + raise OSError("stream is gone") + self._accepted += 1 + return super().write(text) + + +class _BrokenFlush(io.StringIO): + """Buffers writes happily, then fails to flush, like a full disk.""" + + def flush(self): + raise OSError("No space left on device") + + +def test_console_sink_writes_lines_to_its_stream(): + stream = io.StringIO() + sink = ConsoleAuditSink(stream) + sink.start() + sink.emit('{"a":1}\n') + sink.emit('{"b":2}\n') + sink.close(timeout=5.0) + + assert stream.getvalue() == '{"a":1}\n{"b":2}\n' + assert sink.stats == {"written": 2, "dropped": 0, "write_errors": 0} + + +def test_console_sink_defaults_to_stderr_never_stdout(capsys): + """stdout carries the JSON-RPC protocol under the stdio transport. + + One audit line written there corrupts the client's stream and takes the + session down, so the default stream is not merely a preference. + """ + sink = ConsoleAuditSink() + sink.start() + sink.emit('{"audited":true}\n') + sink.close(timeout=5.0) + + captured = capsys.readouterr() + assert captured.err == '{"audited":true}\n' + assert captured.out == "" + + +def test_console_sink_never_blocks_its_caller_on_a_stalled_stream(): + """The blocker this sink's queue exists for. + + ``console`` is the *default* sink, and under stdio its stderr is a pipe + owned by the MCP client. Written inline, a client that stops draining that + pipe blocks the audit write at the 64 KiB buffer — and the caller is the + asyncio event loop, so every in-flight tool call and the JSON-RPC reader + stall with it. Enabling auditing was enough to hang the server. + + The stall is a blocking ``write`` the test controls, rather than a real + pipe: the emitting runs on a worker thread and the stall is released in + teardown, so a regression *fails* here instead of hanging the suite — + which matters when the property under test is "does not block forever". + """ + release = threading.Event() + entered = threading.Event() + + class StalledStream(io.StringIO): + def write(self, text): + entered.set() + release.wait(timeout=30) + return super().write(text) + + sink = ConsoleAuditSink(StalledStream(), queue_size=16) + sink.start() + finished = threading.Event() + + def emit_many() -> None: + for index in range(2000): + sink.emit(json.dumps({"n": index}) + "\n") + finished.set() + + worker = threading.Thread(target=emit_many, daemon=True) + try: + worker.start() + assert entered.wait(timeout=5.0), "the writer never reached the stream" + assert finished.wait(timeout=5.0), ( + "emit() blocked on a stalled stream; the caller is the event loop" + ) + # The records that could not be written are dropped and *counted* — + # that is the whole bargain, and the counter is how an operator finds + # out. Silence would be the unacceptable outcome. + assert sink.stats["dropped"] > 0 + finally: + release.set() + sink.close(timeout=2.0) + + +@pytest.mark.parametrize( + ("stream_factory", "label"), + [ + (_BrokenWrite, "write fails"), + (_BrokenFlush, "flush fails"), + (functools.partial(_BrokenWrite, fail_after=2), "write fails mid-batch"), + ], +) +def test_console_sink_counts_every_lost_record(stream_factory, label): + """A buffered line that is never flushed never reached the console. + + Crediting only the lines not yet handed to ``write`` left the rest + unaccounted — ``written`` at zero and ``dropped`` at zero while records + vanished. ``dropped`` is documented as the only way an operator learns of + a gap, so it has to cover the whole batch. + """ + sink = ConsoleAuditSink(stream_factory()) + sink.start() + for index in range(5): + sink.emit(json.dumps({"n": index}) + "\n") + sink.close(timeout=5.0) + + stats = sink.stats + assert stats["write_errors"] >= 1, label + assert stats["written"] + stats["dropped"] == 5, f"{label}: {stats}" + assert stats["written"] == 0, f"{label}: {stats}" + + +def test_console_sink_emits_whole_lines_under_concurrency(): + stream = io.StringIO() + sink = ConsoleAuditSink(stream) + sink.start() + + def worker(worker_id: int) -> None: + for index in range(50): + sink.emit(json.dumps({"w": worker_id, "i": index}) + "\n") + + threads = [threading.Thread(target=worker, args=(w,)) for w in range(6)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + sink.close(timeout=5.0) + + lines = stream.getvalue().splitlines() + assert len(lines) == 300 + # Every line must be complete JSON: no record split by another thread. + assert ( + len({(json.loads(line)["w"], json.loads(line)["i"]) for line in lines}) == 300 + ) + + +def test_composite_writes_to_every_sink_and_sums_their_stats(tmp_path): + stream = io.StringIO() + console = ConsoleAuditSink(stream) + file_sink = AuditSink(tmp_path / "audit.log", max_bytes=0, max_backups=0) + composite = CompositeAuditSink([console, file_sink]) + composite.start() + composite.emit('{"both":true}\n') + composite.close(timeout=10.0) + + assert stream.getvalue() == '{"both":true}\n' + assert file_sink.path.read_text(encoding="utf-8") == '{"both":true}\n' + assert composite.stats["written"] == 2 + + +def test_one_failing_sink_does_not_stop_the_others(tmp_path): + """A full disk must not also cost the operator their console records.""" + + class Broken(io.StringIO): + def write(self, _text): + raise OSError("stream is gone") + + file_sink = AuditSink(tmp_path / "audit.log", max_bytes=0, max_backups=0) + composite = CompositeAuditSink([ConsoleAuditSink(Broken()), file_sink]) + composite.start() + composite.emit('{"survives":true}\n') + composite.close(timeout=10.0) + + assert file_sink.path.read_text(encoding="utf-8") == '{"survives":true}\n' + assert composite.stats["written"] == 1 + assert composite.stats["dropped"] == 1 + + +# --------------------------------------------------------------------------- +# retention keeps the newest, end to end +# --------------------------------------------------------------------------- + + +def _numbered(sink: AuditSink) -> list[int]: + """Every surviving record's ``n``, across backups and the live file.""" + numbers: list[int] = [] + for path in [*_backups(sink), sink.path]: + if path.suffix == ".gz": + with gzip.open(path, "rt", encoding="utf-8") as handle: + text = handle.read() + else: + text = path.read_text(encoding="utf-8") + numbers += [json.loads(line)["n"] for line in text.splitlines() if line.strip()] + return sorted(numbers) + + +def test_retention_discards_the_oldest_records_not_the_newest(tmp_path): + """Counting the backups is not enough: *which* ones survive is the point. + + Every retention case in the PRD — 100 MB across ten files, 90 days of daily + logs — means a sliding window over the most recent history. An earlier + version of this sink ordered backups by filename, and because ``-`` sorts + before ``.`` the same-second disambiguator inverted the order: it deleted + the newest backups and kept the oldest, while still reporting exactly + ``max_backups`` files. The count was right and the contents were useless. + """ + line = json.dumps({"n": 0}) + "\n" + # Two records a file, many rotations, several of them inside one second. + sink = AuditSink(tmp_path / "audit.log", max_bytes=len(line) * 2, max_backups=9) + sink.start() + for index in range(60): + sink.emit(json.dumps({"n": index}) + "\n") + _drain(sink) + + survivors = _numbered(sink) + assert len(_backups(sink)) == 9 + # The window is the tail of the sequence: contiguous, and ending at the + # last record written. + assert survivors[-1] == 59 + assert survivors == list(range(survivors[0], 60)), survivors + # Ten files of two records each; nothing older may have been kept. + assert survivors[0] >= 40, f"stale records survived: {survivors}" + + +def test_backups_are_ordered_chronologically_not_alphabetically(tmp_path): + """The ordering itself, isolated from the rotation that produces it.""" + sink = AuditSink(tmp_path / "audit.log", max_bytes=0, max_backups=5) + try: + # Written out of order, and spanning the disambiguators that broke a + # naive name sort: '-' < '.', and '-10' < '-2' as strings. + stamps = [ + "20261006T120000000000Z-2", + "20261006T120000000000Z", + "20261006T120000000000Z-10", + "20261006T120000000000Z-1", + "20261006T115959000000Z", + ] + for stamp in stamps: + sink.path.with_name(f"{sink.path.name}.{stamp}.gz").write_bytes(b"") + ordered = [path.name for path in sink._existing_backups()] + finally: + _drain(sink) + + assert ordered == [ + f"{sink.path.name}.20261006T115959000000Z.gz", + f"{sink.path.name}.20261006T120000000000Z.gz", + f"{sink.path.name}.20261006T120000000000Z-1.gz", + f"{sink.path.name}.20261006T120000000000Z-2.gz", + f"{sink.path.name}.20261006T120000000000Z-10.gz", + ], ordered + + +def test_an_idle_period_does_not_cost_a_record_or_a_retention_slot(tmp_path): + """A deadline that passed while the file was empty must move on. + + Left in the past, it makes the first record after a quiet spell arrive + already overdue: it is split into a backup of its own, and with + ``max_backups=0`` the next record truncates it away — written, counted, and + gone. That is the one failure an audit sink may not have. + """ + sink = AuditSink( + tmp_path / "audit.log", max_bytes=0, max_backups=0, interval_seconds=86_400 + ) + sink.start() + # A deadline that fell due while nothing was being written. + sink._rotate_at = time.time() - 1 + time.sleep(0.4) # let the idle poll see it + + for index in range(3): + sink.emit(json.dumps({"n": index}) + "\n") + _drain(sink) + + assert _numbered(sink) == [0, 1, 2] + assert sink.stats["written"] == 3 + assert sink.stats["dropped"] == 0 + + +def test_a_record_queued_as_the_sink_closes_is_still_written(tmp_path): + """``emit`` can land behind the sentinel; the writer must still drain it.""" + sink = AuditSink(tmp_path / "audit.log", max_bytes=0, max_backups=0) + sink.start() + # Post the sentinel first, then a record behind it — the ordering the + # emit/close race produces, without having to win the race. + sink._queue.put_nowait(_SENTINEL) + sink._queue.put_nowait(json.dumps({"n": 7}) + "\n") + sink.close(timeout=10.0) + + assert _numbered(sink) == [7] + + +@pytest.mark.parametrize( + ("name", "max_bytes", "interval", "max_backups", "records", "expected_files"), + [ + # PRD case 4: 10 MB a file, 9 backups -> 100 MB across ten files. + ("case4", 2, 0, 9, 40, 10), + # PRD case 5 / 6 shape: interval only, retention by count. + ("case5", 0, 86_400, 3, 12, 4), + ], +) +def test_prd_retention_cases_end_to_end( + tmp_path, name, max_bytes, interval, max_backups, records, expected_files +): + """The configuration cases as the sink actually behaves, not as arithmetic. + + ``test_audit_config`` checks that each case resolves to the right numbers; + this checks that those numbers produce the right files on disk, with the + most recent records in them. + """ + line = json.dumps({"n": 0}) + "\n" + sink = AuditSink( + tmp_path / f"{name}.log", + max_bytes=len(line) * max_bytes if max_bytes else 0, + max_backups=max_backups, + interval_seconds=interval, + ) + sink.start() + for index in range(records): + sink.emit(json.dumps({"n": index}) + "\n") + if interval: + # Force the interval trigger rather than waiting a day for it. + _settle(sink, timeout=0.5) + sink._rotate_at = time.time() - 1 + _drain(sink) + + assert len(_backups(sink)) + 1 == expected_files + survivors = _numbered(sink) + assert survivors[-1] == records - 1, "the newest record was not retained" + assert survivors == list(range(survivors[0], records)), survivors + + +def test_a_glob_metacharacter_in_the_path_does_not_disable_retention(tmp_path): + """``max_backups`` must bound the directory whatever the path looks like. + + The live filename becomes a glob pattern when backups are listed. Left + unescaped, a path like ``audit[1].log`` reads ``[1]`` as a character class, + matches nothing, and pruning silently stops — the directory grows without + limit while the setting claims to bound it, and the operator has no signal. + """ + line = json.dumps({"n": 0}) + "\n" + sink = AuditSink(tmp_path / "audit[1].log", max_bytes=len(line) * 2, max_backups=2) + sink.start() + for index in range(20): + sink.emit(json.dumps({"n": index}) + "\n") + _drain(sink) + + assert len(sink._existing_backups()) > 0, "the sink cannot see its own backups" + assert len(_backups(sink)) == 2, [p.name for p in _backups(sink)] + + +def test_a_failed_flush_is_counted_as_lost_not_written(tmp_path): + """``dropped`` is documented as the only way an operator learns of a gap. + + ``write`` only fills the stream's buffer; a full disk fails at ``flush``. + Counting a record as written when it was buffered meant the most likely + real failure reported a clean audit trail — ``written`` climbing, ``dropped`` + at zero — for records that never reached the disk. + """ + + class FailingFlush: + """Buffers writes happily, fails to flush, like ENOSPC.""" + + closed = False + + def __init__(self) -> None: + self.written: list[str] = [] + + def write(self, text: str) -> None: + self.written.append(text) + + def flush(self) -> None: + raise OSError("No space left on device") + + def close(self) -> None: + self.closed = True + + sink = AuditSink(tmp_path / "a.log", max_bytes=0, max_backups=0) + sink._stream.close() + sink._stream = FailingFlush() # type: ignore[assignment] + sink.start() + for index in range(5): + sink.emit(json.dumps({"n": index}) + "\n") + _settle(sink) + stats = dict(sink.stats) + sink.close(timeout=5.0) + + assert stats["written"] == 0, f"buffered records reported as written: {stats}" + assert stats["dropped"] == 5, stats + assert stats["write_errors"] >= 1, stats + + +def test_records_abandoned_at_shutdown_are_counted(tmp_path): + """A wedged destination must not make an audit gap invisible. + + ``close`` gives the writer a bounded time to drain, which a wedged disk or + an un-drained console pipe will exceed. Whatever is still queued is lost; + the counters have to say so, because ``dropped`` is the only signal an + operator has. + """ + release = threading.Event() + + class StalledStream(io.StringIO): + def write(self, text): + release.wait(timeout=30) + return super().write(text) + + sink = ConsoleAuditSink(StalledStream(), queue_size=256) + sink.start() + try: + for index in range(200): + sink.emit(json.dumps({"n": index}) + "\n") + sink.close(timeout=0.3) # the writer is stuck in write() + stats = sink.stats + finally: + release.set() + + assert stats["dropped"] > 0, f"abandoned records went uncounted: {stats}" + # The batch the writer is stuck inside is genuinely undecided — it may yet + # land — so the counters must not claim it either way. Everything still in + # the queue, which is the bulk of it, is counted. + assert stats["dropped"] >= 100, stats + assert stats["written"] + stats["dropped"] <= 200, ( + f"counters over-report what was handled: {stats}" + ) + + +def test_records_queued_before_start_are_not_silently_discarded(tmp_path): + """``emit`` before ``start`` must not lose records quietly.""" + stream = io.StringIO() + sink = ConsoleAuditSink(stream) + # Deliberately no start(). + sink.emit('{"early":1}\n') + sink.close(timeout=5.0) + + assert stream.getvalue() == '{"early":1}\n' + assert sink.stats["written"] == 1 + assert sink.stats["dropped"] == 0 + + +class TestPathShapesAndWriterSurvival: + """Failure modes an operator cannot see from the outside. + + Each would otherwise present as a server that refuses to start, records + written beside the mounted directory rather than inside it, a writer thread + that dies for good, or a loss figure reported at twice its real size. + """ + + @pytest.mark.parametrize( + "given", + ["/", "/var/log/cbmcp/", "logs/", "."], + ) + def test_a_directory_shaped_path_resolves_inside_the_directory(self, given): + """A trailing slash must not produce a *sibling* of the directory. + + Without the guard, /var/log/cbmcp/ resolves to + /var/log/cbmcp.. -- next to the volume the operator mounted + and chmod'ed rather than in it, with no warning. "/" raises instead, + and nothing catches it, so a path typo would stop the server starting. + """ + resolved = process_scoped_path(given, pid=7, host="h") + assert resolved.name.startswith("audit.") + assert resolved.suffix == ".log" + assert resolved.parent == Path(given.rstrip("/") or "/") + + def test_a_file_shaped_path_is_left_alone(self): + assert process_scoped_path("/var/log/cbmcp/audit.log", pid=7, host="h") == Path( + "/var/log/cbmcp/audit.h.7.log" + ) + + def test_the_writer_survives_an_unexpected_error(self): + """Every exit from the writer loop is permanent: start() will not restart it. + + sys.stderr is None under pythonw and under hosts that detach stdio, so + the console sink -- the default -- raised AttributeError, which was not + in its except clause. The thread died and all audit delivery stopped + for the process lifetime, silently. + """ + sink = ConsoleAuditSink() + sink.start() + real = sys.stderr + sys.stderr = None + try: + for _ in range(3): + sink.emit('{"id": 1}') + _settle(sink) + assert sink._thread is not None and sink._thread.is_alive() + assert sink.stats["dropped"] == 3 + finally: + sys.stderr = real + sink.close() + + def test_abandoned_records_are_counted_once(self): + """dropped is the only signal that records were lost; 2x is actionable. + + close() must drain the backlog rather than measure it with qsize(). + On a timed-out join the writer is still running, so records left in the + queue would be pulled by it, fail against the closed stream, and be + counted a second time. + """ + sink = ConsoleAuditSink() + for _ in range(5): + sink.emit('{"id": 1}') # queued before start, so never written + sink.close() + assert sink.stats["dropped"] + sink.stats["written"] == 5 diff --git a/tests/unit/test_audit_state.py b/tests/unit/test_audit_state.py new file mode 100644 index 00000000..6fd35cad --- /dev/null +++ b/tests/unit/test_audit_state.py @@ -0,0 +1,186 @@ +"""Tests for the per-request audit state channel. + +The load-bearing test here is +:func:`test_mutation_from_a_worker_thread_is_visible`. FastMCP runs synchronous +tools on a worker thread via ``anyio.to_thread``, which *copies* the context. +Rebinding a contextvar inside the worker would therefore be invisible to the +middleware that installed it. Mutating a shared dict in place is visible, +because both contexts hold a reference to the same object. Every refusal record +depends on that, so it is asserted directly rather than assumed. + +Coverage map: +- accessors are no-ops when no state is installed +- install/reset nesting +- first refusal wins +- confirmation and statement-class recording +- extra payload keys survive +- in-place mutation crosses a thread boundary via a copied context +- the correlation id round-trips, and is readable from a worker thread +""" + +from __future__ import annotations + +import contextvars +from concurrent.futures import ThreadPoolExecutor +from contextvars import copy_context + +from cb_mcp.audit import state + + +def test_accessors_are_safe_with_no_state_installed(): + assert state.current() is None + assert state.get_refusal() is None + assert state.get_confirmation() is None + assert state.get_statement_class() is None + # Recording must not raise when nothing is tracking. + state.record_refusal(event_id=1, event_name="x", outcome="denied", reason="r") + state.record_confirmation(state.CONFIRMATION_SKIPPED) + state.record_statement_class("write") + + +def test_install_and_reset(): + assert state.current() is None + token = state.install() + try: + # Installed state always carries the correlation slot, empty when the + # caller supplied none, so a reader never has to distinguish "no state" + # from "state without a cid". + assert state.current() == {"cid": None} + finally: + state.reset(token) + assert state.current() is None + + +def test_correlation_id_round_trips(): + token = state.install(cid="cid-1") + try: + assert state.get_cid() == "cid-1" + finally: + state.reset(token) + # The accessor is safe with nothing installed. + assert state.get_cid() is None + + +def test_correlation_id_is_readable_from_a_worker_thread(): + """A sync tool runs on a worker thread, and needs the request's cid there. + + This is what a later phase depends on to propagate the ``cid`` to Couchbase + Server as a SQL++ ``client_context_id`` from inside tool code. + """ + token = state.install(cid="cid-worker") + try: + with ThreadPoolExecutor(max_workers=1) as pool: + # copy_context() mirrors what anyio.to_thread does for sync tools. + ctx = copy_context() + seen = pool.submit(ctx.run, state.get_cid) + assert seen.result() == "cid-worker" + finally: + state.reset(token) + + +def test_refusal_round_trip_with_extra_payload(): + token = state.install() + try: + state.record_refusal( + event_id=57377, + event_name="scope check denied", + outcome="denied", + reason="missing_scope", + required_scope=["couchbase-mcp:write"], + ) + refusal = state.get_refusal() + assert refusal == { + "event_id": 57377, + "event_name": "scope check denied", + "outcome": "denied", + "reason": "missing_scope", + "required_scope": ["couchbase-mcp:write"], + } + finally: + state.reset(token) + + +def test_first_refusal_wins(): + """The earliest gate to fire is the one that stopped the request.""" + token = state.install() + try: + state.record_refusal( + event_id=57377, + event_name="scope check denied", + outcome="denied", + reason="missing_scope", + ) + state.record_refusal( + event_id=57490, + event_name="confirmation declined", + outcome="blocked", + reason="confirmation_declined", + ) + assert state.get_refusal()["event_id"] == 57377 + finally: + state.reset(token) + + +def test_confirmation_and_statement_class(): + token = state.install() + try: + state.record_confirmation(state.CONFIRMATION_ACCEPTED) + state.record_statement_class("write") + assert state.get_confirmation() == "accepted" + assert state.get_statement_class() == "write" + # Last write wins for these, unlike refusals. + state.record_confirmation(state.CONFIRMATION_DECLINED) + assert state.get_confirmation() == "declined" + finally: + state.reset(token) + + +def test_mutation_from_a_worker_thread_is_visible(): + """In-place mutation crosses a copied context; a rebind would not. + + This mirrors exactly what ``anyio.to_thread.run_sync`` does for a + synchronous MCP tool: copy the current context and run the callable inside + it on another thread. + """ + token = state.install() + try: + context = contextvars.copy_context() + + def in_worker() -> None: + state.record_refusal( + event_id=57488, + event_name="write blocked (read-only mode)", + outcome="blocked", + reason="read_only_mode", + ) + state.record_statement_class("write") + + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(context.run, in_worker).result() + + # Read back from the *installing* context, not the worker's copy. + refusal = state.get_refusal() + assert refusal is not None + assert refusal["event_id"] == 57488 + assert state.get_statement_class() == "write" + finally: + state.reset(token) + + +def test_rebinding_in_a_worker_would_not_be_visible(): + """Documents why the shared-dict design is necessary, not incidental.""" + token = state.install() + try: + context = contextvars.copy_context() + + def rebind_in_worker() -> None: + state.install() # rebinds the contextvar inside the copy + state.record_confirmation(state.CONFIRMATION_SKIPPED) + + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(context.run, rebind_in_worker).result() + + # The installing context never sees it — hence record_* must mutate. + assert state.get_confirmation() is None + finally: + state.reset(token) diff --git a/tests/unit/test_audit_wiring.py b/tests/unit/test_audit_wiring.py new file mode 100644 index 00000000..c8e3bc4e --- /dev/null +++ b/tests/unit/test_audit_wiring.py @@ -0,0 +1,539 @@ +"""Production wiring for auditing: the paths no unit test injected its own logger into. + +Every other audit test builds its own ``AuditLogger`` and its own ``FastMCP``, +which is right for isolating behaviour but leaves the actual assembly +unexercised. These tests drive the real seams instead: + +- ``build_app`` -> ``_start_audit`` -> the startup records, and the shutdown + record the lifespan's ``finally`` emits. +- That a server declaring no ``audit_package`` registers no middleware and + writes nothing, which is what keeps Operational Insights out of the + operational Tier-2 block. +- ``CouchbaseJWTVerifier.verify_token`` emitting ``token rejected`` — the one + audit hook that cannot live in middleware, because a rejected token never + becomes a JSON-RPC message. +- The six audit flags reaching a resolved configuration through Click. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import json +import logging +import signal +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from _all_specs import ALL_SPECS + +from cb_mcp.audit import emitter +from cb_mcp.audit.catalog import AuditEvent +from cb_mcp.audit.config import resolve_audit_config +from cb_mcp.audit.emitter import get_audit_logger, init_audit, shutdown_audit +from cb_mcp.audit.sink import process_scoped_path +from cb_mcp.auth import CouchbaseJWTVerifier +from cb_mcp.core.app import _start_audit, build_app + + +def _records(directory: Path) -> list[dict]: + return [ + json.loads(line) + for path in sorted(directory.iterdir()) + if ".log" in path.name + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + +def _audit_config(tmp_path: Path, **overrides): + options = { + "enabled": True, + # The file sink specifically: these tests assert on what reaches disk. + "sinks": "file", + "file": str(tmp_path / "audit.log"), + "rotation_max_size_mb": None, + "max_backups": None, + "tool_args": None, + "disabled_events": None, + } + options.update(overrides) + return resolve_audit_config(**options) + + +def _build_unstarted_app(spec, audit_config, tmp_path): + """Call ``build_app`` without running its lifespan. + + The warning under test is emitted while the app is being built, not while + it runs, which is the point: an operator sees it at startup. + """ + with patch("cb_mcp.core.app.FastMCP", return_value=MagicMock()): + return build_app( + spec, + tools=list(spec.tools.all_tools)[:1], + settings={"transport": "stdio", "oauth_enabled": False}, + provider_factory=MagicMock(), + audit_config=audit_config, + ) + + +def _run_lifespan(spec, audit_config, tmp_path): + """Build the app for ``spec`` and take its lifespan through a clean cycle.""" + captured: dict = {} + added: list = [] + + def capture_app(*_args, **kwargs): + captured["lifespan"] = kwargs.get("lifespan") + app = MagicMock() + app.add_middleware.side_effect = added.append + return app + + with patch("cb_mcp.core.app.FastMCP", side_effect=capture_app): + build_app( + spec, + tools=list(spec.tools.all_tools)[:2], + settings={ + "transport": "stdio", + "oauth_enabled": False, + "disabled_tools": {"drop_index"}, + "confirmation_required_tools": set(), + }, + provider_factory=MagicMock, + read_only_mode=True, + audit_config=audit_config, + ) + + async def drive(): + async with captured["lifespan"](MagicMock()): + pass + + asyncio.run(drive()) + shutdown_audit() + return added + + +@pytest.mark.parametrize("spec", ALL_SPECS, ids=lambda s: s.id) +def test_audit_wiring_follows_the_spec_s_audit_package(spec, tmp_path): + """Auditing is opt-in per server, and the opt-out is total. + + A server with no ``audit_package`` must register no middleware and write + no records: its tools would otherwise fall through classification's + fail-closed path and be booked against another server's Tier-2 block, + where the same ids mean different operations. + """ + added = _run_lifespan(spec, _audit_config(tmp_path), tmp_path) + records = _records(tmp_path) + + if spec.audit_package is None: + assert added == [], f"{spec.id} registered audit middleware" + assert records == [], f"{spec.id} wrote audit records" + return + + assert [type(mw).__name__ for mw in added] == ["AuditMiddleware"] + assert [record["name"] for record in records] == [ + "server started", + "server configuration", + "server stopped", + ] + + +def test_startup_records_report_the_enforced_surface(tmp_path): + """The configuration record is what makes withheld tools auditable. + + Neither a disabled nor a read-only-withheld tool is ever registered with + FastMCP, so a client is never told it exists and there is no invocation to + refuse. This record is the only place that surface is stated. + """ + spec = next(s for s in ALL_SPECS if s.audit_package is not None) + _run_lifespan(spec, _audit_config(tmp_path), tmp_path) + + config_record = next( + r for r in _records(tmp_path) if r["name"] == "server configuration" + ) + assert config_record["server_id"] == spec.id + assert config_record["service_package"] == spec.audit_package + assert config_record["read_only_mode"] is True + assert config_record["oauth_enabled"] is False + assert config_record["disabled_tools"] == ["drop_index"] + assert config_record["registered_tools"] + assert config_record["audit_config"]["enabled"] is True + + +def test_a_clean_shutdown_is_recorded_as_success(tmp_path): + spec = next(s for s in ALL_SPECS if s.audit_package is not None) + _run_lifespan(spec, _audit_config(tmp_path), tmp_path) + + stopped = next(r for r in _records(tmp_path) if r["name"] == "server stopped") + assert stopped["outcome"] == "success" + assert "reason" not in stopped + + +def test_nothing_is_written_when_auditing_is_configured_off(tmp_path): + """The middleware is still registered; it just never has a sink to write to. + + Registration is deliberately not conditional on the flag: the middleware + short-circuits on an inactive logger, so one registration path serves both + modes and a sink that fails to *open* degrades to no records rather than to + a half-instrumented server. What must hold is that nothing is written. + """ + spec = next(s for s in ALL_SPECS if s.audit_package is not None) + _run_lifespan(spec, _audit_config(tmp_path, enabled=False), tmp_path) + assert not get_audit_logger().active + assert _records(tmp_path) == [] + + +# --------------------------------------------------------------------------- +# the verifier hook +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_rejected_token_is_audited_by_the_verifier(tmp_path): + """The one audit hook that cannot live in middleware. + + A rejected bearer token is refused in the ASGI auth layer, so no JSON-RPC + message is ever constructed and the MCP pipeline never runs. Without this + override the single most security-relevant event in the catalogue would + never be emitted. + """ + init_audit(_audit_config(tmp_path)) + try: + verifier = CouchbaseJWTVerifier( + jwks_uri="https://idp.example.com/.well-known/jwks.json", + issuer="https://idp.example.com/", + audience="couchbase-mcp", + ) + with patch.object( + CouchbaseJWTVerifier.__bases__[0], "verify_token", return_value=None + ): + assert await verifier.verify_token("not-a-real-token") is None + finally: + shutdown_audit() + + rejected = [r for r in _records(tmp_path) if r["name"] == "token rejected"] + assert len(rejected) == 1, _records(tmp_path) + record = rejected[0] + assert record["id"] == 57376 + assert record["outcome"] == "denied" + assert record["reason"] == "token_invalid" + # A rejected token establishes no identity, and the record is a deliberate + # singleton: it still carries a cid so grouping a file by cid works + # unconditionally, but nothing else can ever share it. + assert record["real_userid"] == {"domain": "anonymous", "user": "anonymous"} + assert record["cid"] + + +@pytest.mark.asyncio +async def test_an_accepted_token_emits_no_record(tmp_path): + """There is no "token accepted" event; success is silent at this layer.""" + init_audit(_audit_config(tmp_path)) + try: + verifier = CouchbaseJWTVerifier( + jwks_uri="https://idp.example.com/.well-known/jwks.json", + issuer="https://idp.example.com/", + audience="couchbase-mcp", + ) + with patch.object( + CouchbaseJWTVerifier.__bases__[0], "verify_token", return_value=MagicMock() + ): + assert await verifier.verify_token("a-valid-token") is not None + finally: + shutdown_audit() + + assert [r for r in _records(tmp_path) if r["name"] == "token rejected"] == [] + + +def test_the_startup_record_states_the_configuration_actually_in_force(tmp_path): + """The permanent record must not claim a file sink that failed to open. + + 57346 is written once and kept for the life of the audit trail. An earlier + version corrected the logger's own copy of the config but still wrote the + *requested* one into this record and onto ``AppContext``, so an auditor — + and ``get_server_configuration_status`` — were told records were going to + disk when the file sink had been dropped. + """ + blocked = tmp_path / "blocked" + blocked.mkdir() + config = _audit_config( + blocked, sinks="console,file", file=str(blocked / "audit.log") + ) + process_scoped_path(config.file).mkdir() + + try: + effective = _start_audit( + next(sp for sp in ALL_SPECS if sp.audit_package is not None), + config, + transport="stdio", + oauth_enabled=False, + read_only_mode=False, + registered_tool_names=["get_document_by_id"], + settings={}, + ) + finally: + shutdown_audit() + + # What _start_audit hands back is what every reporter must use. + assert effective.sinks == ("console",) + assert effective.process_file is None + assert config.sinks == ("console", "file"), "the requested config is unchanged" + + +def test_the_57346_record_and_app_context_both_state_what_is_in_force(tmp_path, capsys): + """The two places the snapshot is actually consumed. + + ``_start_audit`` returning the effective config is only half the fix: the + 57346 record is written once and kept forever, and ``AppContext`` is what + ``get_server_configuration_status`` reports. Asserting only the return + value left both consumers free to use the requested config instead — and + both mutations passed the whole suite, producing a permanent record that + claimed a file sink which had failed to open. + """ + blocked = tmp_path / "blocked" + blocked.mkdir() + config = _audit_config( + blocked, sinks="console,file", file=str(blocked / "audit.log") + ) + process_scoped_path(config.file).mkdir() + spec = next(sp for sp in ALL_SPECS if sp.audit_package is not None) + + captured: dict = {} + + def capture_app(*_args, **kwargs): + captured["lifespan"] = kwargs.get("lifespan") + return MagicMock() + + with patch("cb_mcp.core.app.FastMCP", side_effect=capture_app): + build_app( + spec, + tools=list(spec.tools.all_tools)[:1], + settings={"transport": "stdio", "oauth_enabled": False}, + provider_factory=MagicMock(), + audit_config=config, + ) + + async def run() -> None: + async with captured["lifespan"](MagicMock()) as app_context: + # What the status tool will report. + assert app_context.audit_config["sinks"] == ["console"] + assert app_context.audit_config["process_file"] is None + + try: + asyncio.run(run()) + finally: + shutdown_audit() + + # What the permanent record says. The file sink was dropped, so the record + # went to the surviving console sink — which is itself the point. + emitted = [ + json.loads(line) + for line in capsys.readouterr().err.splitlines() + if line.startswith("{") + ] + configuration = next(r for r in emitted if r["name"] == "server configuration") + assert configuration["audit_config"]["sinks"] == ["console"], configuration + assert configuration["audit_config"]["process_file"] is None, configuration + + +def test_a_file_sink_that_cannot_be_opened_is_removed_from_the_snapshot(tmp_path): + """The reported configuration must match what is actually being written. + + ``init_audit`` reports an unopenable file and keeps serving — but the + snapshot it hands the logger is reported by + ``get_server_configuration_status`` *and* written into the permanent + ``audit configuration changed`` record. A snapshot still naming a file sink + that failed to open would tell an auditor, for as long as the record is + kept, that records were being written to disk when none were. + """ + # A directory where the process-scoped file should be: the portable way to + # make open() fail without depending on the test user's privileges. + blocked = tmp_path / "blocked" + blocked.mkdir() + config = _audit_config( + blocked, sinks="console,file", file=str(blocked / "audit.log") + ) + process_scoped_path(config.file).mkdir() + + try: + audit = init_audit(config) + # The console sink survives, so auditing is still active... + assert audit.active is True + snapshot = audit.config.as_dict() + # ... but nothing claims a file is being written. + assert snapshot["sinks"] == ["console"] + assert snapshot["process_file"] is None + finally: + shutdown_audit() + + +def test_auditing_reports_itself_off_when_every_sink_fails(tmp_path): + blocked = tmp_path / "blocked" + blocked.mkdir() + config = _audit_config(blocked, sinks="file", file=str(blocked / "audit.log")) + process_scoped_path(config.file).mkdir() + + try: + audit = init_audit(config) + assert audit.active is False + assert audit.config.as_dict()["enabled"] is False + finally: + shutdown_audit() + + +def test_sigterm_records_a_clean_shutdown_when_nothing_else_handles_it( + tmp_path, monkeypatch +): + """``docker stop`` under stdio must not look like a crash in the trail. + + SIGTERM's default disposition terminates the process without running + ``atexit`` or unwinding the lifespan, so the most ordinary container + shutdown produced no ``server stopped`` record — while the catalogue says a + missing one means the shutdown was not clean. Every restart would have read + as an unclean one, which makes the signal useless for finding a real one. + """ + config = _audit_config(tmp_path, sinks="file") + killed: list[int] = [] + + audit = init_audit(config) + assert audit.active + try: + # SIG_DFL is the stdio case: nothing else will unwind anything. + monkeypatch.setattr(emitter, "_previous_sigterm", signal.SIG_DFL) + monkeypatch.setattr(emitter.signal, "signal", lambda *_a: None) + monkeypatch.setattr(emitter.os, "kill", lambda _pid, sig: killed.append(sig)) + emitter._handle_sigterm(signal.SIGTERM, None) + finally: + shutdown_audit() + + records = _records(tmp_path) + names = [r["name"] for r in records] + assert "server stopped" in names, names + stopped = next(r for r in records if r["name"] == "server stopped") + assert stopped["outcome"] == "success" + # Says *how* it stopped, without claiming a failure. + assert stopped["shutdown_signal"] == "SIGTERM" + assert "reason" not in stopped + # Still exits by the signal, so the status code does not pretend it was a + # normal exit. + assert killed == [signal.SIGTERM] + + +def test_sigterm_hands_over_without_closing_when_another_handler_owns_it( + tmp_path, monkeypatch +): + """Under http, uvicorn drains in-flight requests *after* its handler runs. + + Closing the audit trail before handing over left every call still running + unrecorded — the middleware reads the process-wide logger, finds it + inactive and emits nothing, not even a dropped count. A write cancelled by + a rolling restart would vanish, which is exactly the suppression that + catching ``BaseException`` around the tool exists to prevent. The lifespan + emits ``server stopped`` when uvicorn unwinds it, so handing over loses + nothing. + """ + config = _audit_config(tmp_path, sinks="file") + chained: list[int] = [] + + audit = init_audit(config) + assert audit.active + try: + monkeypatch.setattr( + emitter, "_previous_sigterm", lambda signum, _frame: chained.append(signum) + ) + emitter._handle_sigterm(signal.SIGTERM, None) + + # The whole point: auditing is still running, so records arriving + # during the drain are still written. + assert chained == [signal.SIGTERM] + assert get_audit_logger().active is True + get_audit_logger().emit_event(AuditEvent.SESSION_INITIALIZED, outcome="success") + finally: + shutdown_audit() + + names = [r["name"] for r in _records(tmp_path)] + assert "session initialized" in names, ( + f"a record arriving during the drain was lost: {names}" + ) + + +def test_the_sigterm_handler_is_installed_only_when_auditing_runs( + tmp_path, monkeypatch +): + """A server with auditing off must not touch the signal disposition.""" + monkeypatch.setattr(emitter, "_previous_sigterm", None) + original = signal.getsignal(signal.SIGTERM) + signal.signal(signal.SIGTERM, signal.SIG_DFL) + try: + init_audit(_audit_config(tmp_path, enabled=False)) + assert signal.getsignal(signal.SIGTERM) is signal.SIG_DFL + + init_audit(_audit_config(tmp_path, sinks="file")) + assert signal.getsignal(signal.SIGTERM) is emitter._handle_sigterm + finally: + shutdown_audit() + signal.signal(signal.SIGTERM, original) + + +def test_installing_twice_does_not_chain_the_handler_to_itself(tmp_path): + """The install guard is correctness, not an optimisation. + + ``_handle_sigterm`` hands over to ``_previous_sigterm`` whenever it is + callable. If a second install captured the already-installed handler as its + own predecessor, the next SIGTERM would call itself until the stack ran + out — on the one path that exists to make shutdown *more* reliable. + """ + original = signal.getsignal(signal.SIGTERM) + signal.signal(signal.SIGTERM, signal.SIG_DFL) + try: + init_audit(_audit_config(tmp_path, sinks="file")) + shutdown_audit() + init_audit(_audit_config(tmp_path, sinks="file")) + + assert signal.getsignal(signal.SIGTERM) is emitter._handle_sigterm + assert emitter._previous_sigterm is not emitter._handle_sigterm + assert emitter._previous_sigterm is signal.SIG_DFL + finally: + shutdown_audit() + signal.signal(signal.SIGTERM, original) + + +def test_enabling_auditing_on_an_unaudited_server_warns(tmp_path, caplog): + """Silence is indistinguishable from a broken sink. + + A server that declares no audit package accepts every audit flag and + reports it, but records nothing. An operator who enabled auditing there + would find an empty directory, with the only signal being ``active: false`` + buried in the status tool. + + Every server this repository ships is audited since Operational Insights + has its own block, so the spec is synthesised rather than + found. The warning is kept, and kept tested, because the next server added + starts out exactly like this one — and the day it does, an operator must be + told rather than left reading an empty file. + """ + audited = next(sp for sp in ALL_SPECS if sp.audit_package is not None) + unaudited = dataclasses.replace(audited, id="future-unaudited", audit_package=None) + config = _audit_config(tmp_path, sinks="file") + + with caplog.at_level(logging.WARNING): + _build_unstarted_app(unaudited, config, tmp_path) + + assert "declares no audit package" in caplog.text + assert unaudited.id in caplog.text + + +def test_every_shipped_server_is_audited(): + """Records the state the test above has to synthesise around.""" + assert [sp.audit_package for sp in ALL_SPECS] == [ + sp.audit_package for sp in ALL_SPECS if sp.audit_package is not None + ] + + +def test_an_audited_server_does_not_warn(tmp_path, caplog): + audited = next(sp for sp in ALL_SPECS if sp.audit_package is not None) + config = _audit_config(tmp_path, sinks="file") + + with caplog.at_level(logging.WARNING): + _build_unstarted_app(audited, config, tmp_path) + + assert "declares no audit package" not in caplog.text diff --git a/tests/unit/test_cli_options.py b/tests/unit/test_cli_options.py index 0bd93199..df06c683 100644 --- a/tests/unit/test_cli_options.py +++ b/tests/unit/test_cli_options.py @@ -21,7 +21,13 @@ import mcp_server from cb_mcp.auth import OAuthConfigError, resolve_oauth -from cb_mcp.utils.constants import LOGGER_ROOT, SCOPE_READ, SCOPE_WRITE +from cb_mcp.utils.cli_params import CliParams, CredentialProfile +from cb_mcp.utils.constants import ( + LOGGER_NAMESPACE, + LOGGER_ROOT, + SCOPE_READ, + SCOPE_WRITE, +) @pytest.fixture(autouse=True) @@ -217,3 +223,211 @@ def test_cli_translates_oauth_config_error_to_usage_error(self): ) assert result.exit_code == 2 assert "must be distinct" in result.output + + +class TestAuditOptions: + """The six audit flags, through Click, to a resolved configuration. + + Audit settings do not appear in ``app_context.settings`` — deliberately, so + they are not duplicated into the env-info snapshot — so these assert on the + ``ResolvedAuditConfig`` the entrypoint hands to ``build_app`` instead. + """ + + @staticmethod + def _resolved(args: list[str], env: dict | None = None): + captured: dict = {} + real = mcp_server.build_app + + def capture(*a, **kw): + captured["audit"] = kw.get("audit_config") + return real(*a, **kw) + + with ( + patch("cb_mcp.core.app.FastMCP", return_value=MagicMock()), + patch("mcp_server.build_app", side_effect=capture), + patch("mcp_server.run_app"), + ): + result = CliRunner().invoke( + mcp_server.main, + ["--connection-string", "couchbase://localhost", *args], + env={**os.environ, **(env or {})}, + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + return captured["audit"] + + def test_auditing_is_off_by_default(self): + config = self._resolved([]) + assert config.enabled is False + assert config.file is None + assert config.tool_args is False + + def test_flags_reach_the_resolved_configuration(self, tmp_path): + config = self._resolved( + [ + "--audit-log-enabled", + "true", + "--audit-log-sinks", + "console,file", + "--audit-log-file-path", + str(tmp_path / "audit.log"), + "--audit-log-rotation-max-size-mb", + "4", + "--audit-log-rotation-interval", + "2w", + "--audit-log-retention-max-backups", + "7", + "--audit-log-tool-args", + "true", + "--audit-log-disabled-events", + "61490,61491", + ] + ) + assert config.enabled is True + assert config.sinks == ("console", "file") + assert config.file == str(tmp_path / "audit.log") + assert config.rotation_max_size_mb == 4.0 + assert config.rotation_interval == "2w" + assert config.rotation_interval_seconds == 2 * 604_800 + assert config.max_backups == 7 + assert config.tool_args is True + assert config.disabled_events == (61490, 61491) + # The process-scoped path is resolved once, here, so startup can log + # the exact file and an operator is never left guessing. + assert config.process_file and config.process_file.endswith(".log") + + def test_env_vars_are_honoured(self, tmp_path): + config = self._resolved( + [], + env={ + "CB_MCP_AUDIT_LOG_ENABLED": "true", + "CB_MCP_AUDIT_LOG_SINKS": "file", + "CB_MCP_AUDIT_LOG_FILE_PATH": str(tmp_path / "from-env.log"), + "CB_MCP_AUDIT_LOG_ROTATION_INTERVAL": "30d", + "CB_MCP_AUDIT_LOG_RETENTION_MAX_BACKUPS": "3", + "CB_MCP_AUDIT_LOG_TOOL_ARGS": "true", + }, + ) + assert config.enabled is True + assert config.sinks == ("file",) + assert config.file == str(tmp_path / "from-env.log") + assert config.rotation_interval == "30d" + assert config.max_backups == 3 + assert config.tool_args is True + + def test_enabling_the_file_sink_without_a_path_still_starts_the_server(self): + """Per the PRD this is reported, not fatal: the server still starts.""" + config = self._resolved( + ["--audit-log-enabled", "true", "--audit-log-sinks", "file"] + ) + assert config.enabled is False + + def test_enabling_without_a_file_defaults_to_the_console_sink(self): + """The default sink needs no path, so this is a working configuration.""" + config = self._resolved(["--audit-log-enabled", "true"]) + assert config.enabled is True + assert config.sinks == ("console",) + assert config.writes_file is False + + def test_both_subcommands_accept_the_audit_flags(self): + for command in ("operational", "operational-insights"): + result = CliRunner().invoke( + mcp_server.main, [command, "--help"], catch_exceptions=False + ) + assert result.exit_code == 0 + for flag in ( + "--audit-log-enabled", + "--audit-log-sinks", + "--audit-log-file-path", + "--audit-log-rotation-max-size-mb", + "--audit-log-rotation-interval", + "--audit-log-retention-max-backups", + "--audit-log-tool-args", + "--audit-log-disabled-events", + ): + assert flag in result.output, f"{flag} missing from {command} --help" + + +class TestAuditDiagnosticOrdering: + """Audit diagnostics must reach the operator's configured log sinks. + + ``README`` and ``DOCKER.md`` both promise "an error in the log" when the + file sink is selected without a path. Resolving the audit configuration + inside ``from_click`` emitted that error two lines before + ``cli.logging.apply()`` ran, so it went to ``logging.lastResort`` on raw + stderr while the per-level log files stayed empty — the documented + behaviour was simply not happening. + """ + + @staticmethod + def _params(**overrides) -> dict: + """A complete Click params mapping, as the real entrypoint receives it.""" + captured: dict = {} + + def capture(params, *, credentials): + captured["params"] = dict(params) + raise SystemExit(0) + + with patch.object(CliParams, "from_click", side_effect=capture): + CliRunner().invoke( + mcp_server.main, + [ + "operational", + "--connection-string", + "couchbase://localhost", + "--username", + "u", + "--password", + "p", + ], + env=dict(os.environ), + catch_exceptions=True, + ) + params = captured["params"] + params.update(overrides) + return params + + def test_resolution_is_deferred_until_first_access(self): + emitted: list[str] = [] + + class Capture(logging.Handler): + def emit(self, record): + emitted.append(record.getMessage()) + + params = self._params( + audit_log_enabled=True, + audit_log_sinks="file", + audit_log_file_path=None, + ) + handler = Capture() + audit_logger = logging.getLogger(f"{LOGGER_NAMESPACE}.audit.config") + audit_logger.addHandler(handler) + try: + cli = CliParams.from_click( + params, + credentials=CredentialProfile(options=lambda f: f, settings_keys=()), + ) + # Nothing logged yet: this is the window in which the real startup + # path configures logging. + assert emitted == [], emitted + + config = cli.audit + finally: + audit_logger.removeHandler(handler) + + assert config.enabled is False + assert any("no audit file path is configured" in m for m in emitted), emitted + + def test_the_resolved_configuration_is_cached(self): + params = self._params(audit_log_enabled=True, audit_log_sinks="console") + cli = CliParams.from_click( + params, credentials=CredentialProfile(options=lambda f: f, settings_keys=()) + ) + # Resolution warns; repeating it per access would duplicate every + # diagnostic and re-derive the process-scoped path. Two named locals + # rather than `cli.audit is cli.audit`: the latter reads as a comparison + # of identical expressions, which static analysis flags and a reader has + # to pause over. + first = cli.audit + second = cli.audit + assert first is second diff --git a/tests/unit/test_logger_names.py b/tests/unit/test_logger_names.py index 5a3cbfc1..118038b0 100644 --- a/tests/unit/test_logger_names.py +++ b/tests/unit/test_logger_names.py @@ -47,6 +47,11 @@ # "couchbase" root, which belongs to the SDK. EXPECTED_LOGGER_NAMES = { # shared + "cb_mcp.audit.config": "couchbase.mcp.audit.config", + "cb_mcp.audit.emitter": "couchbase.mcp.audit", + "cb_mcp.audit.identity": "couchbase.mcp.audit.identity", + "cb_mcp.audit.middleware": "couchbase.mcp.audit.middleware", + "cb_mcp.audit.sink": "couchbase.mcp.audit.sink", "cb_mcp.auth": "couchbase.mcp.auth", "cb_mcp.core.app": "couchbase.mcp.core.app", "cb_mcp.tool_registration": "couchbase.mcp.tool_registration", diff --git a/tests/unit/test_server_configuration_status_tool.py b/tests/unit/test_server_configuration_status_tool.py index 74f053c9..aeadfa02 100644 --- a/tests/unit/test_server_configuration_status_tool.py +++ b/tests/unit/test_server_configuration_status_tool.py @@ -14,10 +14,44 @@ from fastmcp import Context +from cb_mcp.audit import emitter +from cb_mcp.audit.config import resolve_audit_config +from cb_mcp.audit.emitter import AuditLogger, shutdown_audit from cb_mcp.tools.status import get_server_configuration_status -def _make_ctx(settings=None, cluster_provider=None, logging_config=None) -> Context: +class _FakeSink: + """A sink that only has to report counters.""" + + def __init__(self, *, written: int, dropped: int, write_errors: int) -> None: + self._stats = { + "written": written, + "dropped": dropped, + "write_errors": write_errors, + } + + def start(self) -> None: + pass + + def emit(self, line: str) -> None: + pass + + def close(self, timeout: float = 5.0) -> None: + pass + + @property + def stats(self) -> dict[str, int]: + return dict(self._stats) + + +def init_audit_for_test(config, sink) -> None: + """Install an active logger without touching the filesystem.""" + emitter._active = AuditLogger(config, sink=sink) + + +def _make_ctx( + settings=None, cluster_provider=None, logging_config=None, audit_config=None +) -> Context: # SimpleNamespace duck-types the bits get_server_configuration_status # actually reads (request_context.lifespan_context.{settings, # cluster_provider, logging_config}); the cast tells pyright that's @@ -32,6 +66,7 @@ def _make_ctx(settings=None, cluster_provider=None, logging_config=None) -> Cont cluster_provider=cluster_provider, settings=settings if settings is not None else {}, logging_config=logging_config, + audit_config=audit_config, ) ) ), @@ -178,3 +213,75 @@ def test_logging_block_alongside_existing_configuration_keys(): assert "connections" in payload # logging is NOT inside configuration — it's a top-level peer. assert "logging" not in payload["configuration"] + + +# --------------------------------------------------------------------------- +# the audit block +# --------------------------------------------------------------------------- + + +def test_audit_block_reports_the_configuration_and_the_loss_counters(): + """The counters here are the only way an operator learns records were lost. + + README and DOCKER.md both document ``written`` / ``dropped`` / + ``write_errors`` as that signal, and the whole block was untested: + dropping ``audit_config`` from the lifespan context, or making + ``get_audit_config`` return ``None``, passed the entire suite while the + status tool silently stopped reporting auditing at all. + """ + config = resolve_audit_config( + enabled=True, + sinks="file", + file="/var/log/cb-mcp/audit.log", + rotation_max_size_mb=10, + rotation_interval="1d", + max_backups=7, + tool_args=None, + disabled_events=None, + ) + ctx = _make_ctx({}, audit_config=config.as_dict()) + + sink = _FakeSink(written=10427, dropped=3, write_errors=1) + try: + init_audit_for_test(config, sink) + payload = get_server_configuration_status(ctx) + finally: + shutdown_audit() + + audit = payload["audit"] + assert audit["enabled"] is True + assert audit["sinks"] == ["file"] + assert audit["rotation_interval"] == "1d" + assert audit["max_backups"] == 7 + assert audit["active"] is True + # The number that matters: a non-zero ``dropped`` is a gap in the trail. + assert audit["stats"] == {"written": 10427, "dropped": 3, "write_errors": 1} + + +def test_audit_block_says_inactive_when_nothing_is_being_written(): + config = resolve_audit_config( + enabled=True, + sinks="file", + file="/var/log/cb-mcp/audit.log", + rotation_max_size_mb=None, + rotation_interval="0", + max_backups=1, + tool_args=None, + disabled_events=None, + ) + ctx = _make_ctx({}, audit_config=config.as_dict()) + + payload = get_server_configuration_status(ctx) + audit = payload["audit"] + + # No logger installed: enabled by configuration, inactive in fact. An + # operator must be able to tell those apart. + assert audit["enabled"] is True + assert audit["active"] is False + + +def test_a_server_without_auditing_reports_a_null_audit_block(): + """``None`` is the right answer both for auditing off and for a host that + never adopted it: in neither case is anything being recorded.""" + payload = get_server_configuration_status(_make_ctx({})) + assert payload["audit"] is None