diff --git a/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md index d466407c2..c84de5427 100644 --- a/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md +++ b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md @@ -3,6 +3,8 @@ **Date**: 2026-09-25 **Status**: Accepted +> **Update (2026-10-06)**: The MCP HTTP transport in core ([ADR-0043](./0043-mcp-http-transport-with-admin-bound-tokens.md)) is a second consumer with no hub in front of it, so it asks the kernel to drop the output keys a tool marks as personal, unless the operator switches that off; a run can now request that drop, and its audit record then hashes exactly what was returned and carries the client's address and agent. Without the request the kernel still returns allow-listed data as the module has it. A tool descriptor's output JSON Schema now describes only the allow-listed keys, because MCP clients validate structured output against the published schema. `AdminGuard.assertUser` now also refuses a deactivated account. + ## Context An agent hub (a premium package outside this repo) runs models that read platform data and diff --git a/docs/adr/0043-mcp-http-transport-with-admin-bound-tokens.md b/docs/adr/0043-mcp-http-transport-with-admin-bound-tokens.md new file mode 100644 index 000000000..88240cf9b --- /dev/null +++ b/docs/adr/0043-mcp-http-transport-with-admin-bound-tokens.md @@ -0,0 +1,179 @@ +# ADR-0043: MCP HTTP transport with admin-bound tokens + +**Date**: 2026-10-06 +**Status**: Proposed + +## Context + +[ADR-0042](./0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md) gave the platform +one MCP kernel that every consumer goes through. The agent hub calls it in-process: its runs +build their own run context, so they need no network transport and no credential. An admin's +own MCP client is different. Claude Code, Claude Desktop or an analyst's tool runs on the +admin's machine and reaches the platform over HTTP, so the platform has to authenticate the +client, bind every call to one admin, and keep the endpoint off the player-facing surface. + +The credential was decided downstream (downstream ADR 0007): an admin generates a token with a +mandatory lifetime in the Backoffice and pastes it into the client as a bearer header. There is +no authorization server, consent screen or client registry. The spike behind that decision +found the OAuth path workable but costly - a better-auth upgrade, mounting its HTTP handler, +consent and client-management screens, several new tables - while the MCP specification makes +authorization on the HTTP transport optional and every current client accepts a static header. + +Building the transport surfaced three problems the kernel alone did not solve: + +- The MCP SDK client validates structured tool output against the output schema the server + publishes. The kernel published the schema of a tool's full output while returning only its + allow-listed keys, so a client rejected a correct, redacted result. +- Tool output reaches whatever model sits behind the client. With no hub in front of this + transport, nothing else strips the keys a tool marks as personal. +- `AdminGuard` did not check whether an account is still active. A session ends with the + account; a token does not. + +## Decision + +**Tokens live in the iam module.** It owns a table of MCP tokens: the admin a token belongs to, +a label, a SHA-256 hash of the token and never the token itself, a short display prefix, the +creation time and a mandatory expiry, the revocation time, actor and reason, the last use and a +call count. The plaintext is returned once, when the token is created. The lifetime defaults to +30 days and is capped by configuration, at 90 days by default and never more than a year. Two +ports in contracts keep the transport and the identity module independent of iam: one +authenticates a bearer and records a call, the other revokes every token a user holds inside the +caller's transaction. Only an active admin who holds the MCP permission can be issued a token, +and only from a live session. The issuing transaction locks the owner's row, so it cannot +interleave with a deactivation, then takes the token-table lock that grant changes and bulk +revocations hold. Under it the transaction reads the owner's MCP permission again, uncached, and +checks by the database clock that the session issuing the token has not ended, so an issue that +waited on a change taking the permission away, or on a revocation of that session, is refused +instead of leaving a token behind. Each admin holds a configured number of active tokens at most +and can issue a configured number per hour. + +**Two permissions.** One IAM resource lets an admin use MCP and manage their own tokens; another +lets an admin see every admin's tokens and revoke any of them, including an emergency revoke of +all. They are separate resources because the role matrix stores levels: on one resource, the +level that allows creating a token would also allow revoking everyone else's. + +**The transport is part of core and off by default.** `createApp` mounts the MCP Streamable +HTTP endpoint only when `agents.mcp.enabled` is on, and mounts it ahead of CORS, cookie sessions, +caching and the oRPC handler, so none of them touches the request. It runs the official SDK +without session state - a fresh server and transport per request, JSON responses - and imports +the SDK, an optional peer dependency, only when the endpoint is enabled. Each request passes, in +order: + +1. The host binding. The config refuses to enable the endpoint without an allowed host, and on + any other host the request falls through to the normal 404, so the endpoint does not exist on + the player domain. +2. The origin check. A request carrying a browser origin is refused unless that origin is + configured; desktop clients send none. +3. A per-client-address limit, before any token is looked up. +4. Bearer authentication. A missing, unknown, expired or revoked token, or one issued before its + admin's latest password change or reset, gets a 401 whose challenge names no OAuth metadata, + so a client does not start an OAuth discovery. +5. Per-token rate limits for a minute and a day on the shared rate limiter, failing closed: an + unreachable limiter answers 503. +6. `AdminGuard.assertUser` for the MCP permission, which now also refuses a deactivated account. + +Only read-class tools are listed, narrowed to the ones the admin's grants allow; propose-class +tools and action types are never exposed. Proposing over MCP waits for the agent hub's review +queue, which receives proposals, and for a permission to create one, which the IAM catalog does +not have yet. Every call still goes through the kernel, which checks +the tool's own permission and writes the audit record with the token id and the client's address +and agent. A call naming a tool the endpoint does not expose is refused and audited too, and the +kernel's input hashing is total, so no request can reach a tool and skip its audit record. + +**Personal data is dropped by default.** The transport asks the kernel to drop the output keys a +tool marks as personal, so the audited output hash covers exactly what the client received, and +publishes output schemas without them. An operator can switch that off. The kernel's descriptor +now publishes the output schema projected to the allow-listed keys, so what a client validates is +what the kernel returns. + +**Revocation is manual and automatic.** The owner or an overseer revokes a token by hand. +Automatic revocation commits in the same transaction as the change that causes it and keys on the +resulting state, so a failure rolls the change back and a retry finishes the job: + +- the identity module revokes all of an admin's tokens when it deactivates the account, demotes + it from admin, or revokes all of its sessions (which also covers a forced logout, a two-factor + reset and an email change); +- the iam module revokes the tokens of every admin who loses MCP access when a role loses the permission, is deleted or is unassigned, or when a first role assignment replaces the static admin role without it; grant changes and bulk revocations take the same lock, so they apply one at a time. + +A password change or reset cannot share a transaction with the revocation: better-auth commits +the new password itself, and the identity module revokes the admin's tokens after it. The +authenticator therefore also refuses every token issued at or before its admin's latest password +change, read from the update stamp better-auth writes to the credential account with the new +hash, so a revocation that fails leaves the old tokens refused rather than usable. A failed +revocation is therefore logged rather than failing the change or the reset, which has already +committed; on a reset a failure must not abort the hook either, because better-auth revokes the +account's sessions only once its reset hook returns. + +Players can never hold tokens, so their session revocations skip the token table. A bulk +revocation locks the token table first and issuance takes the same lock before it inserts, so a +token issued concurrently either commits before the revocation reads the table and is revoked +with the rest, or is checked against what the revocation committed. The per-request check stops +a token at once; the persisted revocation keeps it dead after the account or the access is +restored. + +**The kill switch is configuration plus revocation.** Turning the endpoint off removes it at the +next start. Revoking all tokens, or removing the MCP permission from a role, stops use without a +restart. + +Rejected alternatives: + +- **better-auth plugins.** The API-key plugin deletes expired keys, losing the history the + Backoffice screen lists, and one misconfiguration turns a key into a session for every route. + The MCP and OAuth-provider plugins require OAuth with login and consent. The agent-auth plugin + is experimental, signs a fresh short-lived JWT per request, and reaches Claude Code only + through a local proxy that exposes its own protocol tools instead of the platform's. +- **Stateful MCP sessions.** A read-only tool catalog needs no server state, and stateless + requests scale across replicas without sticky routing. +- **Serving MCP through the oRPC handler.** MCP is its own JSON-RPC protocol with its own content + negotiation, and that path would add credentialed CORS, cookie sessions and caching the + endpoint must not have. +- **Describing tools with the SDK's high-level registration.** It rebuilds tool schemas from zod; + the kernel already publishes validated JSON Schemas, and the low-level handlers keep the kernel + the only enforcement path. +- **OAuth now.** It is the next step when a client cannot send a static header (Claude Desktop + and claude.ai remote connectors), when a third party needs access, when an agent runtime moves + out of process, or when MCP gains writes. It plugs in as a second authenticator behind the same + port, without touching the transport or the audit. + +## Consequences + +**Positive:** + +- An admin can query the platform from an MCP client under the same grants, audit trail and + redaction as the Backoffice and the agent hub. +- The endpoint is off until an operator enables it, absent on hosts it is not bound to, and + every token expires, can be revoked, and leaves an audit record per call. +- The active-account check in `assertUser` also stops agent runs acting for a deactivated admin. + +**Negative / trade-offs:** + +- An operator who enables the endpoint installs the MCP SDK next to core; a deployment that + never enables it installs nothing extra. +- A stolen token reads what its admin can read until it expires or is revoked. The lifetime cap, + the per-token limits, hashed storage and the per-call audit bound that exposure. +- Claude Desktop's remote connectors accept only OAuth, so Desktop needs a local bridge that adds + the header; Claude Code sends it directly. +- The daily limit is a fixed 24-hour window that opens with the first request, not a calendar + day, and every authenticated request counts towards both limits. Both limits are per token, so + an admin who holds several tokens can exceed them in total. +- Issuing a token needs a session with the MCP permission but no fresh second factor. A step-up + needs a cross-module port into identity and a Backoffice flow, and is left to a later change. +- Issuance holds the token-table lock from its permission and session checks to its commit, so + issues run one at a time, wait for a bulk revocation or grant change in flight, and briefly + hold back the call counter of tokens in use. Issuing is rare enough for that serialization. +- On a password reset better-auth deletes the account's sessions only after the token revocation + has committed, so a token issued from one of those sessions in that interval of a few + milliseconds survives the reset until it expires or is revoked by hand. +- A token whose revocation after a password change or reset failed is refused at once but stays + listed as active, and counts towards its admin's active-token cap, until it is revoked by hand + or expires. The password-change check compares two application-clock stamps, so a token issued + within the clock skew between replicas of a password change can fall on either side of it. +- The host binding reads the request's host, so every proxy or load balancer that can reach the + app, not only the backoffice one, has to set the `Host` header itself and reject absolute-form + request targets. + +**Neutral:** + +- A deactivated admin keeps a live Backoffice session until it expires, because the session-based + `assert` does not check the active flag. That gap predates this decision and is left to a + separate change. diff --git a/docs/guides/downstream-consumer.md b/docs/guides/downstream-consumer.md index f3f974324..93cd337da 100644 --- a/docs/guides/downstream-consumer.md +++ b/docs/guides/downstream-consumer.md @@ -197,10 +197,9 @@ on every change), `file:` (snapshot copy on install, no live source). ## Consumer load pattern -Consumer `extensions.config.ts` points at the built plugin files inside `@openora/core` -(`.../packages/core/dist///src/plugin.js`), not source, because tsx in the -consumer's API entry can't reliably resolve the tsconfig. Always build `@openora/core` -before booting the consumer: +Consumer `extensions.config.ts` loads core's modules through `corePlugins()`, which resolves the +built plugin files inside `@openora/core`, not source, because tsx in the consumer's API entry +can't reliably resolve the tsconfig. Always build `@openora/core` before booting the consumer: ```bash pnpm -F @openora/core build @@ -212,8 +211,13 @@ For a watch loop during development: pnpm -F @openora/core --parallel build --watch ``` -Paths in the consumer's `extensions.config.ts` resolve relative to that config file's own -directory. +Every path in the consumer's `extensions.config.ts` must be absolute. The plugin host imports each +entry from inside `@openora/core`, so a relative path or a bare package name resolves from core's +location, not from the config file. A bare package name works only when core itself can resolve +that package. An add-on package installed in the consumer (a premium or third-party module +published on its own scope) therefore fails with a module-not-found error at boot. Resolve such a +package in the consumer and pass the resulting absolute path. The consumer template's +`extensions.config.ts` shows both cases: a local overlay and an add-on package. ## Keeping the agent files in sync diff --git a/docs/guides/mcp-setup.md b/docs/guides/mcp-setup.md index 357c3b1af..eb23eead6 100644 --- a/docs/guides/mcp-setup.md +++ b/docs/guides/mcp-setup.md @@ -4,6 +4,10 @@ The `oss-dev` MCP server gives any AI agent read-only inspection of the platform schemas, modules, events, and Drizzle tables - plus write tools to scaffold and verify. It works with any MCP-compatible editor. +This page is about that development server, for coding agents working on this repository. To +query a running platform's data from an admin's MCP client, see +[Connect an MCP client to the platform](./mcp-transport.md). + ## Zero-config setup: `pnpm setup:mcp` The fastest way to wire MCP for Claude Code. Run it right after `pnpm install`: diff --git a/docs/guides/mcp-transport.md b/docs/guides/mcp-transport.md new file mode 100644 index 000000000..d5cc923f0 --- /dev/null +++ b/docs/guides/mcp-transport.md @@ -0,0 +1,137 @@ +# Connect an MCP client to the platform + +A running platform can serve its agent read tools to an admin's own MCP client, such as Claude +Code, over MCP Streamable HTTP. The admin authenticates with a token they generate in the +Backoffice, and every call runs under that admin's permissions. This is not the `oss-dev` server +from [MCP Server Setup](./mcp-setup.md), which inspects this repository's code for coding +agents. The agent hub does not use this endpoint either: it calls the MCP kernel in-process. + +The design and its trade-offs are recorded in +[ADR-0043](../adr/0043-mcp-http-transport-with-admin-bound-tokens.md). + +## Enable the endpoint (operator) + +The endpoint is off by default. Enabling it takes two steps. + +1. Install the MCP SDK next to `@openora/core`. Core declares it as an optional peer dependency + and loads it only while the endpoint is on, so a deployment without MCP never installs it. + + ```bash + pnpm add @modelcontextprotocol/sdk@1.29.0 + ``` + +2. Turn the endpoint on in the `agents.mcp` block of the platform config, with the backoffice + host it answers on. The [agents config schema](../../packages/core/src/contracts/schemas/agents.ts) + lists every setting with its default and bounds. + + ```yaml + agents: + mcp: + enabled: true + allowedHosts: + - backoffice-api.example.com + ``` + +- **Bind it to the backoffice host.** The config refuses to enable the endpoint without at least + one allowed host. On any other host the path is a plain 404, so it never appears on the player + domain. The host is read from the request, so let every proxy or load balancer in front of the + app set the `Host` header itself and reject absolute-form request targets, or serve the endpoint + from a deployment only the backoffice proxy reaches. +- **Browser origins.** A request that carries an `Origin` header is refused unless that origin is + listed. Desktop clients send none, so the list normally stays empty. +- **Token issuance.** Each admin can hold a limited number of active tokens and issue a limited + number per hour. A token gets the default lifetime unless the admin picks a shorter or longer + one, up to the configured cap. +- **Rate limits.** Every client address has a per-minute limit before any token is looked up, and + every token has a per-minute and a per-day limit. They run on the shared rate limiter, which + needs Redis; while it is unreachable the endpoint answers 503 instead of serving unthrottled. +- **Personal data.** By default the kernel removes the output fields a tool marks as personal + (names, contact details, date of birth and similar) before they reach the client, and with it + whatever model the client uses. Switching that off sends them through. +- **Path.** The endpoint is served at `/mcp` unless configured otherwise. + +## Grant access + +Two permissions in the IAM catalog control the endpoint: + +- **MCP access** lets an admin generate, list and revoke their own tokens, and is checked again on + every request. Taking it away - removing it from a role, deleting the role, unassigning the role, or giving an admin a first role that replaces the static admin role without it - revokes the affected admins' tokens for good. +- **MCP token oversight** lets an admin see every admin's tokens and revoke any of them, + including revoking all of them at once. + +The static admin role and the seeded admin and super-admin roles hold both. A role created before +this release gets them through the role matrix. MCP access is all-or-nothing, so only the +read-write level grants it. + +A token never reaches more than its admin's own grants: the tools an admin cannot use are not +listed, and the kernel checks each tool's permission again on every call. + +## Generate a token (admin) + +The platform ships the token routes; the operator's Backoffice builds the screen on top of them. +Only an active admin can generate a token. Copy it at once: it is shown only once, and the +platform stores only its hash. + +## Connect Claude Code + +```bash +claude mcp add --transport http --scope local openora \ + https://backoffice-api.example.com/mcp \ + --header "Authorization: Bearer " +``` + +Run `/mcp` in Claude Code (or restart it) to connect. The tools appear with underscores in their +names, for example the player summary tool as `player_summary`. When a token is revoked or +expires, Claude Code reports that the server rejected the header in its configuration; generate a +new token, update the header and reconnect. + +- Keep the server name short. Claude Code names a tool `mcp____`, and the model API + rejects names longer than 64 characters. +- Claude Code keeps the header, token included, in its local configuration in plain text. Prefer + a short lifetime, and never put the token in a committed `.mcp.json`; an uncommitted one can + read it from an environment variable. + +## Claude Desktop and claude.ai + +Their remote connectors accept only OAuth, which the platform does not offer. A local bridge that +speaks stdio to Desktop and adds the bearer header towards the platform works around that. This +path has not been verified by the platform maintainers. + +## What the client gets + +- Only read tools, never a proposal or an action. +- Output limited to each tool's allow-listed fields, without the personal ones unless the + operator switched them on. +- An audit record for every tool call, including a call naming a tool the endpoint does not + expose, with the token, the admin, the tool and its version, the client address and agent, and + hashes of the input and of exactly the output returned, never the data itself. + +## Revoke a token + +- The owner revokes their own tokens; an admin with token oversight revokes anyone's, or all. +- The platform revokes all of a user's tokens when their account is deactivated, when they stop + being an admin, when an IAM change takes away their MCP access, when all of their sessions are + revoked (a forced logout, a two-factor reset, an email change), and when their password is + reset or changed. A revoked token stays revoked after the account or the access is restored. +- Each revocation commits together with the change that caused it, except after a password + change or reset, which is committed first: every token issued before it is refused from that + moment whether or not its revocation succeeds. If that revocation fails, the tokens are still + listed as active, and count towards the admin's token limit, until someone revokes them or they + expire. +- Turning the endpoint off in the platform config removes it at the next start. Revoking all + tokens, or removing MCP access from a role, takes effect immediately. + +## Troubleshooting + +| Status | Meaning | +| ------ | -------------------------------------------------------------------------------------------------------------------------------- | +| 400 | The body is not valid JSON, or it is a JSON-RPC batch, which the endpoint does not accept. | +| 401 | The token is missing, unknown, expired or revoked, or predates the admin's latest password change or reset. Generate a new one. | +| 403 | The admin lacks MCP access, the account is deactivated, a required two-factor enrolment is missing, or the origin is not listed. | +| 404 | The endpoint is off, the path is wrong, or the host is not in the allowed-host list. | +| 405 | The client asked for a server-sent event stream. Clients fall back to plain requests on their own. | +| 406 | The client does not accept both JSON and event-stream responses, which MCP Streamable HTTP requires. | +| 413 | The request body is larger than 1 MiB. | +| 415 | The request is not sent as JSON. | +| 429 | The token or the client address hit its limit. Retry after the time the response names. | +| 503 | The rate limiter is unreachable. Retry shortly. | diff --git a/docs/platform/architecture.md b/docs/platform/architecture.md index 2d346b0d4..ac059e1f0 100644 --- a/docs/platform/architecture.md +++ b/docs/platform/architecture.md @@ -96,7 +96,7 @@ Solid arrows are runtime/build dependencies; dashed arrows are **adapter seams** - **plugin-host** - `Plugin` + `ModuleRegistry`. In `register(ctx)` a plugin binds providers (`ctx.provide(token, factory)`), mounts routers (`ctx.routers.add(namespace, (c) => router)`), subscribes to events, and registers agent tools (`ctx.mcp.tool`) and agent action types (`ctx.actions.register`), which only the MCP kernel serves. Overlays add their own `pgTable` in their module's `schema/index.ts`. ADR-0002. - **Hono + oRPC** - oRPC defines routes and validates I/O against the Zod contract; its `OpenAPIHandler` is mounted on a Hono server with a live API reference. Dependency wiring is a small **functional composition `Container`** (`@openora/core/server`): typed-token factories, lazy + last-wins, no decorators. Downstream consumers call `createApp()` from `@openora/core/server` to boot their API entry. ADR-0009. -**Engine** (`@openora/core/server`) - the node runtime, all under one subpath: `db` (Drizzle client, drizzle-kit migrations, `DrizzleService`, the framework-free `@openora/core/server/orm` re-export), `auth` (better-auth + the shared `AdminGuard`), `kernel` (logger, typed `EventBus`, composition `Container`), `plugin-host` (the plugin loader), `mcp` (the MCP kernel behind `MCP_KERNEL`, the only gateway to agent tools and action types; `createApp()` binds it after every plugin has registered, so no overlay can replace it - ADR-0042), and `createApp()` - which is domain-agnostic (the consumer injects the PAM identity schema, ADR-0025/0026: single-tenant, no resolveTenant). +**Engine** (`@openora/core/server`) - the node runtime, all under one subpath: `db` (Drizzle client, drizzle-kit migrations, `DrizzleService`, the framework-free `@openora/core/server/orm` re-export), `auth` (better-auth + the shared `AdminGuard`), `kernel` (logger, typed `EventBus`, composition `Container`), `plugin-host` (the plugin loader), `mcp` (the MCP kernel behind `MCP_KERNEL`, the only gateway to agent tools and action types; `createApp()` binds it after every plugin has registered, so no overlay can replace it - ADR-0042 - plus the MCP HTTP transport that serves its read tools to admins' MCP clients when the platform config enables it - ADR-0043), and `createApp()` - which is domain-agnostic (the consumer injects the PAM identity schema, ADR-0025/0026: single-tenant, no resolveTenant). **Domains** (`packages/core/src//*`) - folded into the single `@openora/core` package and exposed as subpaths (`@openora/core/`), not as one package per domain. A domain may import the engine zones (`contracts`, `server`, `react`) and a sibling's read-only `/schema` subpath, but **never another domain's internals** - cross-domain communication goes through events, command ports, or shared contracts. Vendor adapter interfaces come from `@openora/core/contracts`. ADR-0025. diff --git a/docs/platform/glossary.md b/docs/platform/glossary.md index bda5b868e..7658a65ea 100644 --- a/docs/platform/glossary.md +++ b/docs/platform/glossary.md @@ -34,6 +34,8 @@ Shared vocabulary for this repo: the **roles** (who's who), the **platform/archi | **Agent tool** | A read a model makes during an agent run (eg a player's summary), declared by the module that owns the data. Registered in `plugin.ts`, reached only through the MCP kernel. | module `contract/`, `docs/catalog.json` | | **Action type** | A state change a model may only propose. An admin approves the proposal; an executor then applies it, and a replay while the effect still holds changes nothing. Core declares the action types; the runtime that runs agents and stores proposals is not part of core. | module `contract/`, `docs/catalog.json` | | **MCP kernel** | The only gateway to agent tools and action types: checks the IAM grant of the admin behind the run, validates input, returns only allow-listed output and declared error codes, and audits each tool call and execution. Not the MCP dev server. | `@openora/core/server` (`MCP_KERNEL`), ADR-0042 | +| **MCP transport** | The MCP Streamable HTTP endpoint that serves the kernel's read tools to an admin's own MCP client, authenticated by an MCP token. Off until an operator enables it, and only on the hosts it is bound to. Not the MCP dev server. | `@openora/core/server`, ADR-0043 | +| **MCP token** | The bearer credential an admin generates for their MCP client: bound to that admin, stored only as a hash, always expiring, revocable by its owner or an overseer, and audited on every call. | iam module, ADR-0043 | | **RGS** (Remote Gaming Server) | The game engine that runs rounds, applies RTP, and settles bets. Can be third-party (a provider) or the platform's own multiplayer/provably-fair foundation. | `gaming` module + `GameAdapter` | ## iGaming domain terms diff --git a/packages/core/package.json b/packages/core/package.json index f2c1ae118..6926d87f6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -765,6 +765,7 @@ "zod": "4.4.3" }, "devDependencies": { + "@modelcontextprotocol/sdk": "1.29.0", "@tanstack/react-query": "5.101.4", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", @@ -780,11 +781,15 @@ "vitest": "4.1.11" }, "peerDependencies": { + "@modelcontextprotocol/sdk": "1.29.0", "@tanstack/react-query": ">=5", "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + }, "@tanstack/react-query": { "optional": true }, diff --git a/packages/core/src/audit/__tests__/audit.service.int.test.ts b/packages/core/src/audit/__tests__/audit.service.int.test.ts index e3240d77b..1445f5a50 100644 --- a/packages/core/src/audit/__tests__/audit.service.int.test.ts +++ b/packages/core/src/audit/__tests__/audit.service.int.test.ts @@ -320,6 +320,145 @@ describe('AuditService.verifyChain() (real PG)', () => { }); }); +describe('AuditService.recordManyInTransaction() (real PG)', () => { + type Entry = Parameters[0]; + + const entry = (resourceId: string, over: Partial = {}): Entry => ({ + actorId: 'admin-1', + actorType: 'admin', + action: 'iam.mcp_token.revoked', + resourceType: 'mcp-token', + resourceId, + before: { revokedAt: null }, + after: { reason: 'revoked_all', nested: { b: 2, a: 1 } }, + ip: '203.0.113.7', + userAgent: 'backoffice/1.0', + ...over, + }); + + const appendMany = (entries: Entry[]) => + db.drizzle.db.transaction((tx) => makeService().recordManyInTransaction(tx, entries)); + + const appendOne = (input: Entry) => + db.drizzle.db.transaction((tx) => makeService().recordInTransaction(tx, input)); + + const chain = () => db.drizzle.db.select().from(auditLog).orderBy(auditLog.seq); + + const persistedHash = (row: typeof auditLog.$inferSelect) => + computeHash({ + id: row.id, + actorId: row.actorId ?? null, + actorType: row.actorType, + action: row.action, + resourceType: row.resourceType, + resourceId: row.resourceId ?? null, + before: row.before ?? null, + after: row.after ?? null, + result: row.result ?? null, + seq: row.seq, + createdAt: row.createdAt.toISOString(), + prevHash: row.prevHash ?? null, + }); + + const recordedFields = ({ + id: _id, + seq: _seq, + prevHash: _prevHash, + hash: _hash, + createdAt: _createdAt, + ...fields + }: typeof auditLog.$inferSelect) => fields; + + it('appends the entries in order on consecutive sequence numbers, each chained to the last', async () => { + const head = await appendOne(entry('head')); + + await appendMany([entry('first'), entry('second'), entry('third')]); + + const [first, ...rows] = await chain(); + expect(first?.id).toBe(head.id); + expect(rows.map((row) => row.resourceId)).toEqual(['first', 'second', 'third']); + expect(rows.map((row) => row.seq)).toEqual([head.seq + 1, head.seq + 2, head.seq + 3]); + expect(rows.map((row) => row.prevHash)).toEqual([head.hash, rows[0]?.hash, rows[1]?.hash]); + }); + + it('stores each entry as a single append of it would, hashed over the stored row', async () => { + const entries = [entry('a'), entry('b', { actorId: null, actorType: 'system', ip: null })]; + for (const input of entries) { + await appendOne(input); + } + + await appendMany(entries); + + const rows = await chain(); + const singles = rows.slice(0, entries.length); + const bulk = rows.slice(entries.length); + expect(bulk.map(recordedFields)).toEqual(singles.map(recordedFields)); + for (const row of rows) { + expect(row.hash).toBe(persistedHash(row)); + } + }); + + it('keeps the chain verifiable with single and bulk appends interleaved', async () => { + await appendOne(entry('one')); + await appendMany([entry('two'), entry('three')]); + await appendOne(entry('four')); + await appendMany([entry('five')]); + + const rows = await chain(); + expect(rows.map((row) => row.resourceId)).toEqual(['one', 'two', 'three', 'four', 'five']); + expect(await makeService().verifyChain()).toEqual({ valid: true }); + }); + + it('serializes with concurrent single appends, so the chain stays one line', async () => { + await Promise.all([ + appendMany([entry('bulk-1'), entry('bulk-2'), entry('bulk-3')]), + appendOne(entry('single-1')), + appendMany([entry('bulk-4'), entry('bulk-5')]), + appendOne(entry('single-2')), + ]); + + expect(await chain()).toHaveLength(7); + expect(await makeService().verifyChain()).toEqual({ valid: true }); + }); + + it('appends more entries than one insert statement carries', async () => { + const entries = Array.from({ length: 2_001 }, (_, index) => entry(`row-${index}`)); + + const inserted = await appendMany(entries); + + const rows = await chain(); + expect(inserted).toHaveLength(entries.length); + expect(rows.map((row) => row.resourceId)).toEqual(entries.map((input) => input.resourceId)); + expect(new Set(rows.map((row) => row.seq)).size).toBe(entries.length); + expect(await makeService().verifyChain()).toEqual({ valid: true }); + }); + + it('writes nothing for an empty list, and the next append chains to the old head', async () => { + const head = await appendOne(entry('head')); + + expect(await appendMany([])).toEqual([]); + const next = await appendOne(entry('next')); + + expect(await chain()).toHaveLength(2); + expect(next.prevHash).toBe(head.hash); + expect(next.seq).toBe(head.seq + 1); + }); + + it("leaves no row behind when the caller's transaction rolls back", async () => { + await appendOne(entry('kept')); + + await expect( + db.drizzle.db.transaction(async (tx) => { + await makeService().recordManyInTransaction(tx, [entry('lost-1'), entry('lost-2')]); + throw new Error('caller rolled back'); + }), + ).rejects.toThrow('caller rolled back'); + + expect((await chain()).map((row) => row.resourceId)).toEqual(['kept']); + expect(await makeService().verifyChain()).toEqual({ valid: true }); + }); +}); + describe('AuditService.list() (real PG)', () => { async function seed(inputs: Parameters[0][]) { const svc = makeService(); diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index 011150061..f1429839d 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -1444,6 +1444,8 @@ export default { record: (entry) => svc.record(entry).then(() => undefined), recordInTransaction: (tx, entry) => svc.recordInTransaction(tx, entry).then(() => undefined), + recordManyInTransaction: (tx, entries) => + svc.recordManyInTransaction(tx, entries).then(() => undefined), }; }); diff --git a/packages/core/src/audit/service/audit.service.ts b/packages/core/src/audit/service/audit.service.ts index 0168f2a53..87be013e4 100644 --- a/packages/core/src/audit/service/audit.service.ts +++ b/packages/core/src/audit/service/audit.service.ts @@ -175,6 +175,58 @@ export type RecordInput = Parameters[0] & { result?: string | null; }; +// Postgres takes at most 65,535 bind parameters per statement; at 16 columns a row, this many +// rows stay well under it. +const MAX_ROWS_PER_INSERT = 1_000; + +function latestChainHash(txn: DrizzleTx) { + return txn + .select({ hash: auditLog.hash }) + .from(auditLog) + .orderBy(desc(auditLog.seq)) + .limit(1) + .then(([latest]) => latest?.hash ?? null); +} + +/** The row `input` appends at `seq` after `prevHash`, hashed over what is persisted. */ +function chainedRow( + input: RecordInput, + { seq, prevHash }: { seq: number; prevHash: string | null }, +) { + const id = randomUUID(); + const createdAt = new Date(); + const hash = computeHash({ + id, + actorId: input.actorId ?? null, + actorType: input.actorType, + action: input.action, + resourceType: input.resourceType, + resourceId: input.resourceId ?? null, + before: input.before ?? null, + after: input.after ?? null, + result: input.result ?? null, + seq, + createdAt: createdAt.toISOString(), + prevHash, + }); + return { ...input, id, seq, prevHash, createdAt, hash }; +} + +function chainedRows( + inputs: readonly RecordInput[], + { seqs, headHash }: { seqs: readonly number[]; headHash: string | null }, +) { + const rows: ReturnType[] = []; + for (const [index, input] of inputs.entries()) { + const seq = seqs[index]; + if (seq === undefined) { + throw new Error('audit_log: fewer sequence values were reserved than rows to append'); + } + rows.push(chainedRow(input, { seq, prevHash: rows.at(-1)?.hash ?? headHash })); + } + return rows; +} + /** * Appends one row to the hash chain inside the caller's transaction. Exported for a writer that * runs outside the container - a deploy step - so it keeps the same chain protocol as @@ -188,39 +240,16 @@ export async function recordAuditInTransaction( input: RecordInput, ): Promise { const row = await withAdvisoryXactLock(txn, 'audit_log', async () => { - const [latest] = await txn - .select({ hash: auditLog.hash }) - .from(auditLog) - .orderBy(desc(auditLog.seq)) - .limit(1); - const prevHash = latest?.hash ?? null; + const prevHash = await latestChainHash(txn); const seqResult = await txn.execute<{ seq: string | number }>( sql`SELECT nextval(pg_get_serial_sequence('audit_log', 'seq')) AS seq`, ); const seq = +(seqResult.rows.at(0)?.seq ?? 0); - const id = randomUUID(); - const createdAt = new Date(); - - const hash = computeHash({ - id, - actorId: input.actorId ?? null, - actorType: input.actorType, - action: input.action, - resourceType: input.resourceType, - resourceId: input.resourceId ?? null, - before: input.before ?? null, - after: input.after ?? null, - result: input.result ?? null, - seq, - createdAt: createdAt.toISOString(), - prevHash, - }); - const [inserted] = await txn .insert(auditLog) - .values({ ...input, id, seq, prevHash, createdAt, hash }) + .values(chainedRow(input, { seq, prevHash })) .returning(); return inserted; @@ -228,6 +257,40 @@ export async function recordAuditInTransaction( return row; } +/** + * Appends `inputs` to the hash chain in order, inside the caller's transaction and under the + * lock `recordAuditInTransaction` takes, with one read of the chain head and one sequence + * reservation for the lot. The rows are those that many `recordAuditInTransaction` calls in the + * same order would append, apart from the id and timestamp each row is given. An empty list + * writes nothing. + */ +async function recordManyAuditInTransaction( + txn: DrizzleTx, + inputs: readonly RecordInput[], +): Promise { + if (inputs.length === 0) { + return []; + } + return withAdvisoryXactLock(txn, 'audit_log', async () => { + const headHash = await latestChainHash(txn); + const { rows: reserved } = await txn.execute<{ seq: string | number }>( + sql`SELECT nextval(pg_get_serial_sequence('audit_log', 'seq')) AS seq + FROM generate_series(1, ${inputs.length}) ORDER BY seq`, + ); + const rows = chainedRows(inputs, { seqs: reserved.map((row) => Number(row.seq)), headHash }); + const inserted: AuditLog[] = []; + for (let start = 0; start < rows.length; start += MAX_ROWS_PER_INSERT) { + inserted.push( + ...(await txn + .insert(auditLog) + .values(rows.slice(start, start + MAX_ROWS_PER_INSERT)) + .returning()), + ); + } + return inserted; + }); +} + export class AuditService { constructor( private readonly drizzle: DrizzleService, @@ -247,6 +310,10 @@ export class AuditService { return recordAuditInTransaction(tx as DrizzleTx, input); } + recordManyInTransaction(tx: unknown, inputs: readonly RecordInput[]): Promise { + return recordManyAuditInTransaction(tx as DrizzleTx, inputs); + } + async list(filters: AuditListFilters) { const db = this.drizzle.db; const { page, limit } = filters; diff --git a/packages/core/src/contracts/adapters/audit.ts b/packages/core/src/contracts/adapters/audit.ts index 6c540901e..5a327d191 100644 --- a/packages/core/src/contracts/adapters/audit.ts +++ b/packages/core/src/contracts/adapters/audit.ts @@ -23,9 +23,6 @@ export type DirectAuditAction = | 'mail.regulatory_delivery.failed' | 'chat.mute.expired' | 'chat.platform_ban.expired' - | 'chat.cooldown.created' - | 'chat.cooldown.lifted' - | 'chat.cooldown.expired' | 'chat.room.rule.created' | 'chat.room.rule.updated' | 'chat.room.rule.deleted' @@ -76,7 +73,12 @@ export type DirectAuditAction = | 'mcp.tool.failed' | 'mcp.action.executed' | 'mcp.action.failed' - | 'wallet.withdrawal.held'; + | 'iam.mcp_token.created' + | 'iam.mcp_token.revoked' + | 'wallet.withdrawal.held' + | 'chat.cooldown.created' + | 'chat.cooldown.lifted' + | 'chat.cooldown.expired'; /** * Every value the audit `action` column legitimately holds: a cross-module domain @@ -86,32 +88,26 @@ export type DirectAuditAction = */ export type AuditAction = DomainEventName | DirectAuditAction | (string & {}); +type AuditEntry = { + actorId?: string | null; + actorType: 'player' | 'admin' | 'system'; + action: AuditAction; + resourceType: string; + resourceId?: string | null; + before?: Record | null; + after?: Record | null; + correlationId?: string | null; +} & Partial; + export type AuditWritePort = { - record( - entry: { - actorId?: string | null; - actorType: 'player' | 'admin' | 'system'; - action: AuditAction; - resourceType: string; - resourceId?: string | null; - before?: Record | null; - after?: Record | null; - correlationId?: string | null; - } & Partial, - ): Promise; - recordInTransaction( - tx: unknown, - entry: { - actorId?: string | null; - actorType: 'player' | 'admin' | 'system'; - action: AuditAction; - resourceType: string; - resourceId?: string | null; - before?: Record | null; - after?: Record | null; - correlationId?: string | null; - } & Partial, - ): Promise; + record(entry: AuditEntry): Promise; + recordInTransaction(tx: unknown, entry: AuditEntry): Promise; + /** + * Appends `entries` in order inside `tx`, reading the chain head and reserving sequence numbers + * once for the lot rather than once per row. Each row is the one `recordInTransaction` would + * write for that entry, apart from its id and timestamp; an empty list writes nothing. + */ + recordManyInTransaction(tx: unknown, entries: readonly AuditEntry[]): Promise; }; export const AUDIT_WRITER: SealedToken = diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index f5d4f7032..6d3d7c768 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -469,3 +469,7 @@ export { triggerCatalog, MCP_KERNEL, } from './mcp.js'; +export type { McpTokenAuthentication, McpTokenAuthenticator } from './mcp-token-authenticator.js'; +export { MCP_TOKEN_AUTHENTICATOR } from './mcp-token-authenticator.js'; +export type { McpTokenAutomaticRevokeReason, McpTokenRevocation } from './mcp-token-revocation.js'; +export { MCP_TOKEN_REVOCATION } from './mcp-token-revocation.js'; diff --git a/packages/core/src/contracts/adapters/mcp-token-authenticator.ts b/packages/core/src/contracts/adapters/mcp-token-authenticator.ts new file mode 100644 index 000000000..6d6cfd091 --- /dev/null +++ b/packages/core/src/contracts/adapters/mcp-token-authenticator.ts @@ -0,0 +1,28 @@ +import { createToken, type Token } from './token.js'; +import type { Uuid } from '../schemas/common.js'; +import type { User } from '../schemas/identity.js'; + +/** + * Answers for the token and its admin's credentials: a token issued before its admin's latest + * password change or reset is refused even while its revocation is unrecorded. Whether its admin + * may still use MCP is AdminGuard's call, made on every request, so a disabled account is + * refused even before its tokens are revoked. + */ +export type McpTokenAuthentication = + | { ok: true; tokenId: Uuid; adminId: User['id'] } + | { ok: false; reason: 'unknown' } + | { + ok: false; + reason: 'expired' | 'revoked' | 'credentials_changed'; + tokenId: Uuid; + adminId: User['id']; + }; + +export type McpTokenAuthenticator = { + authenticate(bearer: string): Promise; + /** Counts one tool call against the token and stamps its last use. */ + recordCall(tokenId: Uuid): Promise; +}; + +export const MCP_TOKEN_AUTHENTICATOR: Token = + createToken('MCP_TOKEN_AUTHENTICATOR'); diff --git a/packages/core/src/contracts/adapters/mcp-token-revocation.ts b/packages/core/src/contracts/adapters/mcp-token-revocation.ts new file mode 100644 index 000000000..1d0d04b89 --- /dev/null +++ b/packages/core/src/contracts/adapters/mcp-token-revocation.ts @@ -0,0 +1,28 @@ +import { createToken, type Token } from './token.js'; +import type { ClientMeta } from '../schemas/common.js'; +import type { User } from '../schemas/identity.js'; +import type { McpTokenRevokeReason } from '../schemas/iam.js'; + +export type McpTokenAutomaticRevokeReason = Extract< + McpTokenRevokeReason, + 'admin_disabled' | 'admin_role_removed' | 'sessions_revoked' +>; + +/** + * Revokes every active MCP token a user holds when their standing changes. Revocation is + * persisted, so restoring the account later does not bring the old tokens back. Pass the + * transaction that changes the standing as `tx`, so both commit or roll back together; it is + * typed `unknown` because this contracts-zone port cannot import drizzle's transaction type. + */ +export type McpTokenRevocation = { + revokeAllForUser( + input: { + userId: User['id']; + reason: McpTokenAutomaticRevokeReason; + actorId: User['id'] | null; + } & Partial, + tx?: unknown, + ): Promise<{ revoked: number }>; +}; + +export const MCP_TOKEN_REVOCATION: Token = createToken('MCP_TOKEN_REVOCATION'); diff --git a/packages/core/src/contracts/adapters/mcp.ts b/packages/core/src/contracts/adapters/mcp.ts index b0d4be273..6f1b58130 100644 --- a/packages/core/src/contracts/adapters/mcp.ts +++ b/packages/core/src/contracts/adapters/mcp.ts @@ -1,5 +1,5 @@ import * as z from 'zod'; -import { UuidSchema } from '../schemas/common.js'; +import { ClientMetaSchema, UuidSchema } from '../schemas/common.js'; import type { AdminActionOf, AdminResource } from '../schemas/iam.js'; import { DOMAIN_EVENT_CATALOG, @@ -94,6 +94,10 @@ export const RunContextSchema = z.object({ playerPseudonym: z.string().min(1).max(128).optional(), catalogVersion: z.string().min(1).max(128), correlationId: z.string().min(1).max(128), + /** The calling client's address and agent, recorded on the call's audit record. */ + clientMeta: ClientMetaSchema.optional(), + /** Drops the output keys a tool marks as personal before the result is hashed and returned. */ + dropPersonal: z.boolean().optional(), }); export type RunContext = z.infer; diff --git a/packages/core/src/contracts/adapters/rate-limit.ts b/packages/core/src/contracts/adapters/rate-limit.ts index ac9312603..2dbf23666 100644 --- a/packages/core/src/contracts/adapters/rate-limit.ts +++ b/packages/core/src/contracts/adapters/rate-limit.ts @@ -1,10 +1,9 @@ /** * Rate-limiter seam. Abuse-prone routes (auth flows, money mutations) consume - * from this adapter so the throttling backend is swappable: the default binding - * is an in-process fixed-window limiter (zero deps - good for `pnpm dev`, seed - * and tests), process-local so it does NOT coordinate across replicas. Set - * REDIS_URL to bind the shipped Redis reference adapter (distributed fixed-window) - * with zero consumer code; rebind RATE_LIMITER via an overlay for any other backend. + * from this adapter so the throttling backend is swappable. It is a required + * durable seam with no in-process fallback: REDIS_URL binds the shipped Redis + * adapter (distributed fixed-window), an overlay can rebind RATE_LIMITER to any + * other backend, and boot refuses to start when neither binds it. */ import { createToken, type Token } from './token.js'; @@ -31,6 +30,10 @@ export const RATE_LIMIT_KEYS = { REPORT_ACCESS_DENIED: 'report-access-denied', GEO_CHECK_IP: 'geo-check-ip', PROMO_PUBLIC_OFFERS_IP: 'promo-public-offers-ip', + MCP_TOKEN_MINUTE: 'mcp-token-min', + MCP_TOKEN_DAY: 'mcp-token-day', + MCP_IP_MINUTE: 'mcp-ip-min', + MCP_TOKEN_CREATE: 'mcp-token-create', } as const; export type RateLimitKeyPrefix = (typeof RATE_LIMIT_KEYS)[keyof typeof RATE_LIMIT_KEYS]; @@ -49,7 +52,6 @@ export type RateLimitOptions = { * What to do when the backing store is unreachable: 'allow' keeps availability * (throttling pauses during an outage); 'deny' fails closed for keys where an * unthrottled window is worse than a 429 (credential guessing). Default 'allow'. - * The in-process default is never unavailable, so it ignores this. */ onUnavailable?: 'allow' | 'deny'; }; @@ -58,6 +60,8 @@ export type RateLimitResult = { allowed: boolean; /** Milliseconds until the window resets. 0 when allowed. */ retryAfterMs: number; + /** The store could not be reached: `allowed` then follows `onUnavailable`, not the count. */ + unavailable?: boolean; }; export type RateLimiterAdapter = { diff --git a/packages/core/src/contracts/schemas/__tests__/agents-config.test.ts b/packages/core/src/contracts/schemas/__tests__/agents-config.test.ts index c7eca6011..26c8ef82a 100644 --- a/packages/core/src/contracts/schemas/__tests__/agents-config.test.ts +++ b/packages/core/src/contracts/schemas/__tests__/agents-config.test.ts @@ -82,6 +82,16 @@ describe('PlatformConfig.agents', () => { retention: { runDays: 30, proposalDays: 365 }, modelGateways: ['openrouter'], models: [], + mcp: { + enabled: false, + path: '/mcp', + personalFields: 'drop', + tokenTtlDays: { default: 30, max: 90 }, + rateLimit: { perMinute: 60, perDay: 2_000, perIpPerMinute: 300 }, + tokenIssuance: { maxActivePerAdmin: 5, perHour: 10 }, + allowedOrigins: [], + allowedHosts: [], + }, }); }); @@ -93,3 +103,38 @@ describe('PlatformConfig.agents', () => { ).toThrow(/agents\.models\.0\.id: must be a \/\/ id/); }); }); + +describe('AgentsConfigSchema mcp transport', () => { + const mcpIssuePaths = (mcp: unknown) => issuePaths({ mcp }); + + it('accepts a bound transport', () => { + expect( + mcpIssuePaths({ + enabled: true, + path: '/agents/mcp', + allowedHosts: ['backoffice.example.com'], + allowedOrigins: ['https://backoffice.example.com'], + }), + ).toEqual([]); + }); + + it('lowercases an allowed host', () => { + const parsed = AgentsConfigSchema.parse({ mcp: { allowedHosts: ['BackOffice.Example.com'] } }); + expect(parsed.mcp.allowedHosts).toEqual(['backoffice.example.com']); + }); + + it.each([ + [{ tokenTtlDays: { default: 91 } }, 'mcp.tokenTtlDays.default'], + [{ tokenTtlDays: { max: 366 } }, 'mcp.tokenTtlDays.max'], + [{ rateLimit: { perMinute: 3_000 } }, 'mcp.rateLimit.perMinute'], + [{ path: 'mcp' }, 'mcp.path'], + [{ path: '/MCP' }, 'mcp.path'], + [{ allowedOrigins: ['https://backoffice.example.com/'] }, 'mcp.allowedOrigins.0'], + [{ allowedHosts: ['backoffice.example.com:8443'] }, 'mcp.allowedHosts.0'], + [{ personalFields: 'mask' }, 'mcp.personalFields'], + [{ enabled: true }, 'mcp.allowedHosts'], + [{ tokenIssuance: { maxActivePerAdmin: 51 } }, 'mcp.tokenIssuance.maxActivePerAdmin'], + ])('rejects %j at %s', (mcp, path) => { + expect(mcpIssuePaths(mcp)).toEqual([path]); + }); +}); diff --git a/packages/core/src/contracts/schemas/agents.ts b/packages/core/src/contracts/schemas/agents.ts index 2e8fd409c..ab5b10514 100644 --- a/packages/core/src/contracts/schemas/agents.ts +++ b/packages/core/src/contracts/schemas/agents.ts @@ -1,4 +1,5 @@ import * as z from 'zod'; +import { HostAllowlistEntrySchema } from './host-allowlist.js'; /** * `//`. The gateway prefix is mandatory: an agent SDK can route a bare @@ -42,6 +43,97 @@ export const AgentModelSchema = z }) .strict(); +export const MCP_PERSONAL_FIELD_MODES = ['drop', 'include'] as const; +export const McpPersonalFieldModeSchema = z.enum(MCP_PERSONAL_FIELD_MODES); +export type McpPersonalFieldMode = z.infer; + +export const MCP_TOKEN_TTL_CEILING_DAYS = 365; + +const McpTransportPathSchema = z + .string() + .max(64) + .regex(/^\/[a-z0-9]+(?:[-_/][a-z0-9]+)*$/, 'must be an absolute lowercase path such as /mcp'); + +const McpAllowedOriginSchema = z + .string() + .max(256) + .refine( + (value) => { + try { + return new URL(value).origin === value; + } catch { + return false; + } + }, + { message: 'must be an origin such as https://backoffice.example.com' }, + ); + +export const McpTransportConfigSchema = z + .object({ + /** Off until an operator turns it on: the route is not mounted at all. */ + enabled: z.boolean().default(false), + path: McpTransportPathSchema.default('/mcp'), + /** Whether output keys a tool marks as personal reach the client and, through it, a model. */ + personalFields: McpPersonalFieldModeSchema.default('drop'), + tokenTtlDays: z + .object({ + default: z.number().int().min(1).max(MCP_TOKEN_TTL_CEILING_DAYS).default(30), + max: z.number().int().min(1).max(MCP_TOKEN_TTL_CEILING_DAYS).default(90), + }) + .strict() + .prefault({}), + /** + * Per token, every authenticated request counting, a tool call or not; and per client + * address before any token is looked up. + */ + rateLimit: z + .object({ + perMinute: z.number().int().min(1).max(10_000).default(60), + perDay: z.number().int().min(1).max(1_000_000).default(2_000), + perIpPerMinute: z.number().int().min(1).max(100_000).default(300), + }) + .strict() + .prefault({}), + /** Per admin: how many tokens may be active at once, and how many may be issued an hour. */ + tokenIssuance: z + .object({ + maxActivePerAdmin: z.number().int().min(1).max(50).default(5), + perHour: z.number().int().min(1).max(1_000).default(10), + }) + .strict() + .prefault({}), + /** Empty refuses every request carrying an Origin header; desktop clients send none. */ + allowedOrigins: z.array(McpAllowedOriginSchema).max(20).default([]), + /** Exact hostnames the route answers on. Required while enabled, so it never reaches the player domain. */ + allowedHosts: z.array(HostAllowlistEntrySchema).max(20).default([]), + }) + .strict() + .superRefine((config, ctx) => { + if (config.enabled && config.allowedHosts.length === 0) { + ctx.addIssue({ + code: 'custom', + message: 'list the backoffice host the transport answers on before enabling it', + path: ['allowedHosts'], + }); + } + if (config.tokenTtlDays.default > config.tokenTtlDays.max) { + ctx.addIssue({ + code: 'custom', + message: 'tokenTtlDays.default must not exceed tokenTtlDays.max', + path: ['tokenTtlDays', 'default'], + }); + } + if (config.rateLimit.perMinute > config.rateLimit.perDay) { + ctx.addIssue({ + code: 'custom', + message: 'rateLimit.perMinute must not exceed rateLimit.perDay', + path: ['rateLimit', 'perMinute'], + }); + } + }); + +export type McpTransportConfig = z.infer; + export const AgentsConfigSchema = z .object({ limits: z @@ -67,6 +159,7 @@ export const AgentsConfigSchema = z .max(10) .default([...DEFAULT_AGENT_MODEL_GATEWAYS]), models: z.array(AgentModelSchema).max(50).default([]), + mcp: McpTransportConfigSchema.prefault({}), }) .strict() .superRefine((config, ctx) => { diff --git a/packages/core/src/contracts/schemas/host-allowlist.ts b/packages/core/src/contracts/schemas/host-allowlist.ts new file mode 100644 index 000000000..26ebb8d1a --- /dev/null +++ b/packages/core/src/contracts/schemas/host-allowlist.ts @@ -0,0 +1,30 @@ +import * as z from 'zod'; + +export const HostAllowlistEntrySchema = z + .string() + .trim() + .min(1) + .refine( + (hostname) => { + try { + const url = new URL(`https://${hostname}`); + return ( + url.hostname === hostname.toLowerCase() && + url.pathname === '/' && + url.port === '' && + url.username === '' && + url.password === '' && + url.search === '' && + url.hash === '' + ); + } catch { + return false; + } + }, + { message: 'must be a hostname without a protocol, port, credentials, or path' }, + ) + .transform((hostname) => hostname.toLowerCase()); + +export function isAllowedHost(hostname: string, allowedHosts: readonly string[]): boolean { + return allowedHosts.some((allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`)); +} diff --git a/packages/core/src/contracts/schemas/iam.ts b/packages/core/src/contracts/schemas/iam.ts index 3fdee9253..7628fef8d 100644 --- a/packages/core/src/contracts/schemas/iam.ts +++ b/packages/core/src/contracts/schemas/iam.ts @@ -50,7 +50,19 @@ export const adminStatement = { agent: ['view', 'create', 'update', 'publish', 'run'] as const, 'agent-proposal': ['view', 'approve', 'reject'] as const, 'agent-config': ['view', 'update'] as const, + 'mcp-access': ['use'] as const, + 'mcp-token': ['view', 'revoke'] as const, } as const; export type AdminResource = keyof typeof adminStatement; export type AdminActionOf = (typeof adminStatement)[R][number]; + +export const MCP_TOKEN_REVOKE_REASONS = [ + 'manual', + 'admin_disabled', + 'admin_role_removed', + 'sessions_revoked', + 'revoked_all', +] as const; +export const McpTokenRevokeReasonSchema = z.enum(MCP_TOKEN_REVOKE_REASONS); +export type McpTokenRevokeReason = z.infer; diff --git a/packages/core/src/contracts/schemas/index.ts b/packages/core/src/contracts/schemas/index.ts index 097ee1eb4..6fa72e06c 100644 --- a/packages/core/src/contracts/schemas/index.ts +++ b/packages/core/src/contracts/schemas/index.ts @@ -11,6 +11,7 @@ export * from './lobby.js'; export * from './igaming-config.js'; export * from './tag.js'; export * from './platform-config.js'; +export * from './host-allowlist.js'; export * from './events.js'; export * from './reporting.js'; export * from './chat-command-metadata.js'; diff --git a/packages/core/src/contracts/schemas/platform-config.ts b/packages/core/src/contracts/schemas/platform-config.ts index 8d3c53efe..61c5b29b9 100644 --- a/packages/core/src/contracts/schemas/platform-config.ts +++ b/packages/core/src/contracts/schemas/platform-config.ts @@ -7,6 +7,7 @@ import { } from './igaming-config.js'; import { CurrencyTickerInputSchema, MoneyAmountSchema } from './common.js'; import { AgentsConfigSchema } from './agents.js'; +import { HostAllowlistEntrySchema } from './host-allowlist.js'; import { createToken } from '../adapters/token.js'; /** @@ -298,35 +299,6 @@ export const PromoConfigSchema = z export type PromoConfig = z.infer; -export const HostAllowlistEntrySchema = z - .string() - .trim() - .min(1) - .refine( - (hostname) => { - try { - const url = new URL(`https://${hostname}`); - return ( - url.hostname === hostname.toLowerCase() && - url.pathname === '/' && - url.port === '' && - url.username === '' && - url.password === '' && - url.search === '' && - url.hash === '' - ); - } catch { - return false; - } - }, - { message: 'must be a hostname without a protocol, port, credentials, or path' }, - ) - .transform((hostname) => hostname.toLowerCase()); - -export function isAllowedHost(hostname: string, allowedHosts: readonly string[]): boolean { - return allowedHosts.some((allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`)); -} - export const ChatConfigSchema = z .object({ /** Hostnames a chat message attachment may be served from. Empty = attachments disabled. */ diff --git a/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts b/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts index f8f679ba1..0fff399c0 100644 --- a/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts +++ b/packages/core/src/engagement/chat/__tests__/chat-moderation-expiry.int.test.ts @@ -81,6 +81,8 @@ beforeAll(async () => { audit = { record: (entry) => svc.record(entry).then(() => undefined), recordInTransaction: (tx, entry) => svc.recordInTransaction(tx, entry).then(() => undefined), + recordManyInTransaction: (tx, entries) => + svc.recordManyInTransaction(tx, entries).then(() => undefined), }; }); diff --git a/packages/core/src/iam/__tests__/default-admin-roles.test.ts b/packages/core/src/iam/__tests__/default-admin-roles.test.ts new file mode 100644 index 000000000..8c75e4afa --- /dev/null +++ b/packages/core/src/iam/__tests__/default-admin-roles.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { levelToActions, roles, statement } from '@openora/core/server'; +import { DEFAULT_ADMIN_ROLES } from '../seed/data/default-admin-roles.js'; + +const predefined = (key: string) => DEFAULT_ADMIN_ROLES.find((role) => role.key === key); + +describe('DEFAULT_ADMIN_ROLES', () => { + it('gives the predefined admin role read-write MCP access and token oversight', () => { + expect(predefined('admin')?.matrix).toMatchObject({ + 'mcp-access': 'read_write', + 'mcp-token': 'read_write', + }); + }); + + it.each(['mcp-access', 'mcp-token'] as const)( + 'grants the predefined admin role every %s action the static admin role holds', + (resource) => { + const level = predefined('admin')?.matrix[resource] ?? 'no_access'; + + expect(levelToActions(resource, level)).toEqual(statement[resource]); + expect(roles.admin.authorize({ [resource]: [...statement[resource]] }).success).toBe(true); + }, + ); + + it('keeps the super-admin role flagged and covering every resource', () => { + const superAdmin = predefined('super-admin'); + + expect(superAdmin).toMatchObject({ isSuperAdmin: true, isSystem: true }); + expect(Object.keys(superAdmin?.matrix ?? {})).toEqual(Object.keys(statement)); + }); + + it('grants MCP access to no other predefined role', () => { + const withMcpAccess = DEFAULT_ADMIN_ROLES.filter( + (role) => !role.isSuperAdmin && role.matrix['mcp-access'] !== undefined, + ); + + expect(withMcpAccess.map((role) => role.key)).toEqual(['admin']); + }); +}); diff --git a/packages/core/src/iam/__tests__/iam.service.int.test.ts b/packages/core/src/iam/__tests__/iam.service.int.test.ts index 335cc6427..305d201fc 100644 --- a/packages/core/src/iam/__tests__/iam.service.int.test.ts +++ b/packages/core/src/iam/__tests__/iam.service.int.test.ts @@ -1,7 +1,14 @@ import { randomUUID } from 'node:crypto'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; import { sql, eq } from 'drizzle-orm'; -import { makeIdentityReader, mock, NO_CLIENT_META, makeEventBus } from '../../testing/mock.js'; +import { + makeAuditWriter, + makeIdentityReader, + makeRateLimiter, + mock, + NO_CLIENT_META, + makeEventBus, +} from '../../testing/mock.js'; import { statement, findOneOrThrow, @@ -9,11 +16,13 @@ import { RedisRateLimiter, type ResourceName, } from '@openora/core/server'; -import type { - MailDispatchPort, - RateLimiterAdapter, - RateLimitKey, - SessionCommands, +import { + McpTransportConfigSchema, + type MailDispatchPort, + type RateLimiterAdapter, + type RateLimitKey, + type SessionCommands, + type User, } from '@openora/core/contracts'; import { createTestDb, @@ -30,6 +39,7 @@ import { adminRolePermission, adminRoleAssignment, adminInvitation, + mcpToken, } from '../schema/index.js'; import { IamService, @@ -43,6 +53,8 @@ import { AdminUserNotFoundError, NotAnAdminUserError, } from '../service/iam.service.js'; +import { McpTokenService } from '../service/mcp-token.service.js'; +import { seedRoles } from '../seed/seed-default-roles.js'; let db: TestDb; let redis: TestRedis; @@ -53,6 +65,19 @@ const makeMailDispatch = () => toAddress: vi.fn().mockResolvedValue(undefined), }); const identityReader = makeIdentityReader(); +const DAY_MS = 24 * 60 * 60 * 1000; + +function makeMcpTokens() { + const audit = makeAuditWriter(); + const mcpTokens = new McpTokenService({ + drizzle: db.drizzle, + audit, + events: makeEventBus(), + rateLimiter: makeRateLimiter(), + config: McpTransportConfigSchema.parse({}), + }); + return { mcpTokens, audit }; +} function makeIamService( drizzle: typeof db.drizzle, @@ -61,14 +86,27 @@ function makeIamService( sessionCommands?: SessionCommands, rateLimiter?: RateLimiterAdapter, ) { - return new IamService( + return new IamService({ drizzle, events, mailDispatch, identityReader, + mcpTokens: makeMcpTokens().mcpTokens, sessionCommands, rateLimiter, - ); + }); +} + +function makeIamServiceWithTokens() { + const { mcpTokens, audit } = makeMcpTokens(); + const svc = new IamService({ + drizzle: db.drizzle, + events: makeEventBus(), + mailDispatch: makeMailDispatch(), + identityReader, + mcpTokens, + }); + return { svc, audit }; } // A staff user holds nothing without an assignment - seedSuperCaller() gives this one a real one. @@ -125,7 +163,7 @@ afterAll(async () => { beforeEach(async () => { await db.drizzle.db.execute( - sql`TRUNCATE ${adminRole}, ${adminRolePermission}, ${adminRoleAssignment}, ${adminInvitation}, ${user} RESTART IDENTITY CASCADE`, + sql`TRUNCATE ${adminRole}, ${adminRolePermission}, ${adminRoleAssignment}, ${adminInvitation}, ${mcpToken}, ${user} RESTART IDENTITY CASCADE`, ); await redis.flush(); }); @@ -836,3 +874,269 @@ describe('IamService.forceLogout', () => { expect(sessionCommands.revokeAll).toHaveBeenCalledWith(targetUserId, ADMIN_CALLER.userId); }); }); + +describe('IamService revokes MCP tokens when an admin loses MCP access (real PG)', () => { + // Reuses ADMIN_CALLER so seedSuperCaller() gives it the assignment these + // super-admin-only calls now need (a resolver-bound caller holds nothing without one). + const CALLER = { + userId: ADMIN_CALLER.userId, + role: 'admin', + ip: '203.0.113.7', + userAgent: 'backoffice/1.0', + }; + + beforeEach(seedSuperCaller); + + const seedAccount = async (role = 'admin') => (await seedUser(db, { name: 'A', role })).id; + + async function seedRoleWith(grants: Partial>) { + const role = await seedRole(); + for (const [resource, level] of Object.entries(grants)) { + await seedPermission(role.id, resource, level); + } + return role.id; + } + + async function seedActiveToken(adminUserId: User['id']) { + const [row] = await db.drizzle.db + .insert(mcpToken) + .values({ + adminUserId, + label: 'Laptop', + tokenHash: randomUUID(), + tokenPrefix: 'ora_mcp_test', + expiresAt: new Date(Date.now() + DAY_MS), + }) + .returning({ id: mcpToken.id }); + if (!row) { + throw new Error('seedActiveToken: insert returned no row'); + } + return row.id; + } + + async function revocationOf(tokenId: string) { + const [row] = await db.drizzle.db + .select({ + revokedAt: mcpToken.revokedAt, + revokedBy: mcpToken.revokedBy, + revokeReason: mcpToken.revokeReason, + }) + .from(mcpToken) + .where(eq(mcpToken.id, tokenId)); + return row; + } + + const LOST = { + revokedAt: expect.any(Date), + revokedBy: CALLER.userId, + revokeReason: 'admin_role_removed', + }; + const KEPT = { revokedAt: null, revokedBy: null, revokeReason: null }; + + describe('setRolePermissions', () => { + it("revokes, in the caller's name, a holder whose only role loses mcp-access", async () => { + const { svc, audit } = makeIamServiceWithTokens(); + const roleId = await seedRoleWith({ 'mcp-access': 'read_write', player: 'read' }); + const holder = await seedAccount(); + await seedAssignment(holder, roleId); + const tokenId = await seedActiveToken(holder); + + await svc.setRolePermissions({ + roleId, + grants: [{ resource: 'player', level: 'read' }], + caller: CALLER, + }); + + expect(await revocationOf(tokenId)).toEqual(LOST); + expect(audit.recordManyInTransaction).toHaveBeenCalledWith(expect.anything(), [ + expect.objectContaining({ + action: 'iam.mcp_token.revoked', + resourceId: tokenId, + actorId: CALLER.userId, + actorType: 'admin', + ip: CALLER.ip, + userAgent: CALLER.userAgent, + }), + ]); + }); + + it('leaves a holder who keeps mcp-access, and an admin outside the role', async () => { + const { svc } = makeIamServiceWithTokens(); + const roleId = await seedRoleWith({ 'mcp-access': 'read_write' }); + const holder = await seedAccount(); + const outsider = await seedAccount(); + await seedAssignment(holder, roleId); + const holdersToken = await seedActiveToken(holder); + const outsidersToken = await seedActiveToken(outsider); + + await svc.setRolePermissions({ + roleId, + grants: [ + { resource: 'mcp-access', level: 'read_write' }, + { resource: 'player', level: 'read' }, + ], + caller: CALLER, + }); + + expect(await revocationOf(holdersToken)).toEqual(KEPT); + expect(await revocationOf(outsidersToken)).toEqual(KEPT); + }); + + it('leaves a holder who also holds a super-admin role', async () => { + const { svc } = makeIamServiceWithTokens(); + const roleId = await seedRoleWith({ 'mcp-access': 'read_write' }); + const superRole = await seedRole({ isSuperAdmin: true, isSystem: true }); + const holder = await seedAccount(); + await seedAssignment(holder, roleId); + await seedAssignment(holder, superRole.id); + const tokenId = await seedActiveToken(holder); + + await svc.setRolePermissions({ + roleId, + grants: [{ resource: 'player', level: 'read' }], + caller: CALLER, + }); + + expect(await revocationOf(tokenId)).toEqual(KEPT); + }); + + it('rolls the permission change back when the revocation cannot be audited', async () => { + const { svc, audit } = makeIamServiceWithTokens(); + audit.recordManyInTransaction.mockRejectedValueOnce(new Error('audit store unavailable')); + const roleId = await seedRoleWith({ 'mcp-access': 'read_write' }); + const holder = await seedAccount(); + await seedAssignment(holder, roleId); + const tokenId = await seedActiveToken(holder); + + await expect( + svc.setRolePermissions({ + roleId, + grants: [{ resource: 'player', level: 'read' }], + caller: CALLER, + }), + ).rejects.toThrow('audit store unavailable'); + + expect(await revocationOf(tokenId)).toEqual(KEPT); + expect((await svc.getRole(roleId)).permissions).toEqual([ + { resource: 'mcp-access', level: 'read_write' }, + ]); + }); + }); + + describe('unassignRole', () => { + it('revokes when the roles left behind lack mcp-access', async () => { + const { svc } = makeIamServiceWithTokens(); + const mcpRole = await seedRoleWith({ 'mcp-access': 'read_write' }); + const playerRole = await seedRoleWith({ player: 'read' }); + const holder = await seedAccount(); + await seedAssignment(holder, mcpRole); + await seedAssignment(holder, playerRole); + const tokenId = await seedActiveToken(holder); + + await svc.unassignRole({ userId: holder, roleId: mcpRole, caller: CALLER }); + + expect(await revocationOf(tokenId)).toEqual(LOST); + }); + + it('revokes when the last role goes, static admin role or not', async () => { + const { svc } = makeIamServiceWithTokens(); + const playerRole = await seedRoleWith({ player: 'read' }); + const holder = await seedAccount('admin'); + await seedAssignment(holder, playerRole); + const tokenId = await seedActiveToken(holder); + + await svc.unassignRole({ userId: holder, roleId: playerRole, caller: CALLER }); + + expect(await revocationOf(tokenId)).toEqual(LOST); + }); + + it('revokes when the last role goes and the static support role, without MCP, takes over', async () => { + const { svc } = makeIamServiceWithTokens(); + const mcpRole = await seedRoleWith({ 'mcp-access': 'read_write' }); + const holder = await seedAccount('support'); + await seedAssignment(holder, mcpRole); + const tokenId = await seedActiveToken(holder); + + await svc.unassignRole({ userId: holder, roleId: mcpRole, caller: CALLER }); + + expect(await revocationOf(tokenId)).toEqual(LOST); + }); + + it('revokes nothing when there was nothing to unassign', async () => { + const { svc } = makeIamServiceWithTokens(); + const mcpRole = await seedRoleWith({ 'mcp-access': 'read_write' }); + const holder = await seedAccount('support'); + const tokenId = await seedActiveToken(holder); + + await svc.unassignRole({ userId: holder, roleId: mcpRole, caller: CALLER }); + + expect(await revocationOf(tokenId)).toEqual(KEPT); + }); + }); + + describe('deleteRole', () => { + it('revokes every holder left without mcp-access, assigned or not', async () => { + const { svc } = makeIamServiceWithTokens(); + const doomed = await seedRoleWith({ 'mcp-access': 'read_write' }); + const playerRole = await seedRoleWith({ player: 'read' }); + const stillAssigned = await seedAccount(); + const backToStatic = await seedAccount('admin'); + await seedAssignment(stillAssigned, doomed); + await seedAssignment(stillAssigned, playerRole); + await seedAssignment(backToStatic, doomed); + const stillAssignedToken = await seedActiveToken(stillAssigned); + const unassignedToken = await seedActiveToken(backToStatic); + + await svc.deleteRole({ roleId: doomed, caller: CALLER }); + + expect(await revocationOf(stillAssignedToken)).toEqual(LOST); + expect(await revocationOf(unassignedToken)).toEqual(LOST); + }); + }); + + describe('assignRole', () => { + it("revokes when a first role without mcp-access replaces the static admin role's grants", async () => { + const { svc } = makeIamServiceWithTokens(); + const playerRole = await seedRoleWith({ player: 'read' }); + const admin = await seedAccount('admin'); + const tokenId = await seedActiveToken(admin); + + await svc.assignRole({ userId: admin, roleId: playerRole, caller: CALLER }); + + expect(await revocationOf(tokenId)).toEqual(LOST); + }); + + it('leaves the tokens when the assigned role grants mcp-access', async () => { + const { svc } = makeIamServiceWithTokens(); + const mcpRole = await seedRoleWith({ 'mcp-access': 'read_write' }); + const admin = await seedAccount('admin'); + const tokenId = await seedActiveToken(admin); + + await svc.assignRole({ userId: admin, roleId: mcpRole, caller: CALLER }); + + expect(await revocationOf(tokenId)).toEqual(KEPT); + }); + }); +}); + +describe('seeded admin roles (real PG)', () => { + it('lets a holder of the predefined admin role use MCP and oversee every token', async () => { + await seedRoles(db.drizzle.db); + const [adminRoleRow] = await db.drizzle.db + .select({ id: adminRole.id }) + .from(adminRole) + .where(eq(adminRole.key, 'admin')); + const holder = randomUUID(); + await seedAssignment(holder, adminRoleRow?.id ?? randomUUID()); + + const grants = await new DbAdminPermissionResolver(db.drizzle).getGrants(holder); + + expect(grants).toEqual( + expect.arrayContaining([ + { resource: 'mcp-access', action: 'use' }, + { resource: 'mcp-token', action: 'view' }, + { resource: 'mcp-token', action: 'revoke' }, + ]), + ); + }); +}); diff --git a/packages/core/src/iam/__tests__/mcp-token-authenticator.int.test.ts b/packages/core/src/iam/__tests__/mcp-token-authenticator.int.test.ts new file mode 100644 index 000000000..9a3231a79 --- /dev/null +++ b/packages/core/src/iam/__tests__/mcp-token-authenticator.int.test.ts @@ -0,0 +1,234 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { and, eq, sql } from 'drizzle-orm'; +import { createTestDb, seedUser, type TestDb } from '@openora/core/testing'; +import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity'; +import { account, user } from '@openora/core/pam/schema/identity'; +import { migrate as migrateIam } from '../migrate.js'; +import { mcpToken } from '../schema/index.js'; +import { DrizzleMcpTokenAuthenticator } from '../adapters/mcp-token-authenticator.js'; +import { generateMcpToken, hashMcpToken, mcpTokenDisplayPrefix } from '../shared/mcp-token.js'; + +const HOUR_MS = 60 * 60 * 1000; +const hoursFromNow = (hours: number) => new Date(Date.now() + hours * HOUR_MS); + +let db: TestDb; +let authenticator: DrizzleMcpTokenAuthenticator; + +beforeAll(async () => { + db = await createTestDb([migrateIam, migrateIdentity]); + authenticator = new DrizzleMcpTokenAuthenticator(db.drizzle); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute(sql`TRUNCATE ${mcpToken}, ${user} RESTART IDENTITY CASCADE`); +}); + +async function seedAdminWithPassword(passwordSetAt: Date) { + const admin = await seedUser(db, { role: 'admin' }); + await db.drizzle.db.insert(account).values({ + accountId: admin.id, + providerId: 'credential', + userId: admin.id, + password: 'stored-hash', + updatedAt: passwordSetAt, + }); + return admin.id; +} + +async function changePasswordAt(adminUserId: string, at: Date) { + await db.drizzle.db + .update(account) + .set({ updatedAt: at }) + .where(and(eq(account.userId, adminUserId), eq(account.providerId, 'credential'))); +} + +async function seedToken( + over: Partial = {}, + plaintext = generateMcpToken(), +) { + const [row] = await db.drizzle.db + .insert(mcpToken) + .values({ + adminUserId: randomUUID(), + label: 'Laptop', + tokenHash: hashMcpToken(plaintext), + tokenPrefix: mcpTokenDisplayPrefix(plaintext), + expiresAt: hoursFromNow(24), + ...over, + }) + .returning(); + return { row, plaintext }; +} + +async function storedToken(id: string) { + const [row] = await db.drizzle.db.select().from(mcpToken).where(eq(mcpToken.id, id)); + return row; +} + +describe('DrizzleMcpTokenAuthenticator.authenticate (real PG)', () => { + it('accepts an active token and names the token and its admin', async () => { + const { row, plaintext } = await seedToken(); + + expect(await authenticator.authenticate(plaintext)).toEqual({ + ok: true, + tokenId: row.id, + adminId: row.adminUserId, + }); + }); + + it('answers unknown for a well-formed token that was never issued', async () => { + await seedToken(); + + expect(await authenticator.authenticate(generateMcpToken())).toEqual({ + ok: false, + reason: 'unknown', + }); + }); + + it.each(['', 'not-a-token', 'ora_mcp_short', `Bearer ${generateMcpToken()}`])( + 'answers unknown for the malformed bearer %j even when its hash is on file', + async (bearer) => { + await seedToken({}, bearer); + + expect(await authenticator.authenticate(bearer)).toEqual({ ok: false, reason: 'unknown' }); + }, + ); + + it('refuses a revoked token as revoked, naming the token and its admin', async () => { + const { row, plaintext } = await seedToken({ + revokedAt: hoursFromNow(-1), + revokeReason: 'manual', + }); + + expect(await authenticator.authenticate(plaintext)).toEqual({ + ok: false, + reason: 'revoked', + tokenId: row.id, + adminId: row.adminUserId, + }); + }); + + it('refuses a token past its expiry as expired', async () => { + const { row, plaintext } = await seedToken({ expiresAt: hoursFromNow(-1) }); + + expect(await authenticator.authenticate(plaintext)).toEqual({ + ok: false, + reason: 'expired', + tokenId: row.id, + adminId: row.adminUserId, + }); + }); + + it('refuses a token that is both revoked and expired as revoked', async () => { + const { plaintext } = await seedToken({ + expiresAt: hoursFromNow(-2), + revokedAt: hoursFromNow(-1), + revokeReason: 'sessions_revoked', + }); + + expect(await authenticator.authenticate(plaintext)).toMatchObject({ + ok: false, + reason: 'revoked', + }); + }); +}); + +describe("DrizzleMcpTokenAuthenticator against the admin's password changes (real PG)", () => { + it('refuses an unrevoked token issued before the latest password change', async () => { + const adminId = await seedAdminWithPassword(hoursFromNow(-48)); + const { row, plaintext } = await seedToken({ + adminUserId: adminId, + createdAt: hoursFromNow(-2), + }); + await changePasswordAt(adminId, hoursFromNow(-1)); + + expect(await authenticator.authenticate(plaintext)).toEqual({ + ok: false, + reason: 'credentials_changed', + tokenId: row.id, + adminId, + }); + expect(await storedToken(row.id)).toMatchObject({ revokedAt: null, revokeReason: null }); + }); + + it('accepts a token issued after the latest password change', async () => { + const adminId = await seedAdminWithPassword(hoursFromNow(-2)); + const { row, plaintext } = await seedToken({ + adminUserId: adminId, + createdAt: hoursFromNow(-1), + }); + + expect(await authenticator.authenticate(plaintext)).toEqual({ + ok: true, + tokenId: row.id, + adminId, + }); + }); + + it('reads the latest stamp when the admin holds more than one password account', async () => { + const adminId = await seedAdminWithPassword(hoursFromNow(-48)); + await db.drizzle.db.insert(account).values({ + accountId: `second-${adminId}`, + providerId: 'credential', + userId: adminId, + password: 'stored-hash', + updatedAt: hoursFromNow(-1), + }); + const { plaintext } = await seedToken({ adminUserId: adminId, createdAt: hoursFromNow(-2) }); + + expect(await authenticator.authenticate(plaintext)).toMatchObject({ + ok: false, + reason: 'credentials_changed', + }); + }); + + it("ignores a later write to the admin's account at another provider", async () => { + const adminId = await seedAdminWithPassword(hoursFromNow(-48)); + await db.drizzle.db.insert(account).values({ + accountId: `oidc-${adminId}`, + providerId: 'oidc', + userId: adminId, + updatedAt: hoursFromNow(-1), + }); + const { plaintext } = await seedToken({ adminUserId: adminId, createdAt: hoursFromNow(-2) }); + + expect(await authenticator.authenticate(plaintext)).toMatchObject({ ok: true }); + }); + + it("ignores another admin's password change", async () => { + const adminId = await seedAdminWithPassword(hoursFromNow(-48)); + await seedAdminWithPassword(hoursFromNow(-1)); + const { plaintext } = await seedToken({ adminUserId: adminId, createdAt: hoursFromNow(-2) }); + + expect(await authenticator.authenticate(plaintext)).toMatchObject({ ok: true }); + }); +}); + +describe('DrizzleMcpTokenAuthenticator.recordCall (real PG)', () => { + it('counts each call and stamps the last use with the database clock', async () => { + const { row } = await seedToken(); + const { rows } = await db.drizzle.db.execute<{ now: string }>(sql`select now() as now`); + const before = new Date(rows[0]?.now ?? 0); + + await authenticator.recordCall(row.id); + await authenticator.recordCall(row.id); + + const stored = await storedToken(row.id); + expect(stored.callCount).toBe(2); + expect(stored.lastUsedAt?.getTime()).toBeGreaterThanOrEqual(before.getTime()); + }); + + it('leaves every other token untouched', async () => { + const { row } = await seedToken(); + const { row: other } = await seedToken(); + + await authenticator.recordCall(row.id); + + expect(await storedToken(other.id)).toMatchObject({ callCount: 0, lastUsedAt: null }); + }); +}); diff --git a/packages/core/src/iam/__tests__/mcp-token-issue-failure.int.test.ts b/packages/core/src/iam/__tests__/mcp-token-issue-failure.int.test.ts new file mode 100644 index 000000000..e8f4fa14c --- /dev/null +++ b/packages/core/src/iam/__tests__/mcp-token-issue-failure.int.test.ts @@ -0,0 +1,97 @@ +import { randomUUID } from 'node:crypto'; +import { inspect } from 'node:util'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { sql } from 'drizzle-orm'; +import { McpTransportConfigSchema } from '@openora/core/contracts'; +import { createTestDb, seedUser, type TestDb } from '@openora/core/testing'; +import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity'; +import { session } from '@openora/core/pam/schema/identity'; +import { makeAuditWriter, makeEventBus, makeRateLimiter } from '../../testing/mock.js'; +import { migrate as migrateIam } from '../migrate.js'; +import { adminRole, adminRolePermission, adminRoleAssignment, mcpToken } from '../schema/index.js'; +import { McpTokenIssueError, McpTokenService } from '../service/mcp-token.service.js'; + +const log = vi.hoisted(() => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() })); +vi.mock('@openora/core/server', async (importOriginal) => ({ + ...(await importOriginal()), + createLogger: () => log, +})); + +const META = { ip: '203.0.113.7', userAgent: 'mcp-client/1.0' }; +const DAY_MS = 24 * 60 * 60 * 1000; +const SHA256_HEX = /[0-9a-f]{64}/; +const TOKEN_SCHEME = 'ora_mcp_'; + +let db: TestDb; + +beforeAll(async () => { + db = await createTestDb([migrateIam, migrateIdentity]); + await db.drizzle.db.execute( + sql`ALTER TABLE ${mcpToken} ADD CONSTRAINT mcp_token_test_unwritable CHECK (label <> 'unwritable')`, + ); +}); + +afterAll(async () => { + await db.drop(); +}); + +describe('McpTokenService.create when the database refuses the write (real PG)', () => { + it('fails generically, with no cause, and logs only the error name and SQL state', async () => { + const audit = makeAuditWriter(); + const svc = new McpTokenService({ + drizzle: db.drizzle, + audit, + events: makeEventBus(), + rateLimiter: makeRateLimiter(), + config: McpTransportConfigSchema.parse({ + enabled: true, + allowedHosts: ['backoffice.example.com'], + }), + }); + const admin = await seedUser(db, { role: 'admin', isActive: true }); + // With the permission resolver bound, an admin holds nothing without a role + // assignment, so the owner needs a real role granting `mcp-access`. + const [mcpRole] = await db.drizzle.db + .insert(adminRole) + .values({ name: 'MCP' }) + .returning({ id: adminRole.id }); + if (!mcpRole) { + throw new Error('role insert returned no row'); + } + await db.drizzle.db + .insert(adminRolePermission) + .values({ roleId: mcpRole.id, resource: 'mcp-access', level: 'read_write' }); + await db.drizzle.db + .insert(adminRoleAssignment) + .values({ userId: admin.id, roleId: mcpRole.id }); + const [live] = await db.drizzle.db + .insert(session) + .values({ + userId: admin.id, + token: randomUUID(), + expiresAt: new Date(Date.now() + DAY_MS), + updatedAt: new Date(), + }) + .returning({ id: session.id }); + + const failure = await svc + .create({ adminUserId: admin.id, sessionId: live?.id ?? null, label: 'unwritable' }, META) + .then( + () => undefined, + (err: unknown) => err, + ); + + expect(failure).toBeInstanceOf(McpTokenIssueError); + expect(failure).toMatchObject({ message: 'The MCP token could not be issued' }); + expect(failure).not.toHaveProperty('cause'); + expect(inspect(failure)).not.toMatch(SHA256_HEX); + expect(inspect(failure)).not.toContain(TOKEN_SCHEME); + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( + { name: 'DrizzleQueryError', code: '23514' }, + 'MCP token issuance failed', + ); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(0); + expect(audit.recordInTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/iam/__tests__/mcp-token.router.int.test.ts b/packages/core/src/iam/__tests__/mcp-token.router.int.test.ts new file mode 100644 index 000000000..750dc4d79 --- /dev/null +++ b/packages/core/src/iam/__tests__/mcp-token.router.int.test.ts @@ -0,0 +1,217 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { sql } from 'drizzle-orm'; +import { call, ORPCError } from '@orpc/server'; +import type { AdminGuard } from '@openora/core/server'; +import { + McpTransportConfigSchema, + type MailDispatchPort, + type McpTransportConfig, + type RateLimiterAdapter, + type RateLimitKey, +} from '@openora/core/contracts'; +import { createTestDb, seedUser, type TestDb } from '@openora/core/testing'; +import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity'; +import { session, user, type Session, type User } from '@openora/core/pam/schema/identity'; +import { + makeAdminGuard, + makeAuditWriter, + makeEventBus, + makeIdentityReader, + makeRateLimiter, + mock, + testContext, +} from '../../testing/mock.js'; +import { migrate as migrateIam } from '../migrate.js'; +import { adminRole, adminRolePermission, adminRoleAssignment, mcpToken } from '../schema/index.js'; +import { createIamRouter } from '../router/index.js'; +import { IamService } from '../service/iam.service.js'; +import { McpTokenService } from '../service/mcp-token.service.js'; + +const HOUR_MS = 60 * 60 * 1000; +const BACKOFFICE_HOSTS = ['backoffice.example.com']; +const ENABLED = McpTransportConfigSchema.parse({ enabled: true, allowedHosts: BACKOFFICE_HOSTS }); + +let db: TestDb; + +beforeAll(async () => { + db = await createTestDb([migrateIam, migrateIdentity]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute( + sql`TRUNCATE ${mcpToken}, ${adminRole}, ${adminRolePermission}, ${adminRoleAssignment}, ${user} RESTART IDENTITY CASCADE`, + ); +}); + +function buildRouter({ + guard, + config = ENABLED, + rateLimiter = makeRateLimiter(), +}: { + guard: AdminGuard; + config?: McpTransportConfig; + rateLimiter?: RateLimiterAdapter; +}) { + const mcpTokens = new McpTokenService({ + drizzle: db.drizzle, + audit: makeAuditWriter(), + events: makeEventBus(), + rateLimiter, + config, + }); + const iam = new IamService({ + drizzle: db.drizzle, + events: makeEventBus(), + mailDispatch: mock({}), + identityReader: makeIdentityReader(), + mcpTokens, + }); + return createIamRouter(iam, guard, mcpTokens); +} + +async function transportError(promise: Promise) { + const err = await promise.then( + () => undefined, + (thrown: unknown) => thrown, + ); + expect(err).toBeInstanceOf(ORPCError); + return err instanceof ORPCError ? { code: err.code, data: err.data } : undefined; +} + +/** + * With the permission resolver bound, an admin holds nothing without a role + * assignment, so an MCP token owner needs a real role granting `mcp-access`. + */ +async function grantMcpAccess(userId: string) { + const [role] = await db.drizzle.db + .insert(adminRole) + .values({ name: `MCP ${crypto.randomUUID()}` }) + .returning({ id: adminRole.id }); + if (!role) { + throw new Error('grantMcpAccess: role insert returned no row'); + } + await db.drizzle.db + .insert(adminRolePermission) + .values({ roleId: role.id, resource: 'mcp-access', level: 'read_write' }); + await db.drizzle.db.insert(adminRoleAssignment).values({ userId, roleId: role.id }); +} + +async function seedOwner(over: Partial = {}) { + const owner = await seedUser(db, { role: 'admin', ...over }); + await grantMcpAccess(owner.id); + const [live] = await db.drizzle.db + .insert(session) + .values({ + userId: owner.id, + token: crypto.randomUUID(), + expiresAt: new Date(Date.now() + HOUR_MS), + updatedAt: new Date(), + }) + .returning({ id: session.id }); + return { id: owner.id, sessionId: live?.id }; +} + +const issue = ( + router: ReturnType, + owner: { id: User['id']; sessionId: Session['id'] | undefined }, + input: { label: string; ttlDays?: number }, +) => + call(router.mcpTokens.create, input, { + context: testContext({ auth: { userId: owner.id, sessionId: owner.sessionId } }), + }); + +describe('iam router mcpTokens.create errors (real PG)', () => { + it('refuses a caller without mcp-access before anything is issued', async () => { + const owner = await seedOwner(); + const router = buildRouter({ + guard: makeAdminGuard({ allow: [], caller: { userId: owner.id } }), + }); + + expect(await transportError(issue(router, owner, { label: 'Denied' }))).toMatchObject({ + code: 'FORBIDDEN', + }); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(0); + }); + + it('answers an inactive owner FORBIDDEN owner_ineligible', async () => { + const owner = await seedOwner({ isActive: false }); + const router = buildRouter({ guard: makeAdminGuard({ caller: { userId: owner.id } }) }); + + expect(await transportError(issue(router, owner, { label: 'Stale session' }))).toEqual({ + code: 'FORBIDDEN', + data: { reason: 'owner_ineligible' }, + }); + }); + + it('answers an owner at the active-token cap CONFLICT token_limit', async () => { + const owner = await seedOwner(); + const router = buildRouter({ + guard: makeAdminGuard({ caller: { userId: owner.id } }), + config: McpTransportConfigSchema.parse({ + enabled: true, + allowedHosts: BACKOFFICE_HOSTS, + tokenIssuance: { maxActivePerAdmin: 1 }, + }), + }); + await issue(router, owner, { label: 'First' }); + + expect(await transportError(issue(router, owner, { label: 'Second' }))).toEqual({ + code: 'CONFLICT', + data: { reason: 'token_limit' }, + }); + }); + + it('answers a request that carries no session FORBIDDEN owner_ineligible, issuing nothing', async () => { + const owner = await seedOwner(); + const router = buildRouter({ guard: makeAdminGuard({ caller: { userId: owner.id } }) }); + + expect( + await transportError(issue(router, { ...owner, sessionId: undefined }, { label: 'Bare' })), + ).toEqual({ code: 'FORBIDDEN', data: { reason: 'owner_ineligible' } }); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(0); + }); + + it('answers a disabled transport CONFLICT mcp_disabled', async () => { + const owner = await seedOwner(); + const router = buildRouter({ + guard: makeAdminGuard({ caller: { userId: owner.id } }), + config: McpTransportConfigSchema.parse({}), + }); + + expect(await transportError(issue(router, owner, { label: 'Off' }))).toEqual({ + code: 'CONFLICT', + data: { reason: 'mcp_disabled' }, + }); + }); + + it('answers a lifetime over the cap BAD_REQUEST ttl_exceeds_max', async () => { + const owner = await seedOwner(); + const router = buildRouter({ guard: makeAdminGuard({ caller: { userId: owner.id } }) }); + + expect(await transportError(issue(router, owner, { label: 'Long', ttlDays: 91 }))).toEqual({ + code: 'BAD_REQUEST', + data: { reason: 'ttl_exceeds_max' }, + }); + }); + + it('answers an owner over the hourly limit TOO_MANY_REQUESTS with the retry delay', async () => { + const owner = await seedOwner(); + const router = buildRouter({ + guard: makeAdminGuard({ caller: { userId: owner.id } }), + rateLimiter: mock>({ + consume: vi.fn(async () => ({ allowed: false, retryAfterMs: HOUR_MS })), + reset: vi.fn(), + }), + }); + + expect(await transportError(issue(router, owner, { label: 'Throttled' }))).toEqual({ + code: 'TOO_MANY_REQUESTS', + data: { retryAfterMs: HOUR_MS }, + }); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(0); + }); +}); diff --git a/packages/core/src/iam/__tests__/mcp-token.service.int.test.ts b/packages/core/src/iam/__tests__/mcp-token.service.int.test.ts new file mode 100644 index 000000000..83d543168 --- /dev/null +++ b/packages/core/src/iam/__tests__/mcp-token.service.int.test.ts @@ -0,0 +1,1087 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { eq, sql } from 'drizzle-orm'; +import { ORPCError } from '@orpc/server'; +import { RedisRateLimiter } from '@openora/core/server'; +import { + McpTransportConfigSchema, + type AuditWritePort, + type McpTransportConfig, + type RateLimiterAdapter, + type RateLimitKey, +} from '@openora/core/contracts'; +import { paginated } from '@openora/core/contracts/kit'; +import { + createTestDb, + createTestRedis, + seedUser, + waitForRowLockWaiter, + waitForTableLockWaiter, + type TestDb, + type TestRedis, +} from '@openora/core/testing'; +import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity'; +import { session, user, type Session, type User } from '@openora/core/pam/schema/identity'; +import { makeAuditWriter, makeEventBus, makeRateLimiter, mock } from '../../testing/mock.js'; +import { migrate as migrateIam } from '../migrate.js'; +import { adminRole, adminRolePermission, adminRoleAssignment, mcpToken } from '../schema/index.js'; +import { + IssuedMcpTokenSchema, + McpTokenListItemSchema, + McpTokenSchema, + type CreateMcpTokenInput, +} from '../contract/index.js'; +import { + McpTokenIssueError, + McpTokenLimitError, + McpTokenNotFoundError, + McpTokenOwnerIneligibleError, + McpTokenService, + McpTokenTtlError, + McpTransportDisabledError, +} from '../service/mcp-token.service.js'; +import { + generateMcpToken, + hashMcpToken, + isMcpTokenFormat, + mcpTokenDisplayPrefix, +} from '../shared/mcp-token.js'; + +const NOW = new Date('2026-10-06T12:00:00.000Z'); +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; +const META = { ip: '203.0.113.7', userAgent: 'mcp-client/1.0' }; +const PAGE = { page: 1, limit: 50 }; +const BACKOFFICE_HOSTS = ['backoffice.example.com']; +const ENABLED = McpTransportConfigSchema.parse({ + enabled: true, + allowedHosts: BACKOFFICE_HOSTS, + tokenTtlDays: { default: 30, max: 90 }, +}); +const DISABLED = McpTransportConfigSchema.parse({}); + +const daysFromNow = (days: number) => new Date(NOW.getTime() + days * DAY_MS); + +const withIssuance = (tokenIssuance: { maxActivePerAdmin?: number; perHour?: number }) => + McpTransportConfigSchema.parse({ enabled: true, allowedHosts: BACKOFFICE_HOSTS, tokenIssuance }); + +let db: TestDb; +let redis: TestRedis; + +beforeAll(async () => { + db = await createTestDb([migrateIam, migrateIdentity]); + redis = await createTestRedis(); +}); + +afterAll(async () => { + await db.drop(); + await redis.quit(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute( + sql`TRUNCATE ${mcpToken}, ${adminRole}, ${adminRolePermission}, ${adminRoleAssignment}, ${user} RESTART IDENTITY CASCADE`, + ); + await redis.flush(); +}); + +function makeService({ + config = ENABLED, + rateLimiter = makeRateLimiter(), +}: { config?: McpTransportConfig; rateLimiter?: RateLimiterAdapter } = {}) { + const audit = makeAuditWriter(); + const events = makeEventBus(); + return { + svc: new McpTokenService({ + drizzle: db.drizzle, + audit, + events, + rateLimiter, + config, + now: () => NOW, + }), + audit, + events, + }; +} + +async function rejectionOf(promise: Promise) { + return promise.then( + () => undefined, + (err: unknown) => err, + ); +} + +async function seedSession(userId: User['id'], expiresAt = new Date(Date.now() + DAY_MS)) { + const [row] = await db.drizzle.db + .insert(session) + .values({ userId, token: randomUUID(), expiresAt, updatedAt: new Date() }) + .returning({ id: session.id }); + if (!row) { + throw new Error('seedSession: insert returned no row'); + } + return row.id; +} + +async function withSession(owner: User) { + return { ...owner, sessionId: await seedSession(owner.id) }; +} + +/** + * With the permission resolver bound, an admin holds nothing without a role + * assignment, so an MCP token owner needs a real role granting `mcp-access`. + */ +async function grantMcpAccess(userId: User['id']) { + const [role] = await db.drizzle.db + .insert(adminRole) + .values({ name: `MCP ${randomUUID()}` }) + .returning({ id: adminRole.id }); + if (!role) { + throw new Error('grantMcpAccess: role insert returned no row'); + } + await db.drizzle.db + .insert(adminRolePermission) + .values({ roleId: role.id, resource: 'mcp-access', level: 'read_write' }); + await db.drizzle.db.insert(adminRoleAssignment).values({ userId, roleId: role.id }); +} + +const seedAdmin = async (email: string, name = 'Admin') => { + const owner = await seedUser(db, { email, name, role: 'admin', isActive: true }); + await grantMcpAccess(owner.id); + return withSession(owner); +}; + +const issueAs = ( + svc: McpTokenService, + owner: { id: User['id']; sessionId: Session['id'] }, + input: CreateMcpTokenInput, +) => svc.create({ ...input, adminUserId: owner.id, sessionId: owner.sessionId }, META); + +async function seedToken(adminUserId: string, over: Partial = {}) { + const token = generateMcpToken(); + const [row] = await db.drizzle.db + .insert(mcpToken) + .values({ + adminUserId, + label: 'Laptop', + tokenHash: hashMcpToken(token), + tokenPrefix: mcpTokenDisplayPrefix(token), + createdAt: NOW, + expiresAt: daysFromNow(30), + ...over, + }) + .returning(); + return row; +} + +async function storedToken(id: string) { + const [row] = await db.drizzle.db.select().from(mcpToken).where(eq(mcpToken.id, id)); + return row; +} + +type AuditEntry = Parameters[1]; + +const auditEntries = (audit: ReturnType): AuditEntry[] => [ + ...audit.recordInTransaction.mock.calls.map((call) => call[1]), + ...audit.recordManyInTransaction.mock.calls.flatMap((call) => call[1]), +]; + +describe('McpTokenService.create (real PG)', () => { + it('stores only the hash and a display prefix and hands the plaintext back once', async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + + const issued = await issueAs(svc, alice, { label: 'Claude Code' }); + + expect(isMcpTokenFormat(issued.token)).toBe(true); + const row = await storedToken(issued.id); + expect(row).toMatchObject({ + adminUserId: alice.id, + label: 'Claude Code', + tokenHash: hashMcpToken(issued.token), + tokenPrefix: issued.token.slice(0, 12), + callCount: 0, + lastUsedAt: null, + revokedAt: null, + }); + expect(Object.values(row)).not.toContain(issued.token); + expect(IssuedMcpTokenSchema.parse(issued)).toEqual(issued); + expect(issued).not.toHaveProperty('tokenHash'); + expect(issued).toMatchObject({ status: 'active', tokenPrefix: row.tokenPrefix }); + const listed = await svc.listMine(alice.id, PAGE); + expect(JSON.stringify(listed)).not.toContain(issued.token); + expect(JSON.stringify(listed)).not.toContain(row.tokenHash); + }); + + it('expires the token the configured default number of days after issue', async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + + const issued = await issueAs(svc, alice, { label: 'Default' }); + + expect((await storedToken(issued.id)).expiresAt).toEqual(daysFromNow(30)); + expect(issued.createdAt).toBe(NOW.toISOString()); + expect(issued.expiresAt).toBe(daysFromNow(30).toISOString()); + }); + + it('honours a requested lifetime up to the configured maximum', async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + + const week = await issueAs(svc, alice, { label: 'Week', ttlDays: 7 }); + const longest = await issueAs(svc, alice, { label: 'Max', ttlDays: 90 }); + + expect((await storedToken(week.id)).expiresAt).toEqual(daysFromNow(7)); + expect((await storedToken(longest.id)).expiresAt).toEqual(daysFromNow(90)); + }); + + it('refuses a lifetime above the configured maximum as ttl_exceeds_max and stores nothing', async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + + const refusal = await rejectionOf(issueAs(svc, alice, { label: 'Too long', ttlDays: 91 })); + + expect(refusal).toBeInstanceOf(McpTokenTtlError); + expect(refusal).toHaveProperty('data', { reason: 'ttl_exceeds_max' }); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(0); + expect(audit.recordInTransaction).not.toHaveBeenCalled(); + }); + + it('refuses to issue while the transport is disabled, as mcp_disabled', async () => { + const { svc, audit } = makeService({ config: DISABLED }); + const alice = await seedAdmin('alice@ops.example'); + + const refusal = await rejectionOf(issueAs(svc, alice, { label: 'Claude Code' })); + + expect(refusal).toBeInstanceOf(McpTransportDisabledError); + expect(refusal).toHaveProperty('data', { reason: 'mcp_disabled' }); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(0); + expect(audit.recordInTransaction).not.toHaveBeenCalled(); + }); + + it('audits the issue on the creating transaction without the token or its hash', async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + + const issued = await issueAs(svc, alice, { label: 'Claude Code' }); + + expect(audit.record).not.toHaveBeenCalled(); + expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); + expect(audit.recordInTransaction).toHaveBeenCalledWith(expect.anything(), { + actorId: alice.id, + actorType: 'admin', + action: 'iam.mcp_token.created', + resourceType: 'mcp-token', + resourceId: issued.id, + after: { + adminUserId: alice.id, + label: 'Claude Code', + tokenPrefix: issued.tokenPrefix, + expiresAt: daysFromNow(30).toISOString(), + ttlDays: 30, + }, + ...META, + }); + const recorded = JSON.stringify(auditEntries(audit)); + expect(recorded).not.toContain(issued.token); + expect(recorded).not.toContain(hashMcpToken(issued.token)); + }); + + it('rolls the token back when its audit record cannot be written, failing generically', async () => { + const { svc, audit } = makeService(); + audit.recordInTransaction.mockRejectedValueOnce(new Error('audit store unavailable')); + const alice = await seedAdmin('alice@ops.example'); + + const failure = await rejectionOf(issueAs(svc, alice, { label: 'Claude Code' })); + + expect(failure).toBeInstanceOf(McpTokenIssueError); + expect(failure).not.toHaveProperty('cause'); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(0); + }); +}); + +describe('McpTokenService.create owner checks (real PG)', () => { + it.each([ + [ + 'an inactive admin', + async () => withSession(await seedUser(db, { role: 'admin', isActive: false })), + 'admin', + ], + ['a player', async () => withSession(await seedUser(db, { role: 'player' })), 'player'], + [ + 'a staff account whose role grants no MCP access', + async () => withSession(await seedUser(db, { role: 'support' })), + 'support', + ], + ])('refuses %s as owner_ineligible and records the denial', async (_case, seed, role) => { + const { svc, audit, events } = makeService(); + const owner = await seed(); + + const refusal = await rejectionOf(issueAs(svc, owner, { label: 'Nope' })); + + expect(refusal).toBeInstanceOf(McpTokenOwnerIneligibleError); + expect(refusal).toHaveProperty('data', { reason: 'owner_ineligible' }); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(0); + expect(audit.recordInTransaction).not.toHaveBeenCalled(); + expect(events.emit).toHaveBeenCalledWith('identity.user.unauthorized_access', { + userId: owner.id, + playerId: null, + resource: 'mcp-access', + action: 'use', + role, + ...META, + }); + }); + + it('refuses an owner with no account as owner_ineligible', async () => { + const { svc, events } = makeService(); + const ghostId = randomUUID(); + + await expect( + svc.create({ adminUserId: ghostId, sessionId: null, label: 'Nope' }, META), + ).rejects.toBeInstanceOf(McpTokenOwnerIneligibleError); + + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(0); + expect(events.emit).toHaveBeenCalledWith( + 'identity.user.unauthorized_access', + expect.objectContaining({ userId: ghostId, role: undefined }), + ); + }); +}); + +describe('McpTokenService.create session check (real PG)', () => { + const ENDED_SESSIONS: [string, (ownerId: User['id']) => Promise][] = [ + ['has expired', (ownerId) => seedSession(ownerId, new Date(Date.now() - HOUR_MS))], + ['was deleted', async () => randomUUID()], + ['belongs to another admin', async () => (await seedAdmin('other@ops.example')).sessionId], + ['the request did not carry', async () => null], + ]; + + it.each(ENDED_SESSIONS)( + 'refuses an issue from a session that %s, as owner_ineligible, and records the denial', + async (_case, sessionOf) => { + const { svc, audit, events } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + + const refusal = await rejectionOf( + svc.create( + { adminUserId: alice.id, sessionId: await sessionOf(alice.id), label: 'Stale' }, + META, + ), + ); + + expect(refusal).toBeInstanceOf(McpTokenOwnerIneligibleError); + expect(refusal).toHaveProperty('data', { reason: 'owner_ineligible' }); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(0); + expect(audit.recordInTransaction).not.toHaveBeenCalled(); + expect(events.emit).toHaveBeenCalledWith('identity.user.unauthorized_access', { + userId: alice.id, + playerId: null, + resource: 'mcp-access', + action: 'use', + role: 'admin', + ...META, + }); + }, + ); + + it('refuses an issue whose session was revoked while it waited, though its transaction began first', async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + let releaseOwner = () => {}; + let markOwnerHeld = () => {}; + const ownerHeld = new Promise((resolve) => { + markOwnerHeld = resolve; + }); + const holdingOwner = db.drizzle.db.transaction(async (tx) => { + await tx.select({ id: user.id }).from(user).where(eq(user.id, alice.id)).for('update'); + markOwnerHeld(); + await new Promise((resolve) => { + releaseOwner = resolve; + }); + }); + await ownerHeld; + + const issuing = rejectionOf(issueAs(svc, alice, { label: 'Waited' })); + await waitForRowLockWaiter(db); + await db.drizzle.db.transaction((tx) => + tx + .update(session) + .set({ expiresAt: sql`now()` }) + .where(eq(session.id, alice.sessionId)), + ); + releaseOwner(); + await holdingOwner; + + expect(await issuing).toBeInstanceOf(McpTokenOwnerIneligibleError); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(0); + }); + + it('issues from a live session of the owner', async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + + await expect(issueAs(svc, alice, { label: 'Live' })).resolves.toMatchObject({ + adminUserId: alice.id, + status: 'active', + }); + }); +}); + +describe('McpTokenService.create active-token cap (real PG)', () => { + it('refuses a token over the cap as token_limit, counting neither expired nor revoked ones', async () => { + const { svc } = makeService({ config: withIssuance({ maxActivePerAdmin: 2 }) }); + const alice = await seedAdmin('alice@ops.example'); + const [first] = [await seedToken(alice.id), await seedToken(alice.id)]; + await seedToken(alice.id, { expiresAt: daysFromNow(-1) }); + await seedToken(alice.id, { revokedAt: daysFromNow(-1), revokeReason: 'manual' }); + + const refusal = await rejectionOf(issueAs(svc, alice, { label: 'Third' })); + + expect(refusal).toBeInstanceOf(McpTokenLimitError); + expect(refusal).toHaveProperty('data', { reason: 'token_limit' }); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(4); + + await svc.revokeMine(alice.id, first.id, META); + await expect(issueAs(svc, alice, { label: 'Replacement' })).resolves.toMatchObject({ + status: 'active', + }); + }); + + it("counts only the owner's own tokens", async () => { + const { svc } = makeService({ config: withIssuance({ maxActivePerAdmin: 1 }) }); + const alice = await seedAdmin('alice@ops.example'); + const bob = await seedAdmin('bob@ops.example'); + await seedToken(bob.id); + + await expect(issueAs(svc, alice, { label: 'Own' })).resolves.toMatchObject({ + adminUserId: alice.id, + }); + }); + + it('holds a concurrent issue until the first commits, then refuses it at the cap', async () => { + let markCounted = () => {}; + let releaseFirst = () => {}; + const counted = new Promise((resolve) => { + markCounted = resolve; + }); + const firstHeld = new Promise((resolve) => { + releaseFirst = resolve; + }); + const consume = vi.fn(async () => ({ allowed: true, retryAfterMs: 0 })); + consume.mockImplementationOnce(async () => { + markCounted(); + await firstHeld; + return { allowed: true, retryAfterMs: 0 }; + }); + const { svc } = makeService({ + config: withIssuance({ maxActivePerAdmin: 1 }), + rateLimiter: mock>({ consume, reset: vi.fn() }), + }); + const alice = await seedAdmin('alice@ops.example'); + + const first = issueAs(svc, alice, { label: 'First' }); + await counted; + const second = rejectionOf(issueAs(svc, alice, { label: 'Second' })); + await waitForRowLockWaiter(db); + releaseFirst(); + + await expect(first).resolves.toMatchObject({ label: 'First' }); + expect(await second).toBeInstanceOf(McpTokenLimitError); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(1); + }); +}); + +describe('McpTokenService.create issuance throttle', () => { + it('refuses the issue over the hourly limit with TOO_MANY_REQUESTS and its retry delay', async () => { + const { svc } = makeService({ + config: withIssuance({ perHour: 2 }), + rateLimiter: new RedisRateLimiter(redis.client), + }); + const alice = await seedAdmin('alice@ops.example'); + const bob = await seedAdmin('bob@ops.example'); + await issueAs(svc, alice, { label: 'One' }); + await issueAs(svc, alice, { label: 'Two' }); + + const refusal = await rejectionOf(issueAs(svc, alice, { label: 'Three' })); + + expect(refusal).toBeInstanceOf(ORPCError); + expect(refusal).toMatchObject({ code: 'TOO_MANY_REQUESTS' }); + expect(refusal).toHaveProperty( + 'data.retryAfterMs', + expect.toSatisfy((ms: number) => ms > 0 && ms <= HOUR_MS), + ); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(2); + await expect(issueAs(svc, bob, { label: 'Bob' })).resolves.toMatchObject({ + adminUserId: bob.id, + }); + }); + + it('throttles per admin for an hour and fails closed when the limiter store is down', async () => { + const consume = vi.fn(async () => ({ + allowed: false, + retryAfterMs: HOUR_MS, + unavailable: true, + })); + const { svc, audit } = makeService({ + config: withIssuance({ perHour: 7 }), + rateLimiter: mock>({ consume, reset: vi.fn() }), + }); + const alice = await seedAdmin('alice@ops.example'); + + await expect(issueAs(svc, alice, { label: 'Blocked' })).rejects.toMatchObject({ + code: 'TOO_MANY_REQUESTS', + data: { retryAfterMs: HOUR_MS }, + }); + + expect(consume).toHaveBeenCalledWith(`mcp-token-create:${alice.id}`, { + limit: 7, + windowMs: HOUR_MS, + onUnavailable: 'deny', + }); + expect(await db.drizzle.db.select().from(mcpToken)).toHaveLength(0); + expect(audit.recordInTransaction).not.toHaveBeenCalled(); + }); +}); + +describe('McpTokenService.revokeMine (real PG)', () => { + it("revokes the caller's own token as a manual revoke and audits it once", async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const token = await seedToken(alice.id); + + const revoked = await svc.revokeMine(alice.id, token.id, META); + + expect(McpTokenSchema.parse(revoked)).toEqual(revoked); + expect(revoked).toMatchObject({ + id: token.id, + status: 'revoked', + revokedAt: NOW.toISOString(), + revokedBy: alice.id, + revokeReason: 'manual', + }); + expect(await storedToken(token.id)).toMatchObject({ + revokedAt: NOW, + revokedBy: alice.id, + revokeReason: 'manual', + }); + expect(auditEntries(audit)).toEqual([ + { + actorId: alice.id, + actorType: 'admin', + action: 'iam.mcp_token.revoked', + resourceType: 'mcp-token', + resourceId: token.id, + before: { revokedAt: null }, + after: { + adminUserId: alice.id, + label: token.label, + tokenPrefix: token.tokenPrefix, + reason: 'manual', + }, + ...META, + }, + ]); + }); + + it("answers another admin's token as not found and leaves it active", async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const bob = await seedAdmin('bob@ops.example'); + const bobsToken = await seedToken(bob.id); + + await expect(svc.revokeMine(alice.id, bobsToken.id, META)).rejects.toBeInstanceOf( + McpTokenNotFoundError, + ); + + expect(await storedToken(bobsToken.id)).toMatchObject({ revokedAt: null, revokedBy: null }); + expect(auditEntries(audit)).toEqual([]); + }); + + it('answers an unknown token id as not found', async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + + await expect(svc.revokeMine(alice.id, randomUUID(), META)).rejects.toBeInstanceOf( + McpTokenNotFoundError, + ); + }); + + it('returns an already revoked token unchanged and writes no second audit record', async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const token = await seedToken(alice.id); + + const first = await svc.revokeMine(alice.id, token.id, META); + const second = await svc.revokeMine(alice.id, token.id, META); + + expect(second).toEqual(first); + expect(auditEntries(audit)).toHaveLength(1); + }); +}); + +describe('McpTokenService concurrent revocation (real PG)', () => { + it('revokes once and audits once when an owner and an overseer revoke together', async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const overseer = await seedAdmin('overseer@ops.example'); + const token = await seedToken(alice.id); + + const [mine, overseen, all] = await Promise.all([ + svc.revokeMine(alice.id, token.id, META), + svc.revoke(token.id, overseer.id, META), + svc.revokeAll(overseer.id, META), + ]); + + const [winner] = auditEntries(audit); + expect(auditEntries(audit)).toHaveLength(1); + expect(overseen).toEqual(mine); + expect(mine).toMatchObject({ status: 'revoked', revokeReason: winner?.after?.['reason'] }); + expect(all.revoked).toBe(winner?.after?.['reason'] === 'revoked_all' ? 1 : 0); + }); +}); + +describe('McpTokenService.revoke (real PG)', () => { + it("revokes any admin's token in the overseer's name, once", async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const overseer = await seedAdmin('overseer@ops.example'); + const token = await seedToken(alice.id); + + const revoked = await svc.revoke(token.id, overseer.id, META); + const repeated = await svc.revoke(token.id, overseer.id, META); + + expect(revoked).toMatchObject({ + status: 'revoked', + adminUserId: alice.id, + revokedBy: overseer.id, + revokeReason: 'manual', + }); + expect(repeated).toEqual(revoked); + expect(auditEntries(audit)).toEqual([ + expect.objectContaining({ actorId: overseer.id, actorType: 'admin', resourceId: token.id }), + ]); + }); + + it('answers an unknown token id as not found', async () => { + const { svc } = makeService(); + const overseer = await seedAdmin('overseer@ops.example'); + + await expect(svc.revoke(randomUUID(), overseer.id, META)).rejects.toBeInstanceOf( + McpTokenNotFoundError, + ); + }); +}); + +describe('McpTokenService.revokeAll (real PG)', () => { + it('revokes every active token, skips expired and revoked ones, and audits each', async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const bob = await seedAdmin('bob@ops.example'); + const overseer = await seedAdmin('overseer@ops.example'); + const active = [await seedToken(alice.id), await seedToken(alice.id), await seedToken(bob.id)]; + const expired = await seedToken(alice.id, { expiresAt: daysFromNow(-1) }); + const earlierRevokedAt = daysFromNow(-2); + const alreadyRevoked = await seedToken(bob.id, { + revokedAt: earlierRevokedAt, + revokedBy: bob.id, + revokeReason: 'manual', + }); + + const result = await svc.revokeAll(overseer.id, META); + + expect(result).toEqual({ revoked: 3 }); + for (const token of active) { + expect(await storedToken(token.id)).toMatchObject({ + revokedAt: NOW, + revokedBy: overseer.id, + revokeReason: 'revoked_all', + }); + } + expect(await storedToken(expired.id)).toMatchObject({ revokedAt: null, revokeReason: null }); + expect(await storedToken(alreadyRevoked.id)).toMatchObject({ + revokedAt: earlierRevokedAt, + revokedBy: bob.id, + revokeReason: 'manual', + }); + const entries = auditEntries(audit); + expect(audit.recordManyInTransaction).toHaveBeenCalledTimes(1); + expect(entries.map((entry) => entry.resourceId).sort()).toEqual( + active.map((token) => token.id).sort(), + ); + for (const entry of entries) { + expect(entry).toMatchObject({ + actorId: overseer.id, + actorType: 'admin', + action: 'iam.mcp_token.revoked', + after: { reason: 'revoked_all' }, + }); + } + }); +}); + +describe('McpTokenService.revokeAllForUser (real PG)', () => { + it("revokes only that user's active tokens with the given reason and actor", async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const bob = await seedAdmin('bob@ops.example'); + const overseer = await seedAdmin('overseer@ops.example'); + const alicesTokens = [await seedToken(alice.id), await seedToken(alice.id)]; + const bobsToken = await seedToken(bob.id); + + const result = await svc.revokeAllForUser({ + userId: alice.id, + reason: 'admin_disabled', + actorId: overseer.id, + ...META, + }); + + expect(result).toEqual({ revoked: 2 }); + for (const token of alicesTokens) { + expect(await storedToken(token.id)).toMatchObject({ + revokedAt: NOW, + revokedBy: overseer.id, + revokeReason: 'admin_disabled', + }); + } + expect(await storedToken(bobsToken.id)).toMatchObject({ revokedAt: null }); + expect(auditEntries(audit)).toHaveLength(2); + for (const entry of auditEntries(audit)) { + expect(entry).toMatchObject({ + actorId: overseer.id, + actorType: 'admin', + after: { adminUserId: alice.id, reason: 'admin_disabled' }, + ...META, + }); + } + }); + + it('records a revocation with no actor as a system revocation', async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const token = await seedToken(alice.id); + + await svc.revokeAllForUser({ userId: alice.id, reason: 'sessions_revoked', actorId: null }); + + expect(await storedToken(token.id)).toMatchObject({ + revokedBy: null, + revokeReason: 'sessions_revoked', + }); + expect(auditEntries(audit)).toEqual([ + expect.objectContaining({ + actorId: null, + actorType: 'system', + ip: null, + userAgent: null, + after: expect.objectContaining({ reason: 'sessions_revoked' }), + }), + ]); + }); + + it('revokes nothing and writes no audit record for a user without active tokens', async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + await seedToken(alice.id, { expiresAt: daysFromNow(-1) }); + + const result = await svc.revokeAllForUser({ + userId: alice.id, + reason: 'admin_role_removed', + actorId: null, + }); + + expect(result).toEqual({ revoked: 0 }); + expect(auditEntries(audit)).toEqual([]); + }); +}); + +describe('McpTokenService.list and listMine (real PG)', () => { + it("lists only the caller's tokens in listMine, each with its owning admin", async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example', 'Alice'); + const bob = await seedAdmin('bob@ops.example', 'Bob'); + await seedToken(alice.id); + await seedToken(alice.id); + await seedToken(bob.id); + + const result = await svc.listMine(alice.id, PAGE); + + expect(paginated(McpTokenListItemSchema).parse(result)).toEqual(result); + expect(result.total).toBe(2); + expect(result.items).toHaveLength(2); + for (const item of result.items) { + expect(item).not.toHaveProperty('tokenHash'); + expect(item).toMatchObject({ + adminUserId: alice.id, + status: 'active', + admin: { id: alice.id, email: 'alice@ops.example', name: 'Alice', isActive: true }, + }); + } + }); + + it('filters by status, counting only the matching tokens', async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const active = await seedToken(alice.id); + const expired = await seedToken(alice.id, { expiresAt: NOW }); + const revoked = await seedToken(alice.id, { + revokedAt: daysFromNow(-1), + revokeReason: 'manual', + }); + const revokedAfterExpiry = await seedToken(alice.id, { + expiresAt: daysFromNow(-3), + revokedAt: daysFromNow(-1), + revokeReason: 'manual', + }); + + const byStatus = async (status: 'active' | 'expired' | 'revoked') => { + const result = await svc.list({ ...PAGE, status }); + return { + total: result.total, + ids: result.items.map((item) => item.id).sort(), + statuses: [...new Set(result.items.map((item) => item.status))], + }; + }; + + expect(await byStatus('active')).toEqual({ + total: 1, + ids: [active.id], + statuses: ['active'], + }); + expect(await byStatus('expired')).toEqual({ + total: 1, + ids: [expired.id], + statuses: ['expired'], + }); + expect(await byStatus('revoked')).toEqual({ + total: 2, + ids: [revoked.id, revokedAfterExpiry.id].sort(), + statuses: ['revoked'], + }); + }); + + it('searches the label and the admin email, treating LIKE wildcards literally', async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const bob = await seedAdmin('bob@ops.example'); + const pipeline = await seedToken(alice.id, { label: 'CI pipeline' }); + const percent = await seedToken(alice.id, { label: 'Laptop 100%' }); + const underscore = await seedToken(alice.id, { label: 'desk_top' }); + const bobsToken = await seedToken(bob.id, { label: 'Workstation' }); + + const idsFor = async (search: string) => + (await svc.list({ ...PAGE, search })).items.map((item) => item.id).sort(); + + expect(await idsFor('PIPELINE')).toEqual([pipeline.id]); + expect(await idsFor('bob@ops')).toEqual([bobsToken.id]); + expect(await idsFor('%')).toEqual([percent.id]); + expect(await idsFor('_')).toEqual([underscore.id]); + }); + + it('narrows the overseer list to one admin and pages with the full total', async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const bob = await seedAdmin('bob@ops.example'); + await seedToken(alice.id); + await seedToken(alice.id); + await seedToken(bob.id); + + const secondPage = await svc.list({ page: 2, limit: 1, adminUserId: alice.id }); + + expect(secondPage.total).toBe(2); + expect(secondPage.items).toHaveLength(1); + expect(secondPage.items[0]).toMatchObject({ adminUserId: alice.id }); + }); + + it('sorts by the requested column in the requested direction', async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + await seedToken(alice.id, { callCount: 5, createdAt: daysFromNow(-3) }); + await seedToken(alice.id, { callCount: 1, createdAt: daysFromNow(-1) }); + await seedToken(alice.id, { callCount: 3, createdAt: daysFromNow(-2) }); + + const callCounts = async (query: Parameters[0]) => + (await svc.list(query)).items.map((item) => item.callCount); + + expect(await callCounts({ ...PAGE, sortBy: 'callCount', sortOrder: 'asc' })).toEqual([1, 3, 5]); + expect(await callCounts({ ...PAGE, sortBy: 'callCount', sortOrder: 'desc' })).toEqual([ + 5, 3, 1, + ]); + expect(await callCounts(PAGE)).toEqual([1, 3, 5]); + }); + + it('lists never-used tokens last whichever way it sorts by last use', async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + await seedToken(alice.id, { label: 'Never used' }); + await seedToken(alice.id, { label: 'Older', lastUsedAt: daysFromNow(-2), callCount: 1 }); + await seedToken(alice.id, { label: 'Newer', lastUsedAt: daysFromNow(-1), callCount: 1 }); + + const labels = async (sortOrder: 'asc' | 'desc') => + (await svc.list({ ...PAGE, sortBy: 'lastUsedAt', sortOrder })).items.map( + (item) => item.label, + ); + + expect(await labels('asc')).toEqual(['Older', 'Newer', 'Never used']); + expect(await labels('desc')).toEqual(['Newer', 'Older', 'Never used']); + }); + + it('keeps a token whose admin account is gone, with the admin details empty', async () => { + const { svc } = makeService(); + const goneAdminId = randomUUID(); + await seedToken(goneAdminId); + + const [item] = (await svc.list(PAGE)).items; + + expect(McpTokenListItemSchema.parse(item)).toEqual(item); + expect(item?.admin).toEqual({ id: goneAdminId, email: null, name: null, isActive: null }); + }); +}); + +describe("McpTokenService.revokeAllForUser in the caller's transaction (real PG)", () => { + it('rolls back with the caller and audits on that transaction', async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const token = await seedToken(alice.id); + let callerTx: unknown; + + await expect( + db.drizzle.db.transaction(async (tx) => { + callerTx = tx; + await svc.revokeAllForUser( + { userId: alice.id, reason: 'admin_disabled', actorId: null }, + tx, + ); + throw new Error('caller rolled back'); + }), + ).rejects.toThrow('caller rolled back'); + + expect(await storedToken(token.id)).toMatchObject({ revokedAt: null, revokeReason: null }); + expect(auditEntries(audit)).toHaveLength(1); + expect(audit.recordManyInTransaction).toHaveBeenCalledTimes(1); + expect(audit.recordManyInTransaction.mock.calls[0]?.[0]).toBe(callerTx); + }); + + it('commits with the caller', async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const token = await seedToken(alice.id); + + const result = await db.drizzle.db.transaction((tx) => + svc.revokeAllForUser({ userId: alice.id, reason: 'admin_disabled', actorId: null }, tx), + ); + + expect(result).toEqual({ revoked: 1 }); + expect(await storedToken(token.id)).toMatchObject({ revokeReason: 'admin_disabled' }); + }); +}); + +describe('McpTokenService.revokeAllForUsers (real PG)', () => { + it("revokes every listed user's active tokens and nobody else's", async () => { + const { svc } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const bob = await seedAdmin('bob@ops.example'); + const carol = await seedAdmin('carol@ops.example'); + const [alicesToken, bobsToken, carolsToken] = [ + await seedToken(alice.id), + await seedToken(bob.id), + await seedToken(carol.id), + ]; + + const result = await svc.revokeAllForUsers({ + userIds: [alice.id, bob.id], + reason: 'admin_role_removed', + actorId: null, + }); + + expect(result).toEqual({ revoked: 2 }); + expect(await storedToken(alicesToken.id)).toMatchObject({ revokeReason: 'admin_role_removed' }); + expect(await storedToken(bobsToken.id)).toMatchObject({ revokeReason: 'admin_role_removed' }); + expect(await storedToken(carolsToken.id)).toMatchObject({ revokedAt: null }); + }); + + it('revokes nothing for an empty list', async () => { + const { svc, audit } = makeService(); + const alice = await seedAdmin('alice@ops.example'); + const token = await seedToken(alice.id); + + expect( + await svc.revokeAllForUsers({ userIds: [], reason: 'admin_role_removed', actorId: null }), + ).toEqual({ revoked: 0 }); + expect(await storedToken(token.id)).toMatchObject({ revokedAt: null }); + expect(auditEntries(audit)).toEqual([]); + }); +}); + +describe('McpTokenService bulk revocation racing an issue (real PG)', () => { + it('revokes a token whose issue commits while a bulk revoke waits for it', async () => { + const alice = await seedAdmin('alice@ops.example'); + const overseer = await seedAdmin('overseer@ops.example'); + const issuer = makeService(); + let markInserted = () => {}; + let releaseIssue = () => {}; + const inserted = new Promise((resolve) => { + markInserted = resolve; + }); + const issueHeld = new Promise((resolve) => { + releaseIssue = resolve; + }); + issuer.audit.recordInTransaction.mockImplementationOnce(async () => { + markInserted(); + await issueHeld; + }); + + const issuing = issueAs(issuer.svc, alice, { label: 'In flight' }); + await inserted; + const revoking = makeService().svc.revokeAll(overseer.id, META); + await Promise.race([revoking, waitForTableLockWaiter(db, mcpToken)]); + releaseIssue(); + const [issued, revoked] = await Promise.all([issuing, revoking]); + + expect(revoked).toEqual({ revoked: 1 }); + expect(await storedToken(issued.id)).toMatchObject({ + revokedBy: overseer.id, + revokeReason: 'revoked_all', + }); + }); +}); + +describe('mcp_token schema (real PG)', () => { + it('refuses a revocation time without a reason, and a reason without a time', async () => { + const alice = await seedAdmin('alice@ops.example'); + const token = await seedToken(alice.id); + const violation = { + cause: expect.objectContaining({ + code: '23514', + constraint: 'mcp_token_revocation_complete', + }), + }; + + await expect( + db.drizzle.db.execute(sql`UPDATE ${mcpToken} SET revoked_at = now() WHERE id = ${token.id}`), + ).rejects.toMatchObject(violation); + await expect( + db.drizzle.db.execute( + sql`UPDATE ${mcpToken} SET revoke_reason = 'manual' WHERE id = ${token.id}`, + ), + ).rejects.toMatchObject(violation); + expect(await storedToken(token.id)).toMatchObject({ revokedAt: null, revokeReason: null }); + }); + + it('carries the revocation check and the status-filter index from its migrations', async () => { + const { rows: checks } = await db.drizzle.db.execute<{ def: string }>( + sql`select pg_get_constraintdef(oid) as def from pg_constraint + where conname = 'mcp_token_revocation_complete'`, + ); + const { rows: indexes } = await db.drizzle.db.execute<{ def: string }>( + sql`select indexdef as def from pg_indexes + where indexname = 'mcp_token_revoked_at_expires_at_idx'`, + ); + + expect(checks).toEqual([{ def: 'CHECK (((revoked_at IS NULL) = (revoke_reason IS NULL)))' }]); + expect(indexes).toEqual([ + { + def: 'CREATE INDEX mcp_token_revoked_at_expires_at_idx ON public.mcp_token USING btree (revoked_at, expires_at)', + }, + ]); + }); +}); diff --git a/packages/core/src/iam/__tests__/mcp-token.test.ts b/packages/core/src/iam/__tests__/mcp-token.test.ts new file mode 100644 index 000000000..8bae9884d --- /dev/null +++ b/packages/core/src/iam/__tests__/mcp-token.test.ts @@ -0,0 +1,185 @@ +import { randomUUID } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + generateMcpToken, + hashMcpToken, + isMcpTokenFormat, + mcpTokenAuthentication, + mcpTokenDisplayPrefix, + mcpTokenStatus, +} from '../shared/mcp-token.js'; + +const NOW = new Date('2026-10-06T12:00:00.000Z'); +const BEFORE_NOW = new Date('2026-10-06T11:59:59.999Z'); +const AFTER_NOW = new Date('2026-10-06T12:00:00.001Z'); +const SECRET_43 = 'A'.repeat(43); +const SHA256_OF_ABC = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'; + +const ISSUED_AT = new Date('2026-10-01T09:00:00.000Z'); +const BEFORE_ISSUE = new Date('2026-10-01T08:59:59.999Z'); +const AFTER_ISSUE = new Date('2026-10-01T09:00:00.001Z'); + +const lifecycle = (over: { expiresAt?: Date; revokedAt?: Date | null } = {}) => ({ + expiresAt: AFTER_NOW, + revokedAt: null, + ...over, +}); + +const stored = ({ + credentialsChangedAt = null, + ...over +}: { expiresAt?: Date; revokedAt?: Date | null; credentialsChangedAt?: Date | null } = {}) => ({ + id: randomUUID(), + adminUserId: randomUUID(), + createdAt: ISSUED_AT, + credentialsChangedAt, + ...lifecycle(over), +}); + +describe('generateMcpToken', () => { + it('issues the ora_mcp_ scheme followed by 43 base64url characters', () => { + const token = generateMcpToken(); + + expect(token).toMatch(/^ora_mcp_[A-Za-z0-9_-]{43}$/); + expect(isMcpTokenFormat(token)).toBe(true); + }); + + it('never issues the same token twice', () => { + const tokens = new Set(Array.from({ length: 50 }, generateMcpToken)); + + expect(tokens.size).toBe(50); + }); +}); + +describe('isMcpTokenFormat', () => { + it('accepts the scheme with a 43-character base64url secret', () => { + expect(isMcpTokenFormat(`ora_mcp_${SECRET_43}`)).toBe(true); + expect(isMcpTokenFormat(`ora_mcp_${'a_-9'.repeat(10)}xyz`)).toBe(true); + }); + + it.each([ + ['an empty string', ''], + ['the bare scheme', 'ora_mcp_'], + ['another scheme', `ora_api_${SECRET_43}`], + ['an upper-case scheme', `ORA_MCP_${SECRET_43}`], + ['a secret one character short', `ora_mcp_${'A'.repeat(42)}`], + ['a secret one character long', `ora_mcp_${'A'.repeat(44)}`], + ['standard base64 characters', `ora_mcp_${'A'.repeat(41)}+/`], + ['base64 padding', `ora_mcp_${'A'.repeat(42)}=`], + ['surrounding whitespace', ` ora_mcp_${SECRET_43} `], + ['a Bearer prefix', `Bearer ora_mcp_${SECRET_43}`], + ])('rejects %s', (_case, value) => { + expect(isMcpTokenFormat(value)).toBe(false); + }); +}); + +describe('hashMcpToken', () => { + it('is the lowercase hex SHA-256 of the UTF-8 token', () => { + expect(hashMcpToken('abc')).toBe(SHA256_OF_ABC); + }); + + it('gives a 64-character digest that differs from the token and between tokens', () => { + const token = generateMcpToken(); + + expect(hashMcpToken(token)).toMatch(/^[0-9a-f]{64}$/); + expect(hashMcpToken(token)).not.toBe(token); + expect(hashMcpToken(token)).not.toBe(hashMcpToken(generateMcpToken())); + }); +}); + +describe('mcpTokenDisplayPrefix', () => { + it('keeps the scheme and the first four secret characters', () => { + expect(mcpTokenDisplayPrefix(`ora_mcp_WXYZ${'A'.repeat(39)}`)).toBe('ora_mcp_WXYZ'); + }); +}); + +describe('mcpTokenStatus', () => { + it('is active until the expiry instant', () => { + expect(mcpTokenStatus(lifecycle({ expiresAt: AFTER_NOW }), NOW)).toBe('active'); + }); + + it('is expired from the expiry instant on', () => { + expect(mcpTokenStatus(lifecycle({ expiresAt: NOW }), NOW)).toBe('expired'); + expect(mcpTokenStatus(lifecycle({ expiresAt: BEFORE_NOW }), NOW)).toBe('expired'); + }); + + it('is revoked once revoked, whether or not it has expired', () => { + expect(mcpTokenStatus(lifecycle({ revokedAt: BEFORE_NOW }), NOW)).toBe('revoked'); + expect(mcpTokenStatus(lifecycle({ revokedAt: BEFORE_NOW, expiresAt: BEFORE_NOW }), NOW)).toBe( + 'revoked', + ); + }); +}); + +describe('mcpTokenAuthentication', () => { + it('answers unknown when no token is on file', () => { + expect(mcpTokenAuthentication(undefined, NOW)).toEqual({ ok: false, reason: 'unknown' }); + }); + + it('accepts an active token and names its admin', () => { + const token = stored(); + + expect(mcpTokenAuthentication(token, NOW)).toEqual({ + ok: true, + tokenId: token.id, + adminId: token.adminUserId, + }); + }); + + it('refuses an expired token, still naming it and its admin', () => { + const token = stored({ expiresAt: NOW }); + + expect(mcpTokenAuthentication(token, NOW)).toEqual({ + ok: false, + reason: 'expired', + tokenId: token.id, + adminId: token.adminUserId, + }); + }); + + it('refuses a revoked token as revoked even after it has expired', () => { + const token = stored({ revokedAt: BEFORE_NOW, expiresAt: BEFORE_NOW }); + + expect(mcpTokenAuthentication(token, NOW)).toEqual({ + ok: false, + reason: 'revoked', + tokenId: token.id, + adminId: token.adminUserId, + }); + }); + + it("refuses an unrevoked token issued before its admin's latest credential change", () => { + const token = stored({ credentialsChangedAt: AFTER_ISSUE }); + + expect(mcpTokenAuthentication(token, NOW)).toEqual({ + ok: false, + reason: 'credentials_changed', + tokenId: token.id, + adminId: token.adminUserId, + }); + }); + + it('refuses a token issued at the very instant of the credential change', () => { + expect(mcpTokenAuthentication(stored({ credentialsChangedAt: ISSUED_AT }), NOW)).toMatchObject({ + ok: false, + reason: 'credentials_changed', + }); + }); + + it('accepts a token issued after the latest credential change, or for an admin without one', () => { + expect( + mcpTokenAuthentication(stored({ credentialsChangedAt: BEFORE_ISSUE }), NOW), + ).toMatchObject({ ok: true }); + expect(mcpTokenAuthentication(stored({ credentialsChangedAt: null }), NOW)).toMatchObject({ + ok: true, + }); + }); + + it('reports a revoked or expired token as such even when it predates a credential change', () => { + const revoked = stored({ revokedAt: BEFORE_NOW, credentialsChangedAt: AFTER_ISSUE }); + const expired = stored({ expiresAt: NOW, credentialsChangedAt: AFTER_ISSUE }); + + expect(mcpTokenAuthentication(revoked, NOW)).toMatchObject({ reason: 'revoked' }); + expect(mcpTokenAuthentication(expired, NOW)).toMatchObject({ reason: 'expired' }); + }); +}); diff --git a/packages/core/src/iam/adapters/mcp-token-authenticator.ts b/packages/core/src/iam/adapters/mcp-token-authenticator.ts new file mode 100644 index 000000000..24bfb5409 --- /dev/null +++ b/packages/core/src/iam/adapters/mcp-token-authenticator.ts @@ -0,0 +1,50 @@ +import type { McpTokenAuthentication, McpTokenAuthenticator } from '@openora/core/contracts'; +import { DrizzleService } from '@openora/core/server'; +import { and, desc, eq, sql } from 'drizzle-orm'; +// Read-only cross-domain schema import (sanctioned): a token issued before its admin's latest +// credential change is refused. +import { account } from '@openora/core/pam/schema/identity'; +import { mcpToken, type McpToken } from '../schema/index.js'; +import { hashMcpToken, isMcpTokenFormat, mcpTokenAuthentication } from '../shared/mcp-token.js'; + +// better-auth stamps the email-and-password account's updatedAt in the statement that writes a +// new password hash, on a change and on a reset; a sign-in or a second factor leaves the row be. +const CREDENTIAL_PROVIDER_ID = 'credential'; + +export class DrizzleMcpTokenAuthenticator implements McpTokenAuthenticator { + constructor(private readonly drizzle: DrizzleService) {} + + async authenticate(bearer: string): Promise { + if (!isMcpTokenFormat(bearer)) { + return { ok: false, reason: 'unknown' }; + } + const [token] = await this.drizzle.db + .select({ + id: mcpToken.id, + adminUserId: mcpToken.adminUserId, + createdAt: mcpToken.createdAt, + expiresAt: mcpToken.expiresAt, + revokedAt: mcpToken.revokedAt, + credentialsChangedAt: account.updatedAt, + }) + .from(mcpToken) + .leftJoin( + account, + and( + eq(account.userId, mcpToken.adminUserId), + eq(account.providerId, CREDENTIAL_PROVIDER_ID), + ), + ) + .where(eq(mcpToken.tokenHash, hashMcpToken(bearer))) + .orderBy(desc(account.updatedAt)) + .limit(1); + return mcpTokenAuthentication(token, new Date()); + } + + async recordCall(tokenId: McpToken['id']) { + await this.drizzle.db + .update(mcpToken) + .set({ lastUsedAt: sql`now()`, callCount: sql`${mcpToken.callCount} + 1` }) + .where(eq(mcpToken.id, tokenId)); + } +} diff --git a/packages/core/src/iam/contract/index.ts b/packages/core/src/iam/contract/index.ts index c440c1fd8..052820033 100644 --- a/packages/core/src/iam/contract/index.ts +++ b/packages/core/src/iam/contract/index.ts @@ -7,7 +7,9 @@ import { UuidSchema, } from '@openora/core/contracts'; import { PageQuerySchema, SortOrderSchema, paginated } from '@openora/core/contracts/kit'; +import { mcpTokenContract } from './mcp-token.js'; +export * from './mcp-token.js'; export { InvitationStatusSchema, PermissionLevelSchema } from '@openora/core/contracts'; export const AdminRoleSchema = z.object({ @@ -194,6 +196,8 @@ export const iamContract = { .route({ method: 'POST', path: '/iam/report-access-denied' }) .input(GrantInputSchema) .output(z.object({ recorded: z.boolean() })), + + mcpTokens: mcpTokenContract, }; export type AdminRole = z.infer; diff --git a/packages/core/src/iam/contract/mcp-token.ts b/packages/core/src/iam/contract/mcp-token.ts new file mode 100644 index 000000000..046291e36 --- /dev/null +++ b/packages/core/src/iam/contract/mcp-token.ts @@ -0,0 +1,119 @@ +import { oc } from '@orpc/contract'; +import * as z from 'zod'; +import { + MCP_TOKEN_TTL_CEILING_DAYS, + McpTokenRevokeReasonSchema, + TimestampSchema, + UuidSchema, +} from '@openora/core/contracts'; +import { PageQuerySchema, SortOrderSchema, paginated } from '@openora/core/contracts/kit'; + +export { McpTokenRevokeReasonSchema } from '@openora/core/contracts'; + +export const MCP_TOKEN_STATUSES = ['active', 'expired', 'revoked'] as const; +export const McpTokenStatusSchema = z.enum(MCP_TOKEN_STATUSES); +export type McpTokenStatus = z.infer; + +export const MCP_TOKEN_ERROR_REASONS = [ + 'mcp_disabled', + 'ttl_exceeds_max', + 'owner_ineligible', + 'token_limit', +] as const; +export const McpTokenErrorReasonSchema = z.enum(MCP_TOKEN_ERROR_REASONS); +export type McpTokenErrorReason = z.infer; + +export const MCP_TOKEN_SORT_BY_VALUES = [ + 'createdAt', + 'expiresAt', + 'lastUsedAt', + 'callCount', +] as const; +export const McpTokenSortBySchema = z.enum(MCP_TOKEN_SORT_BY_VALUES).default('createdAt'); +export type McpTokenSortBy = z.infer; + +export const McpTokenSchema = z.object({ + id: UuidSchema, + label: z.string(), + tokenPrefix: z.string(), + adminUserId: UuidSchema, + createdAt: TimestampSchema, + expiresAt: TimestampSchema, + lastUsedAt: TimestampSchema.nullable(), + callCount: z.number().int(), + status: McpTokenStatusSchema, + revokedAt: TimestampSchema.nullable(), + revokedBy: UuidSchema.nullable(), + revokeReason: McpTokenRevokeReasonSchema.nullable(), +}); + +// Every field but the id is null once the admin's account no longer exists. +export const McpTokenAdminSchema = z.object({ + id: UuidSchema, + email: z.string().nullable(), + name: z.string().nullable(), + isActive: z.boolean().nullable(), +}); + +export const McpTokenListItemSchema = McpTokenSchema.extend({ admin: McpTokenAdminSchema }); + +/** The only response that carries the plaintext token; it cannot be read back afterwards. */ +export const IssuedMcpTokenSchema = McpTokenSchema.extend({ token: z.string() }); + +export const CreateMcpTokenInputSchema = z.object({ + label: z.string().trim().min(1).max(64), + ttlDays: z.number().int().min(1).max(MCP_TOKEN_TTL_CEILING_DAYS).optional(), +}); + +export const ListMyMcpTokensInputSchema = PageQuerySchema.extend({ + status: McpTokenStatusSchema.optional(), + sortBy: McpTokenSortBySchema.optional(), + sortOrder: SortOrderSchema.default('desc').optional(), +}); + +export const ListMcpTokensInputSchema = ListMyMcpTokensInputSchema.extend({ + adminUserId: UuidSchema.optional(), + search: z.string().trim().min(1).max(100).optional(), +}); + +export const McpTokenIdInputSchema = z.object({ tokenId: UuidSchema }); + +export const RevokeAllMcpTokensOutputSchema = z.object({ revoked: z.number().int() }); + +export const mcpTokenContract = { + create: oc + .route({ method: 'POST', path: '/iam/my-mcp-tokens' }) + .input(CreateMcpTokenInputSchema) + .output(IssuedMcpTokenSchema), + + listMine: oc + .route({ method: 'GET', path: '/iam/my-mcp-tokens' }) + .input(ListMyMcpTokensInputSchema) + .output(paginated(McpTokenListItemSchema)), + + revokeMine: oc + .route({ method: 'POST', path: '/iam/my-mcp-tokens/{tokenId}/revoke' }) + .input(McpTokenIdInputSchema) + .output(McpTokenSchema), + + list: oc + .route({ method: 'GET', path: '/iam/mcp-tokens' }) + .input(ListMcpTokensInputSchema) + .output(paginated(McpTokenListItemSchema)), + + revoke: oc + .route({ method: 'POST', path: '/iam/mcp-tokens/{tokenId}/revoke' }) + .input(McpTokenIdInputSchema) + .output(McpTokenSchema), + + revokeAll: oc + .route({ method: 'POST', path: '/iam/mcp-tokens/revoke-all' }) + .output(RevokeAllMcpTokensOutputSchema), +}; + +export type McpToken = z.infer; +export type McpTokenListItem = z.infer; +export type IssuedMcpToken = z.infer; +export type CreateMcpTokenInput = z.infer; +export type ListMyMcpTokensInput = z.infer; +export type ListMcpTokensInput = z.infer; diff --git a/packages/core/src/iam/drizzle/migrations/0001_lethal_hiroim.sql b/packages/core/src/iam/drizzle/migrations/0001_lethal_hiroim.sql new file mode 100644 index 000000000..ae966318a --- /dev/null +++ b/packages/core/src/iam/drizzle/migrations/0001_lethal_hiroim.sql @@ -0,0 +1,18 @@ +CREATE TYPE "public"."mcp_token_revoke_reason" AS ENUM('manual', 'admin_disabled', 'admin_role_removed', 'sessions_revoked', 'revoked_all');--> statement-breakpoint +CREATE TABLE "mcp_token" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "admin_user_id" uuid NOT NULL, + "label" text NOT NULL, + "token_hash" text NOT NULL, + "token_prefix" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone, + "revoked_by" uuid, + "revoke_reason" "mcp_token_revoke_reason", + "last_used_at" timestamp with time zone, + "call_count" integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "mcp_token_token_hash_uq" ON "mcp_token" USING btree ("token_hash");--> statement-breakpoint +CREATE INDEX "mcp_token_admin_user_id_idx" ON "mcp_token" USING btree ("admin_user_id"); \ No newline at end of file diff --git a/packages/core/src/iam/drizzle/migrations/0002_aspiring_red_ghost.sql b/packages/core/src/iam/drizzle/migrations/0002_aspiring_red_ghost.sql new file mode 100644 index 000000000..0f5afbc90 --- /dev/null +++ b/packages/core/src/iam/drizzle/migrations/0002_aspiring_red_ghost.sql @@ -0,0 +1,2 @@ +CREATE INDEX "mcp_token_revoked_at_expires_at_idx" ON "mcp_token" USING btree ("revoked_at","expires_at");--> statement-breakpoint +ALTER TABLE "mcp_token" ADD CONSTRAINT "mcp_token_revocation_complete" CHECK (("mcp_token"."revoked_at" IS NULL) = ("mcp_token"."revoke_reason" IS NULL)); \ No newline at end of file diff --git a/packages/core/src/iam/drizzle/migrations/meta/0001_snapshot.json b/packages/core/src/iam/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..717fe641a --- /dev/null +++ b/packages/core/src/iam/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,508 @@ +{ + "id": "c1f46c15-6701-4869-8ed9-889acadfda9c", + "prevId": "28077ace-125b-4e04-9ba3-d03ea55ff67d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_invitation": { + "name": "admin_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_invitation_token_idx": { + "name": "admin_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_invitation_role_id_admin_role_id_fk": { + "name": "admin_invitation_role_id_admin_role_id_fk", + "tableFrom": "admin_invitation", + "tableTo": "admin_role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_invitation_token_unique": { + "name": "admin_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_role": { + "name": "admin_role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_super_admin": { + "name": "is_super_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_role_key_uq": { + "name": "admin_role_key_uq", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_role_assignment": { + "name": "admin_role_assignment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_role_assignment_user_id_idx": { + "name": "admin_role_assignment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_role_assignment_uq": { + "name": "admin_role_assignment_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_role_assignment_role_id_admin_role_id_fk": { + "name": "admin_role_assignment_role_id_admin_role_id_fk", + "tableFrom": "admin_role_assignment", + "tableTo": "admin_role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_role_permission": { + "name": "admin_role_permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'no_access'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_role_permission_role_id_idx": { + "name": "admin_role_permission_role_id_idx", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_role_permission_role_resource_uq": { + "name": "admin_role_permission_role_resource_uq", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_role_permission_role_id_admin_role_id_fk": { + "name": "admin_role_permission_role_id_admin_role_id_fk", + "tableFrom": "admin_role_permission", + "tableTo": "admin_role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_token": { + "name": "mcp_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "admin_user_id": { + "name": "admin_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoke_reason": { + "name": "revoke_reason", + "type": "mcp_token_revoke_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "call_count": { + "name": "call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "mcp_token_token_hash_uq": { + "name": "mcp_token_token_hash_uq", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_token_admin_user_id_idx": { + "name": "mcp_token_admin_user_id_idx", + "columns": [ + { + "expression": "admin_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "revoked"] + }, + "public.mcp_token_revoke_reason": { + "name": "mcp_token_revoke_reason", + "schema": "public", + "values": [ + "manual", + "admin_disabled", + "admin_role_removed", + "sessions_revoked", + "revoked_all" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/iam/drizzle/migrations/meta/0002_snapshot.json b/packages/core/src/iam/drizzle/migrations/meta/0002_snapshot.json new file mode 100644 index 000000000..485280d25 --- /dev/null +++ b/packages/core/src/iam/drizzle/migrations/meta/0002_snapshot.json @@ -0,0 +1,534 @@ +{ + "id": "543883e7-8b10-4caf-8fb9-6a305c0aab0d", + "prevId": "c1f46c15-6701-4869-8ed9-889acadfda9c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_invitation": { + "name": "admin_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_invitation_token_idx": { + "name": "admin_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_invitation_role_id_admin_role_id_fk": { + "name": "admin_invitation_role_id_admin_role_id_fk", + "tableFrom": "admin_invitation", + "tableTo": "admin_role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_invitation_token_unique": { + "name": "admin_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_role": { + "name": "admin_role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_super_admin": { + "name": "is_super_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_role_key_uq": { + "name": "admin_role_key_uq", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_role_assignment": { + "name": "admin_role_assignment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_role_assignment_user_id_idx": { + "name": "admin_role_assignment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_role_assignment_uq": { + "name": "admin_role_assignment_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_role_assignment_role_id_admin_role_id_fk": { + "name": "admin_role_assignment_role_id_admin_role_id_fk", + "tableFrom": "admin_role_assignment", + "tableTo": "admin_role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_role_permission": { + "name": "admin_role_permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'no_access'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_role_permission_role_id_idx": { + "name": "admin_role_permission_role_id_idx", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_role_permission_role_resource_uq": { + "name": "admin_role_permission_role_resource_uq", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_role_permission_role_id_admin_role_id_fk": { + "name": "admin_role_permission_role_id_admin_role_id_fk", + "tableFrom": "admin_role_permission", + "tableTo": "admin_role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_token": { + "name": "mcp_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "admin_user_id": { + "name": "admin_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoke_reason": { + "name": "revoke_reason", + "type": "mcp_token_revoke_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "call_count": { + "name": "call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "mcp_token_token_hash_uq": { + "name": "mcp_token_token_hash_uq", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_token_admin_user_id_idx": { + "name": "mcp_token_admin_user_id_idx", + "columns": [ + { + "expression": "admin_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_token_revoked_at_expires_at_idx": { + "name": "mcp_token_revoked_at_expires_at_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_token_revocation_complete": { + "name": "mcp_token_revocation_complete", + "value": "(\"mcp_token\".\"revoked_at\" IS NULL) = (\"mcp_token\".\"revoke_reason\" IS NULL)" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "revoked"] + }, + "public.mcp_token_revoke_reason": { + "name": "mcp_token_revoke_reason", + "schema": "public", + "values": [ + "manual", + "admin_disabled", + "admin_role_removed", + "sessions_revoked", + "revoked_all" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/iam/drizzle/migrations/meta/_journal.json b/packages/core/src/iam/drizzle/migrations/meta/_journal.json index 1a827874c..1f450a413 100644 --- a/packages/core/src/iam/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/iam/drizzle/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1783555225068, "tag": "0000_peaceful_thor", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791286759105, + "tag": "0001_lethal_hiroim", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1791365938800, + "tag": "0002_aspiring_red_ghost", + "breakpoints": true } ] } diff --git a/packages/core/src/iam/plugin.ts b/packages/core/src/iam/plugin.ts index c72a944d6..384323c37 100644 --- a/packages/core/src/iam/plugin.ts +++ b/packages/core/src/iam/plugin.ts @@ -1,26 +1,42 @@ import { EVENT_BUS, DRIZZLE, ADMIN_GUARD, createLogger } from '@openora/core/server'; -import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; +import type { CoreTokenCatalog, Plugin, TypedContainer } from '@openora/core/server'; import { ADMIN_PERMISSION_RESOLVER, ADMIN_PLAYER_ACTIVITY, ADMIN_ROLE_ASSIGNMENT_DIRECTORY, + AUDIT_WRITER, IDENTITY_READER, MAIL_DISPATCH, + MCP_TOKEN_AUTHENTICATOR, + MCP_TOKEN_REVOCATION, + PLATFORM_CONFIG, SESSION_COMMANDS, CACHE, RATE_LIMITER, domainEventSchemas, } from '@openora/core/contracts'; import { IamService, DbAdminPermissionResolver } from './service/iam.service.js'; +import { McpTokenService } from './service/mcp-token.service.js'; import { createIamRouter } from './router/index.js'; import { DrizzleAdminPlayerActivity } from './adapters/admin-player-activity.js'; import { DrizzleAdminRoleAssignmentDirectory } from './adapters/admin-role-assignment-directory.js'; +import { DrizzleMcpTokenAuthenticator } from './adapters/mcp-token-authenticator.js'; const logger = createLogger('iam'); +function makeMcpTokenService(c: TypedContainer) { + return new McpTokenService({ + drizzle: c.get(DRIZZLE), + audit: c.get(AUDIT_WRITER), + events: c.get(EVENT_BUS), + rateLimiter: c.get(RATE_LIMITER), + config: c.get(PLATFORM_CONFIG).agents.mcp, + }); +} + export default { id: 'iam', - dependsOn: ['identity'], + dependsOn: ['identity', 'audit'], requiresPorts: [MAIL_DISPATCH], register(ctx) { // Captured from the provider factory so the event handlers below purge the SAME @@ -67,19 +83,24 @@ export default { ADMIN_ROLE_ASSIGNMENT_DIRECTORY, (c) => new DrizzleAdminRoleAssignmentDirectory(c.get(DRIZZLE)), ); + ctx.provide(MCP_TOKEN_AUTHENTICATOR, (c) => new DrizzleMcpTokenAuthenticator(c.get(DRIZZLE))); + ctx.provide(MCP_TOKEN_REVOCATION, (c) => makeMcpTokenService(c)); - ctx.routers.add('iam', (c) => - createIamRouter( - new IamService( - c.get(DRIZZLE), - c.get(EVENT_BUS), - c.get(MAIL_DISPATCH), - c.get(IDENTITY_READER), - c.get(SESSION_COMMANDS), - c.get(RATE_LIMITER), - ), + ctx.routers.add('iam', (c) => { + const mcpTokens = makeMcpTokenService(c); + return createIamRouter( + new IamService({ + drizzle: c.get(DRIZZLE), + events: c.get(EVENT_BUS), + mailDispatch: c.get(MAIL_DISPATCH), + identityReader: c.get(IDENTITY_READER), + mcpTokens, + sessionCommands: c.get(SESSION_COMMANDS), + rateLimiter: c.get(RATE_LIMITER), + }), c.get(ADMIN_GUARD), - ), - ); + mcpTokens, + ); + }); }, } as const satisfies Plugin; diff --git a/packages/core/src/iam/router/index.ts b/packages/core/src/iam/router/index.ts index 6b22a118d..214511c00 100644 --- a/packages/core/src/iam/router/index.ts +++ b/packages/core/src/iam/router/index.ts @@ -1,5 +1,5 @@ import { implement } from '@orpc/server'; -import { AdminGuard, mapErrors, type OssContext } from '@openora/core/server'; +import { AdminGuard, getSessionId, mapErrors, type OssContext } from '@openora/core/server'; import { iamContract } from '../contract/index.js'; import { IamService, @@ -14,8 +14,20 @@ import { AdminUserNotFoundError, NotAnAdminUserError, } from '../service/iam.service.js'; - -export function createIamRouter(svc: IamService, adminGuard: AdminGuard) { +import { + McpTokenService, + McpTokenLimitError, + McpTokenNotFoundError, + McpTokenOwnerIneligibleError, + McpTokenTtlError, + McpTransportDisabledError, +} from '../service/mcp-token.service.js'; + +export function createIamRouter( + svc: IamService, + adminGuard: AdminGuard, + mcpTokens: McpTokenService, +) { const os = implement(iamContract).$context(); // Error map for the super-admin-only mutation routes. NotSuperAdminError and @@ -27,6 +39,13 @@ export function createIamRouter(svc: IamService, adminGuard: AdminGuard) { CONFLICT: [ProtectedRoleError, LastSuperAdminError], }; + const mcpTokenErrors = { + NOT_FOUND: McpTokenNotFoundError, + BAD_REQUEST: McpTokenTtlError, + FORBIDDEN: McpTokenOwnerIneligibleError, + CONFLICT: [McpTransportDisabledError, McpTokenLimitError], + }; + return os.router({ listCatalog: os.listCatalog.handler(async ({ context }) => { await adminGuard.assert(context, 'admin', 'view'); @@ -119,5 +138,44 @@ export function createIamRouter(svc: IamService, adminGuard: AdminGuard) { const caller = await adminGuard.assert(context); return svc.reportAccessDenied({ ...input, caller }); }), + + mcpTokens: { + create: os.mcpTokens.create.handler(async ({ input, context }) => { + const caller = await adminGuard.assert(context, 'mcp-access', 'use'); + const sessionId = getSessionId(context) ?? null; + return mapErrors(mcpTokenErrors, () => + mcpTokens.create({ ...input, adminUserId: caller.userId, sessionId }, caller), + ); + }), + + listMine: os.mcpTokens.listMine.handler(async ({ input, context }) => { + const caller = await adminGuard.assert(context, 'mcp-access', 'use'); + return mcpTokens.listMine(caller.userId, input); + }), + + revokeMine: os.mcpTokens.revokeMine.handler(async ({ input, context }) => { + const caller = await adminGuard.assert(context, 'mcp-access', 'use'); + return mapErrors(mcpTokenErrors, () => + mcpTokens.revokeMine(caller.userId, input.tokenId, caller), + ); + }), + + list: os.mcpTokens.list.handler(async ({ input, context }) => { + await adminGuard.assert(context, 'mcp-token', 'view'); + return mcpTokens.list(input); + }), + + revoke: os.mcpTokens.revoke.handler(async ({ input, context }) => { + const caller = await adminGuard.assert(context, 'mcp-token', 'revoke'); + return mapErrors(mcpTokenErrors, () => + mcpTokens.revoke(input.tokenId, caller.userId, caller), + ); + }), + + revokeAll: os.mcpTokens.revokeAll.handler(async ({ context }) => { + const caller = await adminGuard.assert(context, 'mcp-token', 'revoke'); + return mcpTokens.revokeAll(caller.userId, caller); + }), + }, }); } diff --git a/packages/core/src/iam/schema/index.ts b/packages/core/src/iam/schema/index.ts index 756165d8c..7d9e5243d 100644 --- a/packages/core/src/iam/schema/index.ts +++ b/packages/core/src/iam/schema/index.ts @@ -1,8 +1,11 @@ -import { invitationStatuses } from '@openora/core/contracts'; +import { invitationStatuses, MCP_TOKEN_REVOKE_REASONS } from '@openora/core/contracts'; +import { sql } from 'drizzle-orm'; import { boolean, + check, foreignKey, index, + integer, pgEnum, pgTable, text, @@ -12,6 +15,7 @@ import { } from 'drizzle-orm/pg-core'; export const invitationStatusEnum = pgEnum('invitation_status', invitationStatuses); +export const mcpTokenRevokeReasonEnum = pgEnum('mcp_token_revoke_reason', MCP_TOKEN_REVOKE_REASONS); export const adminRole = pgTable( 'admin_role', @@ -85,7 +89,35 @@ export const adminInvitation = pgTable( ], ); +export const mcpToken = pgTable( + 'mcp_token', + { + id: uuid().primaryKey().defaultRandom(), + adminUserId: uuid().notNull(), + label: text().notNull(), + tokenHash: text().notNull(), + tokenPrefix: text().notNull(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp({ withTimezone: true }).notNull(), + revokedAt: timestamp({ withTimezone: true }), + revokedBy: uuid(), + revokeReason: mcpTokenRevokeReasonEnum(), + lastUsedAt: timestamp({ withTimezone: true }), + callCount: integer().notNull().default(0), + }, + (t) => [ + uniqueIndex('mcp_token_token_hash_uq').on(t.tokenHash), + index('mcp_token_admin_user_id_idx').on(t.adminUserId), + index('mcp_token_revoked_at_expires_at_idx').on(t.revokedAt, t.expiresAt), + check( + 'mcp_token_revocation_complete', + sql`(${t.revokedAt} IS NULL) = (${t.revokeReason} IS NULL)`, + ), + ], +); + export type AdminRole = typeof adminRole.$inferSelect; export type AdminRolePermission = typeof adminRolePermission.$inferSelect; export type AdminRoleAssignment = typeof adminRoleAssignment.$inferSelect; export type AdminInvitation = typeof adminInvitation.$inferSelect; +export type McpToken = typeof mcpToken.$inferSelect; diff --git a/packages/core/src/iam/seed/data/default-admin-roles.ts b/packages/core/src/iam/seed/data/default-admin-roles.ts index 50b714fd1..0ed8dadde 100644 --- a/packages/core/src/iam/seed/data/default-admin-roles.ts +++ b/packages/core/src/iam/seed/data/default-admin-roles.ts @@ -51,6 +51,8 @@ export const DEFAULT_ADMIN_ROLES: readonly DefaultAdminRole[] = [ 'chat-room': RW, 'chat-command': RW, 'chat-moderation': RW, + 'mcp-access': RW, + 'mcp-token': RW, }, }, { diff --git a/packages/core/src/iam/server.ts b/packages/core/src/iam/server.ts index 6039be491..dd717dba5 100644 --- a/packages/core/src/iam/server.ts +++ b/packages/core/src/iam/server.ts @@ -12,5 +12,14 @@ export { AdminUserNotFoundError, NotAnAdminUserError, } from './service/iam.service.js'; +export { + McpTokenService, + McpTokenIssueError, + McpTokenLimitError, + McpTokenNotFoundError, + McpTokenOwnerIneligibleError, + McpTokenTtlError, + McpTransportDisabledError, +} from './service/mcp-token.service.js'; export { createIamRouter } from './router/index.js'; export { default } from './plugin.js'; diff --git a/packages/core/src/iam/service/iam.service.ts b/packages/core/src/iam/service/iam.service.ts index 149bedb2c..688e61a88 100644 --- a/packages/core/src/iam/service/iam.service.ts +++ b/packages/core/src/iam/service/iam.service.ts @@ -10,12 +10,12 @@ import { statement, readActions, SUPPORTED_LEVELS, - levelToActions, levelRank, isLevelSufficient, cached, invalidate, createLogger, + type DrizzleTx, type ResourceName, type PermissionLevel, } from '@openora/core/server'; @@ -50,6 +50,8 @@ import type { IamRoleSortBy, IamInvitationSortBy, } from '../contract/index.js'; +import { loadAdminGrants, usersWithoutMcpAccess } from '../shared/admin-grants.js'; +import type { McpTokenService } from './mcp-token.service.js'; const logger = createLogger('iam-service'); @@ -195,40 +197,7 @@ export class DbAdminPermissionResolver implements AdminPermissionResolver { } private async loadGrants(userId: User['id']): Promise { - // One indexed join (on admin_role_assignment_user_id_idx) replaces the old - // 2 + N-per-role fan-out. leftJoin keeps super-admin roles (no permission rows) - // and empty roles in the result. - const rows = await this.drizzle.db - .select({ - isSuperAdmin: adminRole.isSuperAdmin, - resource: adminRolePermission.resource, - level: adminRolePermission.level, - }) - .from(adminRoleAssignment) - .innerJoin(adminRole, eq(adminRole.id, adminRoleAssignment.roleId)) - .leftJoin(adminRolePermission, eq(adminRolePermission.roleId, adminRole.id)) - .where(eq(adminRoleAssignment.userId, userId)); - - if (rows.some((r) => r.isSuperAdmin)) { - return allGrants(); - } - - const seen = new Set(); - const grants: AdminGrant[] = []; - for (const r of rows) { - if (!r.resource || !r.level) { - continue; - } // leftJoin null: role with no permission rows - for (const action of levelToActions(r.resource, r.level as PermissionLevel)) { - const key = `${r.resource}:${action}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - grants.push({ resource: r.resource, action }); - } - } - return grants; + return (await loadAdminGrants(this.drizzle.db, [userId])).get(userId) ?? []; } /** Drop one user's cached grants - called on assign/revoke so revocation is instant. */ @@ -251,24 +220,70 @@ export class DbAdminPermissionResolver implements AdminPermissionResolver { } } -function allGrants() { - return (Object.keys(statement) as ResourceName[]).flatMap((resource) => - (statement[resource] as readonly string[]).map((action) => ({ - resource: resource as string, - action, - })), - ); -} +export type IamServiceDeps = { + drizzle: DrizzleService; + events: EventBus; + mailDispatch: MailDispatchPort; + identityReader: IdentityReader; + mcpTokens: McpTokenService; + sessionCommands?: SessionCommands | undefined; + rateLimiter?: RateLimiterAdapter | undefined; +}; export class IamService { - constructor( - private readonly drizzle: DrizzleService, - private readonly events: EventBus, - private readonly mailDispatch: MailDispatchPort, - private readonly identityReader: IdentityReader, - private readonly sessionCommands?: SessionCommands, - private readonly rateLimiter?: RateLimiterAdapter, - ) {} + private readonly drizzle: DrizzleService; + private readonly events: EventBus; + private readonly mailDispatch: MailDispatchPort; + private readonly identityReader: IdentityReader; + private readonly mcpTokens: McpTokenService; + private readonly sessionCommands: SessionCommands | undefined; + private readonly rateLimiter: RateLimiterAdapter | undefined; + + constructor({ + drizzle, + events, + mailDispatch, + identityReader, + mcpTokens, + sessionCommands, + rateLimiter, + }: IamServiceDeps) { + this.drizzle = drizzle; + this.events = events; + this.mailDispatch = mailDispatch; + this.identityReader = identityReader; + this.mcpTokens = mcpTokens; + this.sessionCommands = sessionCommands; + this.rateLimiter = rateLimiter; + } + + /** + * Runs a grant change in one transaction and, in it, revokes the MCP tokens of whichever of the + * users it names no longer hold `mcp-access:use`. The revocation lock is taken first, so grant + * changes run one at a time and each reads the grants every earlier one committed. + */ + private changeGrants( + caller: Caller, + change: (tx: DrizzleTx) => Promise<{ result: T; affectedUserIds: readonly User['id'][] }>, + ) { + return this.drizzle.db.transaction(async (tx) => { + await this.mcpTokens.lockForRevocation(tx); + const { result, affectedUserIds } = await change(tx); + if (affectedUserIds.length > 0) { + await this.mcpTokens.revokeAllForUsers( + { + userIds: await usersWithoutMcpAccess(tx, affectedUserIds), + reason: 'admin_role_removed', + actorId: caller.userId, + ip: caller.ip, + userAgent: caller.userAgent, + }, + tx, + ); + } + return result; + }); + } private callerGrants(caller: Caller) { return new DbAdminPermissionResolver(this.drizzle).getGrants(caller.userId); @@ -437,7 +452,7 @@ export class IamService { // The three child FKs are ON DELETE CASCADE, so deleting the role removes permission, // assignment, and invitation rows automatically. Holder rows are read FOR UPDATE inside // the tx so a concurrent assign cannot slip a row past the audit. - const affectedUserIds = await this.drizzle.db.transaction(async (txn) => { + const affectedUserIds = await this.changeGrants(input.caller, async (txn) => { const holders = await txn .select({ userId: adminRoleAssignment.userId }) .from(adminRoleAssignment) @@ -446,7 +461,8 @@ export class IamService { await txn.delete(adminRole).where(eq(adminRole.id, input.roleId)); - return holders.map((h) => h.userId); + const holderIds = holders.map((h) => h.userId); + return { result: holderIds, affectedUserIds: holderIds }; }); // One revoke per affected admin so each lost-role is individually attributable in the audit log. @@ -501,7 +517,7 @@ export class IamService { // Replace in one transaction - a crash or concurrent call must not leave the role at no_access. const persist = input.grants.filter((g) => g.level !== 'no_access'); - const { before, after } = await this.drizzle.db.transaction(async (txn) => { + const { before, after } = await this.changeGrants(input.caller, async (txn) => { const beforeRows = await txn .select({ resource: adminRolePermission.resource, level: adminRolePermission.level }) .from(adminRolePermission) @@ -520,9 +536,17 @@ export class IamService { .from(adminRolePermission) .where(eq(adminRolePermission.roleId, input.roleId)); + const holders = await txn + .select({ userId: adminRoleAssignment.userId }) + .from(adminRoleAssignment) + .where(eq(adminRoleAssignment.roleId, input.roleId)); + const toLevels = (rows: { resource: string; level: string }[]) => rows.map((r) => ({ resource: r.resource, level: r.level as PermissionLevel })); - return { before: toLevels(beforeRows), after: toLevels(afterRows) }; + return { + result: { before: toLevels(beforeRows), after: toLevels(afterRows) }, + affectedUserIds: holders.map((h) => h.userId), + }; }); this.events.emit('iam.role.permissions.changed', { @@ -568,13 +592,17 @@ export class IamService { return toAssignmentDto(existing); } - const row = findOneOrThrow( - await this.drizzle.db - .insert(adminRoleAssignment) - .values({ userId: input.userId, roleId: input.roleId }) - .returning(), - new RoleNotFoundError(input.roleId), - ); + // A first assignment replaces the static role table's grants with the role's own. + const row = await this.changeGrants(input.caller, async (txn) => ({ + result: findOneOrThrow( + await txn + .insert(adminRoleAssignment) + .values({ userId: input.userId, roleId: input.roleId }) + .returning(), + new RoleNotFoundError(input.roleId), + ), + affectedUserIds: [input.userId], + })); const dto = toAssignmentDto(row); this.events.emit('iam.role.assigned', { roleId: input.roleId, @@ -599,7 +627,7 @@ export class IamService { // Count-then-delete must be atomic (TOCTOU). Lock super-admin holder rows FOR UPDATE // so a concurrent unassign blocks until after the first commits. - const deleted = await this.drizzle.db.transaction(async (txn) => { + const deleted = await this.changeGrants(input.caller, async (txn) => { if (role.isSuperAdmin) { const superRoleRows = await txn .select({ id: adminRole.id }) @@ -628,7 +656,8 @@ export class IamService { ), ) .returning({ id: adminRoleAssignment.id }); - return removed.length > 0; + const wasAssigned = removed.length > 0; + return { result: wasAssigned, affectedUserIds: wasAssigned ? [input.userId] : [] }; }); // No-op unassign returns success but emits nothing - no ghost revocation in the audit log. diff --git a/packages/core/src/iam/service/mcp-token.service.ts b/packages/core/src/iam/service/mcp-token.service.ts new file mode 100644 index 000000000..f386a2d2a --- /dev/null +++ b/packages/core/src/iam/service/mcp-token.service.ts @@ -0,0 +1,543 @@ +import { ORPCError } from '@orpc/server'; +import { DatabaseError } from 'pg'; +import { + DrizzleService, + assertRateLimit, + createDomainError, + createLogger, + findOneOrThrow, + likeContains, + makeConflictError, + makeNotFoundError, + pageToOffset, + serializeRow, + type DrizzleTx, + type EventBus, +} from '@openora/core/server'; +import { + and, + count, + desc, + eq, + gt, + ilike, + inArray, + isNotNull, + isNull, + lte, + or, + sql, + type SQL, +} from 'drizzle-orm'; +import type { AnyPgColumn } from 'drizzle-orm/pg-core'; +import { + RATE_LIMIT_KEYS, + makeRateLimitKey, + type AuditWritePort, + type ClientMeta, + type McpTokenRevocation, + type McpTokenRevokeReason, + type McpTransportConfig, + type RateLimiterAdapter, + type RateLimitKey, + type SortOrder, + type User, +} from '@openora/core/contracts'; +// Read-only cross-domain schema import (sanctioned): token lists show the owning admin, and +// issuance locks the owner's row to check its standing and reads the session issuing it. +import { session, user, type Session } from '@openora/core/pam/schema/identity'; +import { mcpToken, type McpToken } from '../schema/index.js'; +import { usersWithoutMcpAccess } from '../shared/admin-grants.js'; +import { + McpTokenErrorReasonSchema, + type CreateMcpTokenInput, + type ListMcpTokensInput, + type ListMyMcpTokensInput, + type McpTokenStatus, +} from '../contract/mcp-token.js'; +import { + generateMcpToken, + hashMcpToken, + mcpTokenDisplayPrefix, + mcpTokenStatus, +} from '../shared/mcp-token.js'; + +const logger = createLogger('mcp-token-service'); + +export const McpTokenNotFoundError = makeNotFoundError('McpToken'); +export const McpTokenTtlError = createDomainError<[maxDays: number]>( + 'McpTokenTtlError', + (maxDays) => `An MCP token may live at most ${maxDays} days`, + { reason: McpTokenErrorReasonSchema.enum.ttl_exceeds_max }, +); +export const McpTransportDisabledError = makeConflictError( + 'McpTransportDisabledError', + 'The MCP transport is disabled', + { reason: McpTokenErrorReasonSchema.enum.mcp_disabled }, +); +export const McpTokenOwnerIneligibleError = createDomainError( + 'McpTokenOwnerIneligibleError', + () => 'This account may not hold an MCP token', + { reason: McpTokenErrorReasonSchema.enum.owner_ineligible }, +); +export const McpTokenLimitError = createDomainError<[maxActive: number]>( + 'McpTokenLimitError', + (maxActive) => `An admin may hold at most ${maxActive} active MCP tokens`, + { reason: McpTokenErrorReasonSchema.enum.token_limit }, +); +export const McpTokenIssueError = createDomainError( + 'McpTokenIssueError', + () => 'The MCP token could not be issued', +); + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; +const DATE_FIELDS = ['createdAt', 'expiresAt', 'revokedAt', 'lastUsedAt'] as const; + +const SORT_COLUMNS = { + createdAt: mcpToken.createdAt, + expiresAt: mcpToken.expiresAt, + lastUsedAt: mcpToken.lastUsedAt, + callCount: mcpToken.callCount, +} as const; + +const STATUS_FILTERS = { + revoked: () => isNotNull(mcpToken.revokedAt), + expired: (now: Date) => and(isNull(mcpToken.revokedAt), lte(mcpToken.expiresAt, now)), + active: (now: Date) => and(isNull(mcpToken.revokedAt), gt(mcpToken.expiresAt, now)), +} satisfies Record SQL | undefined>; + +const ISSUANCE_REFUSALS = [McpTokenOwnerIneligibleError, McpTokenLimitError, ORPCError] as const; + +export type McpTokenServiceDeps = { + drizzle: DrizzleService; + audit: AuditWritePort; + events: EventBus; + rateLimiter: RateLimiterAdapter; + config: McpTransportConfig; + now?: (() => Date) | undefined; +}; + +type RevocationActor = { actorId: User['id'] | null } & Partial; + +type UsersRevocation = Omit[0], 'userId'> & { + userIds: readonly User['id'][]; +}; + +function toMcpTokenDto(row: McpToken, now: Date) { + const { tokenHash: _tokenHash, ...token } = row; + return { ...serializeRow(token, { dateFields: DATE_FIELDS }), status: mcpTokenStatus(row, now) }; +} + +function auditClientMeta(meta: Partial) { + return { ip: meta.ip ?? null, userAgent: meta.userAgent ?? null }; +} + +function nullsLast(column: AnyPgColumn, order: SortOrder) { + return order === 'asc' ? sql`${column} asc nulls last` : sql`${column} desc nulls last`; +} + +function sqlState(err: unknown) { + return err instanceof DatabaseError ? err.code : undefined; +} + +/** What a failed issuance may log: a query error's message and cause carry the token hash. */ +function failureSignature(err: unknown) { + if (!(err instanceof Error)) { + return { name: typeof err, code: null }; + } + return { name: err.constructor.name, code: sqlState(err) ?? sqlState(err.cause) ?? null }; +} + +function sanitizeIssuanceFailure(err: unknown) { + if (ISSUANCE_REFUSALS.some((refusal) => err instanceof refusal)) { + return err; + } + logger.error(failureSignature(err), 'MCP token issuance failed'); + return new McpTokenIssueError(); +} + +/** + * Issues, lists and revokes the bearer tokens an admin's MCP client authenticates with. Only a + * token's SHA-256 hash is stored; the plaintext leaves this service once, from `create`. + */ +export class McpTokenService implements McpTokenRevocation { + private readonly drizzle: DrizzleService; + private readonly audit: AuditWritePort; + private readonly events: EventBus; + private readonly rateLimiter: RateLimiterAdapter; + private readonly config: McpTransportConfig; + private readonly now: () => Date; + + constructor({ drizzle, audit, events, rateLimiter, config, now }: McpTokenServiceDeps) { + this.drizzle = drizzle; + this.audit = audit; + this.events = events; + this.rateLimiter = rateLimiter; + this.config = config; + this.now = now ?? (() => new Date()); + } + + /** + * Refuses a disabled transport, a lifetime over the cap, an owner who is missing, inactive, a + * player or without MCP access, a session that has ended, an owner at the active-token cap, + * and an owner over the hourly issuance limit (a 429 carrying `retryAfterMs`). Any other + * failure surfaces as `McpTokenIssueError`. + * + * The owner's row is locked before the revocation lock, the order identity's deactivation + * takes them in. The MCP grant and the issuing session are read again under the revocation + * lock, so a grant change or a session revocation that committed while this issue waited + * refuses it. + */ + async create( + { + adminUserId, + sessionId, + label, + ttlDays, + }: CreateMcpTokenInput & { adminUserId: User['id']; sessionId: Session['id'] | null }, + meta: ClientMeta, + ) { + if (!this.config.enabled) { + throw new McpTransportDisabledError(); + } + const lifetimeDays = ttlDays ?? this.config.tokenTtlDays.default; + if (lifetimeDays > this.config.tokenTtlDays.max) { + throw new McpTokenTtlError(this.config.tokenTtlDays.max); + } + + const token = generateMcpToken(); + const createdAt = this.now(); + const created = await this.drizzle.db + .transaction(async (tx) => { + const role = await this.assertOwnerMayIssue(tx, { + ownerId: adminUserId, + meta, + now: createdAt, + }); + await assertRateLimit( + this.rateLimiter, + makeRateLimitKey(RATE_LIMIT_KEYS.MCP_TOKEN_CREATE, adminUserId), + { limit: this.config.tokenIssuance.perHour, windowMs: HOUR_MS, onUnavailable: 'deny' }, + ); + await this.lockForRevocation(tx); + await this.assertOwnerHoldsMcpAccess(tx, { ownerId: adminUserId, role, meta }); + await this.assertSessionLive(tx, { ownerId: adminUserId, sessionId, role, meta }); + return this.insertToken(tx, { adminUserId, label, token, createdAt, lifetimeDays, meta }); + }) + .catch((err: unknown) => { + throw sanitizeIssuanceFailure(err); + }); + return { ...toMcpTokenDto(created, createdAt), token }; + } + + listMine(adminUserId: User['id'], query: ListMyMcpTokensInput) { + return this.list({ ...query, adminUserId }); + } + + async list({ adminUserId, search, status, page, limit, sortBy, sortOrder }: ListMcpTokensInput) { + const now = this.now(); + const where = and( + adminUserId ? eq(mcpToken.adminUserId, adminUserId) : undefined, + status ? STATUS_FILTERS[status](now) : undefined, + search + ? or(ilike(mcpToken.label, likeContains(search)), ilike(user.email, likeContains(search))) + : undefined, + ); + const db = this.drizzle.db; + const [rows, [totals]] = await Promise.all([ + db + .select({ + token: mcpToken, + adminEmail: user.email, + adminName: user.name, + adminIsActive: user.isActive, + }) + .from(mcpToken) + .leftJoin(user, eq(user.id, mcpToken.adminUserId)) + .where(where) + .orderBy( + nullsLast(SORT_COLUMNS[sortBy ?? 'createdAt'], sortOrder ?? 'desc'), + desc(mcpToken.id), + ) + .limit(limit) + .offset(pageToOffset(page, limit)), + db + .select({ n: count() }) + .from(mcpToken) + .leftJoin(user, eq(user.id, mcpToken.adminUserId)) + .where(where), + ]); + return { + items: rows.map(({ token, adminEmail, adminName, adminIsActive }) => ({ + ...toMcpTokenDto(token, now), + admin: { + id: token.adminUserId, + email: adminEmail, + name: adminName, + isActive: adminIsActive, + }, + })), + total: Number(totals?.n ?? 0), + page, + limit, + }; + } + + /** + * Another admin's token answers exactly like an unknown one, so the owner route cannot be used + * to probe which token ids exist. + */ + revokeMine(adminUserId: User['id'], tokenId: McpToken['id'], meta: ClientMeta) { + return this.revokeOne({ tokenId, ownerId: adminUserId }, { ...meta, actorId: adminUserId }); + } + + revoke(tokenId: McpToken['id'], actorId: User['id'], meta: ClientMeta) { + return this.revokeOne({ tokenId }, { ...meta, actorId }); + } + + revokeAll(actorId: User['id'], meta: ClientMeta) { + return this.revokeActive({ + scope: undefined, + reason: 'revoked_all', + actor: { ...meta, actorId }, + }); + } + + revokeAllForUser( + { userId, ...revocation }: Parameters[0], + tx?: unknown, + ) { + return this.revokeAllForUsers( + { ...revocation, userIds: [userId] }, + tx === undefined ? undefined : (tx as DrizzleTx), + ); + } + + /** Runs inside `tx` when one is given, so the revocation commits or rolls back with it. */ + async revokeAllForUsers({ userIds, reason, ...actor }: UsersRevocation, tx?: DrizzleTx) { + if (userIds.length === 0) { + return { revoked: 0 }; + } + return this.revokeActive( + { scope: inArray(mcpToken.adminUserId, [...userIds]), reason, actor }, + tx, + ); + } + + /** + * Postgres' SHARE ROW EXCLUSIVE waits for every transaction that has inserted a token to + * commit, and conflicts with itself: a bulk revocation holding it sees every token issued + * before it, and concurrent bulk revocations run one after another. + */ + async lockForRevocation(tx: DrizzleTx) { + await tx.execute(sql`LOCK TABLE ${mcpToken} IN SHARE ROW EXCLUSIVE MODE`); + } + + private async assertOwnerMayIssue( + tx: DrizzleTx, + { ownerId, meta, now }: { ownerId: User['id']; meta: ClientMeta; now: Date }, + ) { + const [owner] = await tx + .select({ isActive: user.isActive, role: user.role }) + .from(user) + .where(eq(user.id, ownerId)) + .for('no key update'); + if (!owner?.isActive || owner.role === 'player') { + this.emitOwnerIneligible(ownerId, owner?.role, meta); + throw new McpTokenOwnerIneligibleError(); + } + const [active] = await tx + .select({ n: count() }) + .from(mcpToken) + .where(and(eq(mcpToken.adminUserId, ownerId), STATUS_FILTERS.active(now))); + const { maxActivePerAdmin } = this.config.tokenIssuance; + if (Number(active?.n ?? 0) >= maxActivePerAdmin) { + throw new McpTokenLimitError(maxActivePerAdmin); + } + return owner.role; + } + + private async assertOwnerHoldsMcpAccess( + tx: DrizzleTx, + { ownerId, role, meta }: { ownerId: User['id']; role: string; meta: ClientMeta }, + ) { + const [withoutAccess] = await usersWithoutMcpAccess(tx, [ownerId]); + if (withoutAccess) { + this.emitOwnerIneligible(ownerId, role, meta); + throw new McpTokenOwnerIneligibleError(); + } + } + + // Compared with `statement_timestamp()`: `now()` is this transaction's start, which can + // predate the `now()` a revocation that committed while this issue waited wrote as the expiry. + private async assertSessionLive( + tx: DrizzleTx, + { + ownerId, + sessionId, + role, + meta, + }: { ownerId: User['id']; sessionId: Session['id'] | null; role: string; meta: ClientMeta }, + ) { + const [live] = sessionId + ? await tx + .select({ id: session.id }) + .from(session) + .where( + and( + eq(session.id, sessionId), + eq(session.userId, ownerId), + gt(session.expiresAt, sql`statement_timestamp()`), + ), + ) + .limit(1) + : []; + if (!live) { + this.emitOwnerIneligible(ownerId, role, meta); + throw new McpTokenOwnerIneligibleError(); + } + } + + private async insertToken( + tx: DrizzleTx, + { + adminUserId, + label, + token, + createdAt, + lifetimeDays, + meta, + }: { + adminUserId: User['id']; + label: McpToken['label']; + token: string; + createdAt: Date; + lifetimeDays: number; + meta: ClientMeta; + }, + ) { + const row = findOneOrThrow( + await tx + .insert(mcpToken) + .values({ + adminUserId, + label, + tokenHash: hashMcpToken(token), + tokenPrefix: mcpTokenDisplayPrefix(token), + createdAt, + expiresAt: new Date(createdAt.getTime() + lifetimeDays * DAY_MS), + }) + .returning(), + new McpTokenNotFoundError(adminUserId), + ); + await this.audit.recordInTransaction(tx, { + ...auditClientMeta(meta), + actorId: adminUserId, + actorType: 'admin', + action: 'iam.mcp_token.created', + resourceType: 'mcp-token', + resourceId: row.id, + after: { + adminUserId, + label: row.label, + tokenPrefix: row.tokenPrefix, + expiresAt: row.expiresAt.toISOString(), + ttlDays: lifetimeDays, + }, + }); + return row; + } + + private emitOwnerIneligible(ownerId: User['id'], role: string | undefined, meta: ClientMeta) { + this.events.emit('identity.user.unauthorized_access', { + userId: ownerId, + playerId: null, + resource: 'mcp-access', + action: 'use', + ip: meta.ip, + userAgent: meta.userAgent, + role, + }); + } + + private async revokeOne( + { tokenId, ownerId }: { tokenId: McpToken['id']; ownerId?: User['id'] }, + actor: RevocationActor, + ) { + const scope = and( + eq(mcpToken.id, tokenId), + ownerId ? eq(mcpToken.adminUserId, ownerId) : undefined, + ); + const now = this.now(); + const row = await this.drizzle.db.transaction(async (tx) => { + const [revoked] = await this.markRevoked(tx, { scope, reason: 'manual', actor, now }); + if (revoked) { + return revoked; + } + return findOneOrThrow( + await tx.select().from(mcpToken).where(scope), + new McpTokenNotFoundError(tokenId), + ); + }); + return toMcpTokenDto(row, now); + } + + private async revokeActive( + { + scope, + reason, + actor, + }: { scope: SQL | undefined; reason: McpTokenRevokeReason; actor: RevocationActor }, + tx?: DrizzleTx, + ) { + const now = this.now(); + const revoke = async (inTx: DrizzleTx) => { + await this.lockForRevocation(inTx); + return this.markRevoked(inTx, { + scope: and(scope, gt(mcpToken.expiresAt, now)), + reason, + actor, + now, + }); + }; + const revoked = tx ? await revoke(tx) : await this.drizzle.db.transaction(revoke); + return { revoked: revoked.length }; + } + + private async markRevoked( + tx: DrizzleTx, + { + scope, + reason, + actor, + now, + }: { scope: SQL | undefined; reason: McpTokenRevokeReason; actor: RevocationActor; now: Date }, + ) { + const revoked = await tx + .update(mcpToken) + .set({ revokedAt: now, revokedBy: actor.actorId, revokeReason: reason }) + .where(and(scope, isNull(mcpToken.revokedAt))) + .returning(); + const actorType = actor.actorId ? 'admin' : 'system'; + await this.audit.recordManyInTransaction( + tx, + revoked.map((row) => ({ + ...auditClientMeta(actor), + actorId: actor.actorId, + actorType, + action: 'iam.mcp_token.revoked', + resourceType: 'mcp-token', + resourceId: row.id, + before: { revokedAt: null }, + after: { + adminUserId: row.adminUserId, + label: row.label, + tokenPrefix: row.tokenPrefix, + reason, + }, + })), + ); + return revoked; + } +} diff --git a/packages/core/src/iam/shared/admin-grants.ts b/packages/core/src/iam/shared/admin-grants.ts new file mode 100644 index 000000000..e798ec1b6 --- /dev/null +++ b/packages/core/src/iam/shared/admin-grants.ts @@ -0,0 +1,98 @@ +import { + holdsGrant, + levelToActions, + statement, + type DrizzleDb, + type DrizzleTx, + type PermissionLevel, + type ResourceName, +} from '@openora/core/server'; +import { eq, inArray } from 'drizzle-orm'; +import type { AdminGrant, User } from '@openora/core/contracts'; +// Read-only cross-domain schema import (sanctioned): the grant rule falls back to `user.role`. +import { user } from '@openora/core/pam/schema/identity'; +import { adminRole, adminRolePermission, adminRoleAssignment } from '../schema/index.js'; + +function allGrants() { + return (Object.keys(statement) as ResourceName[]).flatMap((resource) => + (statement[resource] as readonly string[]).map((action) => ({ + resource: resource as string, + action, + })), + ); +} + +type AssignedGrantRow = { + isSuperAdmin: boolean; + resource: string | null; + level: PermissionLevel | null; +}; + +function grantsOf(rows: readonly AssignedGrantRow[]): AdminGrant[] { + if (rows.some((r) => r.isSuperAdmin)) { + return allGrants(); + } + const seen = new Set(); + const grants: AdminGrant[] = []; + for (const { resource, level } of rows) { + if (!resource || !level) { + continue; + } + for (const action of levelToActions(resource, level)) { + const key = `${resource}:${action}`; + if (!seen.has(key)) { + seen.add(key); + grants.push({ resource, action }); + } + } + } + return grants; +} + +/** + * The DB grants of each of `userIds` holding a role assignment, read through `db` - a + * transaction included - without the grant cache. A user with no assignment is absent from the + * map, which the resolver reports as no grants at all (ADMIN_PERMISSION_RESOLVER: once a + * resolver is bound its answer is authoritative, there is no static-role fallback). + */ +export async function loadAdminGrants(db: DrizzleDb | DrizzleTx, userIds: readonly User['id'][]) { + const rows = await db + .select({ + userId: adminRoleAssignment.userId, + isSuperAdmin: adminRole.isSuperAdmin, + resource: adminRolePermission.resource, + level: adminRolePermission.level, + }) + .from(adminRoleAssignment) + .innerJoin(adminRole, eq(adminRole.id, adminRoleAssignment.roleId)) + .leftJoin(adminRolePermission, eq(adminRolePermission.roleId, adminRole.id)) + .where(inArray(adminRoleAssignment.userId, [...userIds])); + + const rowsByUser = new Map(); + for (const { userId, ...row } of rows) { + const userRows = rowsByUser.get(userId) ?? []; + userRows.push(row); + rowsByUser.set(userId, userRows); + } + return new Map([...rowsByUser].map(([userId, userRows]) => [userId, grantsOf(userRows)])); +} + +/** Of `userIds`, those AdminGuard's grant rule refuses `mcp-access:use`, read uncached through `db`. */ +export async function usersWithoutMcpAccess( + db: DrizzleDb | DrizzleTx, + userIds: readonly User['id'][], +) { + const accounts = await db + .select({ id: user.id, role: user.role }) + .from(user) + .where(inArray(user.id, [...userIds])); + const grants = await loadAdminGrants(db, userIds); + const roleOf = new Map(accounts.map((account) => [account.id, account.role])); + return userIds.filter((userId) => { + const role = roleOf.get(userId); + return ( + role === undefined || + !holdsGrant({ role, grants: grants.get(userId) ?? [] }, 'mcp-access', 'use') + ); + }); +} diff --git a/packages/core/src/iam/shared/mcp-token.ts b/packages/core/src/iam/shared/mcp-token.ts new file mode 100644 index 000000000..f35ed667a --- /dev/null +++ b/packages/core/src/iam/shared/mcp-token.ts @@ -0,0 +1,59 @@ +import { createHash, randomBytes } from 'node:crypto'; +import type { McpTokenAuthentication } from '@openora/core/contracts'; +import type { McpTokenStatus } from '../contract/mcp-token.js'; +import type { McpToken } from '../schema/index.js'; + +const MCP_TOKEN_SCHEME = 'ora_mcp_'; +const MCP_TOKEN_SECRET_BYTES = 32; +const MCP_TOKEN_DISPLAY_PREFIX_LENGTH = 12; +const MCP_TOKEN_FORMAT = /^ora_mcp_[A-Za-z0-9_-]{43}$/; + +type McpTokenLifecycle = Pick; + +export function generateMcpToken() { + return `${MCP_TOKEN_SCHEME}${randomBytes(MCP_TOKEN_SECRET_BYTES).toString('base64url')}`; +} + +export function hashMcpToken(token: string) { + return createHash('sha256').update(token, 'utf8').digest('hex'); +} + +export function isMcpTokenFormat(value: string) { + return MCP_TOKEN_FORMAT.test(value); +} + +export function mcpTokenDisplayPrefix(token: string) { + return token.slice(0, MCP_TOKEN_DISPLAY_PREFIX_LENGTH); +} + +export function mcpTokenStatus(token: McpTokenLifecycle, now: Date): McpTokenStatus { + if (token.revokedAt) { + return 'revoked'; + } + return token.expiresAt.getTime() <= now.getTime() ? 'expired' : 'active'; +} + +type AuthenticatedMcpToken = McpTokenLifecycle & + Pick & { credentialsChangedAt: Date | null }; + +/** + * A token issued at or before its admin's latest credential change is refused as + * `credentials_changed`, whether or not the revocation that change asked for was recorded. + */ +export function mcpTokenAuthentication( + token: AuthenticatedMcpToken | undefined, + now: Date, +): McpTokenAuthentication { + if (!token) { + return { ok: false, reason: 'unknown' }; + } + const subject = { tokenId: token.id, adminId: token.adminUserId }; + const status = mcpTokenStatus(token, now); + if (status !== 'active') { + return { ok: false, reason: status, ...subject }; + } + if (token.credentialsChangedAt && token.createdAt <= token.credentialsChangedAt) { + return { ok: false, reason: 'credentials_changed', ...subject }; + } + return { ok: true, ...subject }; +} diff --git a/packages/core/src/pam/identity/__tests__/admin-user-directory.int.test.ts b/packages/core/src/pam/identity/__tests__/admin-user-directory.int.test.ts index 27034113e..811308fee 100644 --- a/packages/core/src/pam/identity/__tests__/admin-user-directory.int.test.ts +++ b/packages/core/src/pam/identity/__tests__/admin-user-directory.int.test.ts @@ -2,24 +2,103 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vites import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; import type { EventBus } from '@openora/core/server'; +import { + McpTransportConfigSchema, + type McpTokenRevocation, + type User, +} from '@openora/core/contracts'; import { createTestDb, type TestDb, seedUser } from '@openora/core/testing'; import { player } from '@openora/core/pam/schema/profile'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; -import { mock, NO_CLIENT_META } from '../../../testing/mock.js'; +import { migrate as migrateIam } from '@openora/core/iam/migrate'; +import { mcpToken } from '@openora/core/iam/schema'; +import { McpTokenService } from '@openora/core/iam/server'; +import { + makeAuditWriter, + makeEventBus, + makeRateLimiter, + mock, + NO_CLIENT_META, +} from '../../../testing/mock.js'; import { migrate } from '../migrate.js'; import { user } from '../schema/index.js'; import { DrizzleAdminUserDirectory } from '../admin-user-directory.js'; let db: TestDb; +const META = { ip: '203.0.113.7', userAgent: 'backoffice/1.0' }; +const DAY_MS = 24 * 60 * 60 * 1000; + function makeDirectory() { + const emit = vi.fn(); + const revokeAllForUser = vi.fn(async () => ({ revoked: 0 })); + return { + dir: new DrizzleAdminUserDirectory( + db.drizzle, + mock({ emit, on: vi.fn() }), + mock({ revokeAllForUser }), + ), + emit, + revokeAllForUser, + }; +} + +function makeDirectoryWithTokens() { + const audit = makeAuditWriter(); + const tokens = new McpTokenService({ + drizzle: db.drizzle, + audit, + events: makeEventBus(), + rateLimiter: makeRateLimiter(), + config: McpTransportConfigSchema.parse({}), + }); + const revokeAllForUser = vi.spyOn(tokens, 'revokeAllForUser'); const emit = vi.fn(); return { - dir: new DrizzleAdminUserDirectory(db.drizzle, mock({ emit, on: vi.fn() })), + dir: new DrizzleAdminUserDirectory(db.drizzle, mock({ emit, on: vi.fn() }), tokens), + audit, emit, + revokeAllForUser, }; } +async function seedActiveToken(adminUserId: User['id']) { + const [row] = await db.drizzle.db + .insert(mcpToken) + .values({ + adminUserId, + label: 'Laptop', + tokenHash: randomUUID(), + tokenPrefix: 'ora_mcp_test', + expiresAt: new Date(Date.now() + DAY_MS), + }) + .returning({ id: mcpToken.id }); + if (!row) { + throw new Error('seedActiveToken: insert returned no row'); + } + return row.id; +} + +async function revocationOf(tokenId: string) { + const [row] = await db.drizzle.db + .select({ + revokedAt: mcpToken.revokedAt, + revokedBy: mcpToken.revokedBy, + revokeReason: mcpToken.revokeReason, + }) + .from(mcpToken) + .where(eq(mcpToken.id, tokenId)); + return row; +} + +async function isActive(userId: User['id']) { + const [row] = await db.drizzle.db + .select({ isActive: user.isActive }) + .from(user) + .where(eq(user.id, userId)); + return row?.isActive; +} + async function seedPlayer( userId: string, overrides: Partial & { username?: string } = {}, @@ -34,7 +113,7 @@ async function seedPlayer( } beforeAll(async () => { - db = await createTestDb([migrate, migrateProfile]); + db = await createTestDb([migrate, migrateProfile, migrateIam]); }); afterAll(async () => { @@ -42,7 +121,9 @@ afterAll(async () => { }); beforeEach(async () => { - await db.drizzle.db.execute(sql`TRUNCATE ${player}, ${user} RESTART IDENTITY CASCADE`); + await db.drizzle.db.execute( + sql`TRUNCATE ${mcpToken}, ${player}, ${user} RESTART IDENTITY CASCADE`, + ); }); describe('DrizzleAdminUserDirectory.update (real PG)', () => { @@ -106,6 +187,107 @@ describe('DrizzleAdminUserDirectory.update (real PG)', () => { }); }); +describe('DrizzleAdminUserDirectory.update and MCP tokens (real PG)', () => { + it("revokes the admin's tokens as admin_disabled inside the deactivating transaction", async () => { + const { dir, revokeAllForUser } = makeDirectoryWithTokens(); + const admin = await seedUser(db, { role: 'admin', isActive: true }); + const tokenId = await seedActiveToken(admin.id); + const actorId = randomUUID(); + + await dir.update(admin.id, { isActive: false }, actorId, META); + + expect(revokeAllForUser).toHaveBeenCalledTimes(1); + expect(revokeAllForUser).toHaveBeenCalledWith( + { userId: admin.id, reason: 'admin_disabled', actorId, ...META }, + expect.anything(), + ); + expect(await revocationOf(tokenId)).toEqual({ + revokedAt: expect.any(Date), + revokedBy: actorId, + revokeReason: 'admin_disabled', + }); + }); + + it("revokes the admin's tokens as admin_role_removed when the patch writes another role", async () => { + const { dir } = makeDirectoryWithTokens(); + const admin = await seedUser(db, { role: 'admin', isActive: true }); + const tokenId = await seedActiveToken(admin.id); + const actorId = randomUUID(); + + await dir.update(admin.id, { role: 'player' }, actorId); + + expect(await revocationOf(tokenId)).toMatchObject({ + revokedBy: actorId, + revokeReason: 'admin_role_removed', + }); + }); + + it('revokes again on a repeated deactivation, without a second deactivated event', async () => { + const { dir, emit } = makeDirectoryWithTokens(); + const admin = await seedUser(db, { role: 'admin', isActive: false }); + const leftActive = await seedActiveToken(admin.id); + + await dir.update(admin.id, { isActive: false }, randomUUID(), META); + + expect(await revocationOf(leftActive)).toMatchObject({ revokeReason: 'admin_disabled' }); + expect(emit).not.toHaveBeenCalled(); + }); + + it('leaves the tokens alone when an admin is reactivated', async () => { + const { dir, revokeAllForUser } = makeDirectoryWithTokens(); + const admin = await seedUser(db, { role: 'admin', isActive: false }); + const tokenId = await seedActiveToken(admin.id); + + await dir.update(admin.id, { isActive: true }, randomUUID(), META); + + expect(revokeAllForUser).not.toHaveBeenCalled(); + expect(await revocationOf(tokenId)).toMatchObject({ revokedAt: null }); + }); + + it('leaves the tokens alone on a patch that writes an active admin', async () => { + const { dir, revokeAllForUser } = makeDirectoryWithTokens(); + const admin = await seedUser(db, { role: 'admin', isActive: true }); + + await dir.update(admin.id, { isActive: true, role: 'admin' }, randomUUID(), META); + + expect(revokeAllForUser).not.toHaveBeenCalled(); + }); + + it('rolls the deactivation back when the revocation fails, and a retry revokes', async () => { + const { dir, audit, emit } = makeDirectoryWithTokens(); + audit.recordManyInTransaction.mockRejectedValueOnce(new Error('audit store unavailable')); + const admin = await seedUser(db, { role: 'admin', isActive: true }); + const tokenId = await seedActiveToken(admin.id); + const actorId = randomUUID(); + + await expect(dir.update(admin.id, { isActive: false }, actorId, META)).rejects.toThrow( + 'audit store unavailable', + ); + + expect(await isActive(admin.id)).toBe(true); + expect(await revocationOf(tokenId)).toEqual({ + revokedAt: null, + revokedBy: null, + revokeReason: null, + }); + expect(emit).not.toHaveBeenCalled(); + + await dir.update(admin.id, { isActive: false }, actorId, META); + + expect(await isActive(admin.id)).toBe(false); + expect(await revocationOf(tokenId)).toMatchObject({ + revokedBy: actorId, + revokeReason: 'admin_disabled', + }); + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith('identity.user.deactivated', { + userId: admin.id, + actorId, + ...META, + }); + }); +}); + describe('DrizzleAdminUserDirectory reads (real PG)', () => { it('counts every user row', async () => { const { dir } = makeDirectory(); diff --git a/packages/core/src/pam/identity/__tests__/automatic-mcp-revoke-reason.test.ts b/packages/core/src/pam/identity/__tests__/automatic-mcp-revoke-reason.test.ts new file mode 100644 index 000000000..0c7d88f1c --- /dev/null +++ b/packages/core/src/pam/identity/__tests__/automatic-mcp-revoke-reason.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { automaticMcpRevokeReason } from '../admin-user-directory.js'; + +describe('automaticMcpRevokeReason', () => { + it('names a patch that writes an inactive account admin_disabled', () => { + expect(automaticMcpRevokeReason({ isActive: false })).toBe('admin_disabled'); + }); + + it('prefers admin_disabled when one patch both deactivates and demotes', () => { + expect(automaticMcpRevokeReason({ isActive: false, role: 'player' })).toBe('admin_disabled'); + }); + + it.each(['player', 'support', 'content-manager'])( + 'names a patch that writes the %s role admin_role_removed', + (role) => { + expect(automaticMcpRevokeReason({ role })).toBe('admin_role_removed'); + }, + ); + + it('names a demotion that also reactivates admin_role_removed', () => { + expect(automaticMcpRevokeReason({ isActive: true, role: 'player' })).toBe('admin_role_removed'); + }); + + it.each([ + ['a reactivation', { isActive: true }], + ['a write of the admin role', { role: 'admin' }], + ['an active admin written as such', { isActive: true, role: 'admin' }], + ['an empty patch', {}], + ])('leaves the tokens alone on %s', (_case, patch) => { + expect(automaticMcpRevokeReason(patch)).toBeNull(); + }); +}); diff --git a/packages/core/src/pam/identity/__tests__/identity.service.int.test.ts b/packages/core/src/pam/identity/__tests__/identity.service.int.test.ts index f80e2604b..2299e0fc9 100644 --- a/packages/core/src/pam/identity/__tests__/identity.service.int.test.ts +++ b/packages/core/src/pam/identity/__tests__/identity.service.int.test.ts @@ -14,6 +14,7 @@ import { player } from '@openora/core/pam/schema/profile'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; import type { MailDispatchPort, + McpTokenRevocation, PlatformConfig, RateLimiterAdapter, SmsAdapter, @@ -1231,6 +1232,129 @@ describe('IdentityService onPasswordReset hook (wired via createAuth)', () => { }); }); +describe('IdentityService password changes and MCP tokens', () => { + const ADMIN_REQUEST_HEADERS = { 'x-real-ip': '203.0.113.7', 'user-agent': 'backoffice/1.0' }; + const NEW_PASSWORD = { currentPassword: 'password1234', newPassword: 'brand-new-secret-123' }; + + function sessionsRevokingTokens() { + const revokeAllForUser = vi.fn(async () => ({ revoked: 1 })); + const sessions = new SessionService({ + drizzle: db.drizzle, + events: makeEventBus(), + identityReader: makeIdentityReader(), + mcpTokens: mock({ revokeAllForUser }), + }); + return { sessions, revokeAllForUser }; + } + + it("revokes an admin's MCP tokens when better-auth reports a password reset", async () => { + const admin = await seedUser({ role: 'admin' }); + const { sessions, revokeAllForUser } = sessionsRevokingTokens(); + buildService({ sessions }); + + await capturedAuthOptions.current?.onPasswordReset?.({ id: admin.id, email: EMAIL }); + + expect(revokeAllForUser).toHaveBeenCalledTimes(1); + expect(revokeAllForUser).toHaveBeenCalledWith({ + userId: admin.id, + reason: 'sessions_revoked', + actorId: admin.id, + ip: null, + userAgent: null, + }); + }); + + it('lets better-auth finish the password reset when the MCP token revocation fails', async () => { + const admin = await seedUser({ role: 'admin' }); + const { sessions, revokeAllForUser } = sessionsRevokingTokens(); + revokeAllForUser.mockRejectedValueOnce(new Error('token store unavailable')); + buildService({ sessions }); + + await expect( + capturedAuthOptions.current?.onPasswordReset?.({ id: admin.id, email: EMAIL }), + ).resolves.toBeUndefined(); + + expect(revokeAllForUser).toHaveBeenCalledTimes(1); + }); + + it("leaves the token port alone on a player's password reset", async () => { + const player = await seedUser({ role: 'player' }); + const { sessions, revokeAllForUser } = sessionsRevokingTokens(); + buildService({ sessions }); + + await capturedAuthOptions.current?.onPasswordReset?.({ id: player.id, email: EMAIL }); + + expect(revokeAllForUser).not.toHaveBeenCalled(); + }); + + it("revokes an admin's MCP tokens after a password change, in the admin's name", async () => { + const admin = await seedUser({ role: 'admin' }); + changePasswordMock.mockResolvedValue(jsonResponse({ token: null, user: betterAuthUser }, 200)); + const { sessions, revokeAllForUser } = sessionsRevokingTokens(); + + await buildService({ sessions }).changePassword( + NEW_PASSWORD, + ADMIN_REQUEST_HEADERS, + new Headers(), + { userId: admin.id, sessionId: randomUUID() }, + ); + + expect(revokeAllForUser).toHaveBeenCalledWith({ + userId: admin.id, + reason: 'sessions_revoked', + actorId: admin.id, + ip: '203.0.113.7', + userAgent: 'backoffice/1.0', + }); + }); + + it('answers a password change with success when the MCP token revocation fails', async () => { + const admin = await seedUser({ role: 'admin' }); + changePasswordMock.mockResolvedValue(jsonResponse({ token: null, user: betterAuthUser }, 200)); + const { sessions, revokeAllForUser } = sessionsRevokingTokens(); + revokeAllForUser.mockRejectedValueOnce(new Error('token store unavailable')); + + await expect( + buildService({ sessions }).changePassword( + NEW_PASSWORD, + ADMIN_REQUEST_HEADERS, + new Headers(), + { userId: admin.id, sessionId: randomUUID() }, + ), + ).resolves.toEqual({ success: true }); + + expect(revokeAllForUser).toHaveBeenCalledTimes(1); + }); + + it('leaves the MCP tokens alone when better-auth rejects the password change', async () => { + const admin = await seedUser({ role: 'admin' }); + changePasswordMock.mockResolvedValue(jsonResponse({ message: 'INVALID_PASSWORD' }, 400)); + const { sessions, revokeAllForUser } = sessionsRevokingTokens(); + + await expect( + buildService({ sessions }).changePassword(NEW_PASSWORD, {}, new Headers(), { + userId: admin.id, + sessionId: randomUUID(), + }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + expect(revokeAllForUser).not.toHaveBeenCalled(); + }); + + it("leaves the token port alone on a player's password change", async () => { + const player = await seedUser({ role: 'player' }); + changePasswordMock.mockResolvedValue(jsonResponse({ token: null, user: betterAuthUser }, 200)); + const { sessions, revokeAllForUser } = sessionsRevokingTokens(); + + await buildService({ sessions }).changePassword(NEW_PASSWORD, {}, new Headers(), { + userId: player.id, + sessionId: randomUUID(), + }); + + expect(revokeAllForUser).not.toHaveBeenCalled(); + }); +}); + describe('IdentityService.updateProfile language validation', () => { it('rejects an unsupported language before calling updateUser', async () => { const svc = buildService({ diff --git a/packages/core/src/pam/identity/__tests__/login-enforcement.service.int.test.ts b/packages/core/src/pam/identity/__tests__/login-enforcement.service.int.test.ts index 6df70c4db..493523ceb 100644 --- a/packages/core/src/pam/identity/__tests__/login-enforcement.service.int.test.ts +++ b/packages/core/src/pam/identity/__tests__/login-enforcement.service.int.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; +import type { McpTokenRevocation } from '@openora/core/contracts'; import { createTestDb, type TestDb, seedUser } from '@openora/core/testing'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; -import { makeEventBus, makeIdentityReader } from '../../../testing/mock.js'; +import { makeEventBus, makeIdentityReader, mock } from '../../../testing/mock.js'; import { migrate } from '../migrate.js'; import { user, session } from '../schema/index.js'; import { LoginEnforcementService } from '../service/login-enforcement.service.js'; @@ -15,12 +16,14 @@ let db: TestDb; function makeService() { const events = makeEventBus(); + const revokeAllForUser = vi.fn(async () => ({ revoked: 0 })); const sessions = new SessionService({ drizzle: db.drizzle, events, identityReader: makeIdentityReader(), + mcpTokens: mock({ revokeAllForUser }), }); - return { svc: new LoginEnforcementService(db.drizzle, sessions), events }; + return { svc: new LoginEnforcementService(db.drizzle, sessions), events, revokeAllForUser }; } async function seedActiveSession(userId: string) { @@ -78,6 +81,17 @@ describe('LoginEnforcementService (real PG)', () => { ); }); + it("expires a player's sessions on a block without touching the MCP token port", async () => { + const { svc, revokeAllForUser } = makeService(); + const player = await seedUser(db, { role: 'player' }); + await seedActiveSession(player.id); + + await svc.block(player.id, { until: null }); + + expect(await activeSessionCount(player.id)).toBe(0); + expect(revokeAllForUser).not.toHaveBeenCalled(); + }); + it('stores a null expiry for an indefinite block', async () => { const { svc } = makeService(); const account = await seedUser(db); diff --git a/packages/core/src/pam/identity/__tests__/session.service.int.test.ts b/packages/core/src/pam/identity/__tests__/session.service.int.test.ts index 584335ded..159d82f7e 100644 --- a/packages/core/src/pam/identity/__tests__/session.service.int.test.ts +++ b/packages/core/src/pam/identity/__tests__/session.service.int.test.ts @@ -1,22 +1,91 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; +import { + McpTransportConfigSchema, + type McpTokenRevocation, + type User, +} from '@openora/core/contracts'; import { createTestDb, type TestDb, seedUser } from '@openora/core/testing'; import { user, session } from '@openora/core/pam/schema/identity'; -import { makeEventBus, makeIdentityReader } from '../../../testing/mock.js'; +import { migrate as migrateIam } from '@openora/core/iam/migrate'; +import { mcpToken } from '@openora/core/iam/schema'; +import { McpTokenService } from '@openora/core/iam/server'; +import { + makeAuditWriter, + makeEventBus, + makeIdentityReader, + makeRateLimiter, + mock, +} from '../../../testing/mock.js'; import { migrate } from '../migrate.js'; import { SessionService, SessionNotFoundError } from '../service/session.service.js'; const minutesAgo = (n: number) => new Date(Date.now() - n * 60_000); +const META = { ip: '203.0.113.7', userAgent: 'backoffice/1.0' }; +const DAY_MS = 24 * 60 * 60 * 1000; let db: TestDb; -const service = () => +const service = (mcpTokens?: McpTokenRevocation, events = makeEventBus()) => new SessionService({ drizzle: db.drizzle, - events: makeEventBus(), + events, identityReader: makeIdentityReader(), + mcpTokens, }); +function makeMcpTokenRevocation() { + const revokeAllForUser = vi.fn(async () => ({ revoked: 0 })); + return { port: mock({ revokeAllForUser }), revokeAllForUser }; +} + +function makeMcpTokenService() { + const audit = makeAuditWriter(); + const tokens = new McpTokenService({ + drizzle: db.drizzle, + audit, + events: makeEventBus(), + rateLimiter: makeRateLimiter(), + config: McpTransportConfigSchema.parse({}), + }); + return { tokens, audit, revokeAllForUser: vi.spyOn(tokens, 'revokeAllForUser') }; +} + +async function seedActiveToken(adminUserId: User['id']) { + const [row] = await db.drizzle.db + .insert(mcpToken) + .values({ + adminUserId, + label: 'Laptop', + tokenHash: randomUUID(), + tokenPrefix: 'ora_mcp_test', + expiresAt: new Date(Date.now() + DAY_MS), + }) + .returning({ id: mcpToken.id }); + if (!row) { + throw new Error('seedActiveToken: insert returned no row'); + } + return row.id; +} + +async function revocationOf(tokenId: string) { + const [row] = await db.drizzle.db + .select({ + revokedAt: mcpToken.revokedAt, + revokedBy: mcpToken.revokedBy, + revokeReason: mcpToken.revokeReason, + }) + .from(mcpToken) + .where(eq(mcpToken.id, tokenId)); + return row; +} + +async function activeSessionCount(userId: User['id']) { + const { total } = await service().listSessions({ userId, activeOnly: true, page: 1, limit: 20 }); + return total; +} + const seedSession = (userId: string, overrides: Partial = {}) => db.drizzle.db .insert(session) @@ -31,7 +100,7 @@ const seedSession = (userId: string, overrides: Partial row!); beforeAll(async () => { - db = await createTestDb([migrate]); + db = await createTestDb([migrate, migrateIam]); }); afterAll(async () => { @@ -40,7 +109,7 @@ afterAll(async () => { beforeEach(async () => { vi.clearAllMocks(); - await db.drizzle.db.execute(sql`TRUNCATE ${user} RESTART IDENTITY CASCADE`); + await db.drizzle.db.execute(sql`TRUNCATE ${mcpToken}, ${user} RESTART IDENTITY CASCADE`); }); describe('SessionService', () => { @@ -160,3 +229,121 @@ describe('SessionService', () => { expect(items.map((s) => s.id)).not.toContain(idled.id); }); }); + +describe('SessionService.revokeAllSessions and MCP tokens', () => { + it("revokes an admin's MCP tokens as sessions_revoked inside the sessions' transaction", async () => { + const { tokens, revokeAllForUser } = makeMcpTokenService(); + const admin = await seedUser(db, { role: 'admin' }); + await seedSession(admin.id); + const tokenId = await seedActiveToken(admin.id); + const actorId = randomUUID(); + + await service(tokens).revokeAllSessions(admin.id, actorId, META); + + expect(revokeAllForUser).toHaveBeenCalledTimes(1); + expect(revokeAllForUser).toHaveBeenCalledWith( + { userId: admin.id, reason: 'sessions_revoked', actorId, ...META }, + expect.anything(), + ); + expect(await revocationOf(tokenId)).toEqual({ + revokedAt: expect.any(Date), + revokedBy: actorId, + revokeReason: 'sessions_revoked', + }); + expect(await activeSessionCount(admin.id)).toBe(0); + }); + + it('passes a null actor and no client details for a revoke nobody initiated', async () => { + const { port, revokeAllForUser } = makeMcpTokenRevocation(); + const admin = await seedUser(db, { role: 'admin' }); + + await service(port).revokeAllSessions(admin.id); + + expect(revokeAllForUser).toHaveBeenCalledWith( + { + userId: admin.id, + reason: 'sessions_revoked', + actorId: null, + ip: null, + userAgent: null, + }, + expect.anything(), + ); + }); + + it('rolls the sessions back when the token revocation fails, and a retry revokes both', async () => { + const { tokens, audit } = makeMcpTokenService(); + audit.recordManyInTransaction.mockRejectedValueOnce(new Error('audit store unavailable')); + const events = makeEventBus(); + const admin = await seedUser(db, { role: 'admin' }); + await seedSession(admin.id); + await seedSession(admin.id); + const tokenId = await seedActiveToken(admin.id); + + await expect(service(tokens, events).revokeAllSessions(admin.id, randomUUID())).rejects.toThrow( + 'audit store unavailable', + ); + + expect(await activeSessionCount(admin.id)).toBe(2); + expect(await revocationOf(tokenId)).toMatchObject({ revokedAt: null, revokeReason: null }); + expect(events.emit).not.toHaveBeenCalled(); + + await service(tokens, events).revokeAllSessions(admin.id, randomUUID()); + + expect(await activeSessionCount(admin.id)).toBe(0); + expect(await revocationOf(tokenId)).toMatchObject({ revokeReason: 'sessions_revoked' }); + expect(events.emit).toHaveBeenCalledTimes(1); + expect(events.emit).toHaveBeenCalledWith( + 'identity.sessions.revoked_all', + expect.objectContaining({ userId: admin.id }), + ); + }); + + it("revokes a player's sessions without touching the token port", async () => { + const { port, revokeAllForUser } = makeMcpTokenRevocation(); + const events = makeEventBus(); + const player = await seedUser(db, { role: 'player' }); + await seedSession(player.id); + + await service(port, events).revokeAllSessions(player.id, randomUUID(), META); + + expect(revokeAllForUser).not.toHaveBeenCalled(); + expect(await activeSessionCount(player.id)).toBe(0); + expect(events.emit).toHaveBeenCalledWith( + 'identity.sessions.revoked_all', + expect.objectContaining({ userId: player.id, ...META }), + ); + }); +}); + +describe('SessionService.revokeMcpTokens', () => { + it("revokes an admin's MCP tokens as sessions_revoked and leaves the sessions alone", async () => { + const { tokens, revokeAllForUser } = makeMcpTokenService(); + const admin = await seedUser(db, { role: 'admin' }); + await seedSession(admin.id); + const tokenId = await seedActiveToken(admin.id); + + await service(tokens).revokeMcpTokens(admin.id, admin.id, META); + + expect(revokeAllForUser).toHaveBeenCalledWith({ + userId: admin.id, + reason: 'sessions_revoked', + actorId: admin.id, + ...META, + }); + expect(await revocationOf(tokenId)).toMatchObject({ + revokedBy: admin.id, + revokeReason: 'sessions_revoked', + }); + expect(await activeSessionCount(admin.id)).toBe(1); + }); + + it('never touches the token port for a player', async () => { + const { port, revokeAllForUser } = makeMcpTokenRevocation(); + const player = await seedUser(db, { role: 'player' }); + + await service(port).revokeMcpTokens(player.id, player.id, META); + + expect(revokeAllForUser).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/pam/identity/admin-user-directory.ts b/packages/core/src/pam/identity/admin-user-directory.ts index 1034e3dbc..64f46ca85 100644 --- a/packages/core/src/pam/identity/admin-user-directory.ts +++ b/packages/core/src/pam/identity/admin-user-directory.ts @@ -4,6 +4,8 @@ import type { ClientMeta, KycStatus, MailRecipientDirectory, + McpTokenAutomaticRevokeReason, + McpTokenRevocation, PlayerIdSearchOptions, } from '@openora/core/contracts'; import { KycStatusSchema, normalizeKycStatus, UuidSchema } from '@openora/core/contracts'; @@ -47,10 +49,29 @@ function toRow(r: typeof user.$inferSelect) { }; } +type AdminUserPatch = { isActive?: boolean; role?: string }; + +/** + * The revocation a patch calls for, keyed on the state it writes rather than on the change it + * makes: repeating a deactivation or a demotion revokes again. + */ +export function automaticMcpRevokeReason( + patch: AdminUserPatch, +): McpTokenAutomaticRevokeReason | null { + if (patch.isActive === false) { + return 'admin_disabled'; + } + if (patch.role !== undefined && patch.role !== 'admin') { + return 'admin_role_removed'; + } + return null; +} + export class DrizzleAdminUserDirectory implements AdminUserDirectory { constructor( private readonly drizzle: DrizzleService, private readonly events: EventBus, + private readonly mcpTokens?: McpTokenRevocation, ) {} async count() { @@ -112,16 +133,7 @@ export class DrizzleAdminUserDirectory implements AdminUserDirectory { return rows.map(toRow); } - async update( - id: string, - patch: { isActive?: boolean; role?: string }, - actorId: string, - meta?: ClientMeta, - ) { - const [existing] = await this.drizzle.db.select().from(user).where(eq(user.id, id)); - if (!existing) { - return null; - } + async update(id: string, patch: AdminUserPatch, actorId: string, meta?: ClientMeta) { const set: Partial = {}; if (patch.isActive !== undefined) { set.isActive = patch.isActive; @@ -129,23 +141,45 @@ export class DrizzleAdminUserDirectory implements AdminUserDirectory { if (patch.role !== undefined) { set.role = patch.role; } - const [r] = await this.drizzle.db.update(user).set(set).where(eq(user.id, id)).returning(); - if (!r) { + const ip = meta?.ip ?? null; + const userAgent = meta?.userAgent ?? null; + const mcpRevokeReason = automaticMcpRevokeReason(patch); + + const written = await this.drizzle.db.transaction(async (tx) => { + const [existing] = await tx + .select({ isActive: user.isActive }) + .from(user) + .where(eq(user.id, id)) + .for('update'); + if (!existing) { + return null; + } + const [updated] = await tx.update(user).set(set).where(eq(user.id, id)).returning(); + if (!updated) { + return null; + } + if (mcpRevokeReason) { + await this.mcpTokens?.revokeAllForUser( + { userId: id, reason: mcpRevokeReason, actorId, ip, userAgent }, + tx, + ); + } + return { wasActive: existing.isActive, updated }; + }); + if (!written) { return null; } // Emit only on an actual active-status flip, after the commit. Literal topics // (not a ternary) so the catalog generator's emit-scanner picks them up. - if (patch.isActive !== undefined && patch.isActive !== existing.isActive) { - const ip = meta?.ip ?? null; - const userAgent = meta?.userAgent ?? null; + if (patch.isActive !== undefined && patch.isActive !== written.wasActive) { if (patch.isActive) { this.events.emit('identity.user.reactivated', { userId: id, actorId, ip, userAgent }); } else { this.events.emit('identity.user.deactivated', { userId: id, actorId, ip, userAgent }); } } - return toRow(r); + return toRow(written.updated); } async lookupPlayers(userIds: readonly string[]) { diff --git a/packages/core/src/pam/identity/plugin.ts b/packages/core/src/pam/identity/plugin.ts index 74017d9f4..63c65dee6 100644 --- a/packages/core/src/pam/identity/plugin.ts +++ b/packages/core/src/pam/identity/plugin.ts @@ -9,6 +9,7 @@ import { PLAY_ELIGIBILITY, MAIL_DISPATCH, MAIL_RECIPIENT_DIRECTORY, + MCP_TOKEN_REVOCATION, GEO_CHECK_COMMANDS, PLAYER_PROVISIONING, IDENTITY_OPTIONS, @@ -61,6 +62,19 @@ function adminSecurityConfig(c: IdentityContainer) { return platformConfig?.adminSecurity ?? AdminSecurityConfigSchema.parse({}); } +function mcpTokenRevocation(c: IdentityContainer) { + return c.has(MCP_TOKEN_REVOCATION) ? c.get(MCP_TOKEN_REVOCATION) : undefined; +} + +function makeSessionService(c: IdentityContainer) { + return new SessionService({ + drizzle: c.get(DRIZZLE), + events: c.get(EVENT_BUS), + identityReader: c.get(IDENTITY_READER), + mcpTokens: mcpTokenRevocation(c), + }); +} + function makeTrustedDevices(c: IdentityContainer) { return new TrustedDeviceService({ drizzle: c.get(DRIZZLE), @@ -85,11 +99,7 @@ function makeAdminSecurity(c: IdentityContainer) { drizzle: c.get(DRIZZLE), auth: c.get(AUTH_SESSION).auth, events: c.get(EVENT_BUS), - sessions: new SessionService({ - drizzle: c.get(DRIZZLE), - events: c.get(EVENT_BUS), - identityReader, - }), + sessions: makeSessionService(c), trustedDevices: makeTrustedDevices(c), identityReader, config: adminSecurityConfig(c), @@ -164,7 +174,7 @@ export default { // The back-office depends on this port, not on the identity schema directly. ctx.provide( ADMIN_USER_DIRECTORY, - (c) => new DrizzleAdminUserDirectory(c.get(DRIZZLE), c.get(EVENT_BUS)), + (c) => new DrizzleAdminUserDirectory(c.get(DRIZZLE), c.get(EVENT_BUS), mcpTokenRevocation(c)), ); ctx.provide(MAIL_RECIPIENT_DIRECTORY, (c) => new DrizzleMailRecipientDirectory(c.get(DRIZZLE))); // Read-only session queries for cross-module consumers (eg tag inactive evaluation). @@ -172,15 +182,7 @@ export default { // RG login-block writer. compliance drives it through the port, never the schema. ctx.provide( LOGIN_ENFORCEMENT, - (c) => - new LoginEnforcementService( - c.get(DRIZZLE), - new SessionService({ - drizzle: c.get(DRIZZLE), - events: c.get(EVENT_BUS), - identityReader: c.get(IDENTITY_READER), - }), - ), + (c) => new LoginEnforcementService(c.get(DRIZZLE), makeSessionService(c)), ); // Per-player "auto-logout when inactive". The request middleware resolves this on // every authenticated request; leaving it unbound turns the idle check off entirely. @@ -203,22 +205,14 @@ export default { ctx.provide(ADMIN_SECURITY_POLICY, (c) => resolveAdminSecurity(c)); ctx.provide(USER_COMMANDS, (c) => new DrizzleUserCommands(c.get(DRIZZLE))); ctx.provide(SESSION_COMMANDS, (c) => { - const sessionSvc = new SessionService({ - drizzle: c.get(DRIZZLE), - events: c.get(EVENT_BUS), - identityReader: c.get(IDENTITY_READER), - }); + const sessionSvc = makeSessionService(c); return { revokeAll: (userId, actorId) => sessionSvc.revokeAllSessions(userId, actorId), }; }); ctx.routers.add('identity', (c) => { realtimeTransport = c.get(REALTIME_TRANSPORT); - const sessions = new SessionService({ - drizzle: c.get(DRIZZLE), - events: c.get(EVENT_BUS), - identityReader: c.get(IDENTITY_READER), - }); + const sessions = makeSessionService(c); return createIdentityRouter( new IdentityService({ drizzle: c.get(DRIZZLE), diff --git a/packages/core/src/pam/identity/service/identity.service.ts b/packages/core/src/pam/identity/service/identity.service.ts index f77922a6d..11018afe7 100644 --- a/packages/core/src/pam/identity/service/identity.service.ts +++ b/packages/core/src/pam/identity/service/identity.service.ts @@ -585,16 +585,30 @@ export class IdentityService { .update(user) .set({ passwordMeetsPolicy: true }) .where(eq(user.id, resetUser.id)); + const meta = getCurrentClientMeta(); this.events.emit('identity.password.reset', { userId: resetUser.id, playerId: await this.identityReader.getPlayerIdByUserIdSafe(resetUser.id), - ...getCurrentClientMeta(), + ...meta, }); await this.clearLockout(resetUser.id); + await this.revokeMcpTokensAfterCredentialChange(resetUser.id, meta); }, }); } + // The new password is committed before this runs, and the MCP authenticator refuses every + // token issued before it either way, so a failed revocation is logged rather than rethrown. On + // a reset a throw would also keep every session alive: better-auth revokes them only after + // `onPasswordReset` returns. + private async revokeMcpTokensAfterCredentialChange(userId: User['id'], meta: ClientMeta) { + try { + await this.sessions?.revokeMcpTokens(userId, userId, meta); + } catch (err) { + identityLogger.error({ err, userId }, 'MCP token revocation after a password change failed'); + } + } + private async findUserByEmail(email: string): Promise<{ id: User['id'] } | undefined> { const [row] = await this.drizzle.db .select({ id: user.id }) @@ -2251,6 +2265,7 @@ export class IdentityService { }, RACED_SESSION_SWEEP_DELAY_MS); timer.unref?.(); } + await this.revokeMcpTokensAfterCredentialChange(userId, { ip, userAgent }); return SUCCESS; } diff --git a/packages/core/src/pam/identity/service/session.service.ts b/packages/core/src/pam/identity/service/session.service.ts index 058230f59..557b93412 100644 --- a/packages/core/src/pam/identity/service/session.service.ts +++ b/packages/core/src/pam/identity/service/session.service.ts @@ -1,4 +1,6 @@ import { + type DrizzleDb, + type DrizzleTx, type EventBus, DrizzleService, pageToOffset, @@ -10,6 +12,7 @@ import { AUTO_LOGOUT_MINUTES, type ClientMeta, type IdentityReader, + type McpTokenRevocation, type User, type PaginationOptions, } from '@openora/core/contracts'; @@ -55,6 +58,7 @@ export type SessionServiceDeps = { drizzle: DrizzleService; events: EventBus; identityReader: IdentityReader; + mcpTokens?: McpTokenRevocation | undefined; }; function toSessionItem(row: Session, currentSessionId?: Session['id']): SessionItem { @@ -78,11 +82,13 @@ export class SessionService { private readonly drizzle: DrizzleService; private readonly events: EventBus; private readonly identityReader: IdentityReader; + private readonly mcpTokens: McpTokenRevocation | undefined; - constructor({ drizzle, events, identityReader }: SessionServiceDeps) { + constructor({ drizzle, events, identityReader, mcpTokens }: SessionServiceDeps) { this.drizzle = drizzle; this.events = events; this.identityReader = identityReader; + this.mcpTokens = mcpTokens; } async listSessions({ @@ -239,11 +245,17 @@ export class SessionService { return { success: true as const }; } + /** For an account that may hold MCP tokens, revokes them in the same transaction. */ async revokeAllSessions(userId: User['id'], actorId?: User['id'], meta?: ClientMeta) { - await this.drizzle.db - .update(session) - .set({ expiresAt: sql`now()`, updatedAt: session.updatedAt }) - .where(and(eq(session.userId, userId), gt(session.expiresAt, sql`now()`))); + const mcpTokens = await this.mcpTokensOf(userId); + if (mcpTokens) { + await this.drizzle.db.transaction(async (tx) => { + await expireActiveSessions(tx, userId); + await mcpTokens.revokeAllForUser(sessionsRevoked({ userId, actorId, meta }), tx); + }); + } else { + await expireActiveSessions(this.drizzle.db, userId); + } this.events.emit('identity.sessions.revoked_all', { userId, @@ -254,4 +266,46 @@ export class SessionService { }); return { success: true as const }; } + + /** For a credential change that ends the user's sessions without `revokeAllSessions`. */ + async revokeMcpTokens(userId: User['id'], actorId: User['id'], meta?: ClientMeta) { + const mcpTokens = await this.mcpTokensOf(userId); + await mcpTokens?.revokeAllForUser(sessionsRevoked({ userId, actorId, meta })); + } + + private async mcpTokensOf(userId: User['id']) { + if (!this.mcpTokens) { + return undefined; + } + const [account] = await this.drizzle.db + .select({ role: user.role }) + .from(user) + .where(eq(user.id, userId)); + return account?.role === 'player' ? undefined : this.mcpTokens; + } +} + +function expireActiveSessions(db: DrizzleDb | DrizzleTx, userId: User['id']) { + return db + .update(session) + .set({ expiresAt: sql`now()`, updatedAt: session.updatedAt }) + .where(and(eq(session.userId, userId), gt(session.expiresAt, sql`now()`))); +} + +function sessionsRevoked({ + userId, + actorId, + meta, +}: { + userId: User['id']; + actorId: User['id'] | undefined; + meta: ClientMeta | undefined; +}) { + return { + userId, + reason: 'sessions_revoked' as const, + actorId: actorId ?? null, + ip: meta?.ip ?? null, + userAgent: meta?.userAgent ?? null, + }; } diff --git a/packages/core/src/server/auth/__tests__/admin-guard.int.test.ts b/packages/core/src/server/auth/__tests__/admin-guard.int.test.ts index 6c73703c3..777c003ee 100644 --- a/packages/core/src/server/auth/__tests__/admin-guard.int.test.ts +++ b/packages/core/src/server/auth/__tests__/admin-guard.int.test.ts @@ -45,9 +45,11 @@ function makeGuard({ return { guard, events }; } -async function seedUser(role: string) { +async function seedUser(role: string, { isActive = true }: { isActive?: boolean } = {}) { const id = randomUUID(); - await db.drizzle.db.execute(sql`INSERT INTO "user" (id, role) VALUES (${id}, ${role})`); + await db.drizzle.db.execute( + sql`INSERT INTO "user" (id, role, is_active) VALUES (${id}, ${role}, ${isActive})`, + ); return id; } @@ -57,7 +59,9 @@ beforeAll(async () => { process.env['DATABASE_URL'] = databaseUrl; }, ]); - await db.drizzle.db.execute(sql`CREATE TABLE "user" (id uuid PRIMARY KEY, role text NOT NULL)`); + await db.drizzle.db.execute( + sql`CREATE TABLE "user" (id uuid PRIMARY KEY, role text NOT NULL, is_active boolean NOT NULL DEFAULT true)`, + ); }); afterAll(async () => { @@ -326,6 +330,138 @@ describe('AdminGuard.assertSuperAdmin (real PG)', () => { }); }); +describe('AdminGuard.assertUser (real PG)', () => { + it('returns the caller for an admin holding the grant, with no client metadata', async () => { + const userId = await seedUser('admin'); + const { guard, events } = makeGuard(); + + await expect(guard.assertUser(userId, 'agent-proposal', 'approve')).resolves.toEqual({ + userId, + role: 'admin', + ip: null, + userAgent: null, + }); + expect(events.emit).not.toHaveBeenCalled(); + }); + + it('denies a user without the grant and emits the same denial event as assert', async () => { + const userId = await seedUser('admin'); + const { guard, events } = makeGuard({ grants: [{ resource: 'player', action: 'view' }] }); + + await expect(guard.assertUser(userId, 'agent-proposal', 'approve')).rejects.toThrow( + expect.objectContaining({ code: 'FORBIDDEN' }), + ); + expect(events.emit).toHaveBeenCalledWith('identity.user.unauthorized_access', { + userId, + playerId: null, + resource: 'agent-proposal', + action: 'approve', + ip: null, + userAgent: null, + role: 'admin', + }); + }); + + it('denies an id with no user row', async () => { + const userId = randomUUID(); + const { guard, events } = makeGuard(); + + await expect(guard.assertUser(userId, 'agent', 'run')).rejects.toThrow( + expect.objectContaining({ code: 'FORBIDDEN' }), + ); + expect(events.emit).toHaveBeenCalledWith( + 'identity.user.unauthorized_access', + expect.objectContaining({ userId, resource: 'agent', action: 'run', role: undefined }), + ); + }); + + it('enforces 2FA enrolment but runs no session-integrity check, having no session', async () => { + const userId = await seedUser('admin'); + const securityPolicy = mock({ + assertEnrolled: vi.fn(async () => undefined), + assertSessionIntact: vi.fn(async () => undefined), + }); + const { guard } = makeGuard({ securityPolicy }); + + await guard.assertUser(userId, 'agent-proposal', 'view'); + + expect(securityPolicy.assertEnrolled).toHaveBeenCalledWith({ + userId, + sessionId: null, + ip: null, + userAgent: null, + }); + expect(securityPolicy.assertSessionIntact).not.toHaveBeenCalled(); + }); + + it('propagates a refused enrolment check', async () => { + const userId = await seedUser('admin'); + const securityPolicy = mock({ + assertEnrolled: vi.fn(async () => { + throw new Error('two-factor enrolment required'); + }), + assertSessionIntact: vi.fn(async () => undefined), + }); + const { guard } = makeGuard({ securityPolicy }); + + await expect(guard.assertUser(userId, 'agent-proposal', 'view')).rejects.toThrow( + /two-factor enrolment required/, + ); + }); + + it('denies a deactivated admin who still holds the grant, and emits the denial event', async () => { + const userId = await seedUser('admin', { isActive: false }); + const { guard, events } = makeGuard(); + + await expect(guard.assertUser(userId, 'mcp-access', 'use')).rejects.toThrow( + expect.objectContaining({ code: 'FORBIDDEN' }), + ); + expect(events.emit).toHaveBeenCalledWith( + 'identity.user.unauthorized_access', + expect.objectContaining({ userId, resource: 'mcp-access', action: 'use', role: 'admin' }), + ); + }); +}); + +describe('AdminGuard.filterGranted (real PG)', () => { + const requirements = [ + { resource: 'player', action: 'view' }, + { resource: 'withdrawal', action: 'approve' }, + { resource: 'mcp-access', action: 'use' }, + ] as const; + + it('keeps what the static role grants', async () => { + const userId = await seedUser('support'); + const { guard, events } = makeGuard(); + + await expect(guard.filterGranted(userId, requirements)).resolves.toEqual([ + { resource: 'player', action: 'view' }, + ]); + expect(events.emit).not.toHaveBeenCalled(); + }); + + it('keeps what the DB grants, over the static role', async () => { + const userId = await seedUser('admin'); + const { guard } = makeGuard({ grants: [{ resource: 'mcp-access', action: 'use' }] }); + + await expect(guard.filterGranted(userId, requirements)).resolves.toEqual([ + { resource: 'mcp-access', action: 'use' }, + ]); + }); + + it.each([ + ['a deactivated admin', () => seedUser('admin', { isActive: false })], + ['a role with no static entry', () => seedUser('player')], + ['an id with no user row', async () => randomUUID()], + ])('keeps nothing for %s and emits nothing', async (_label, seed) => { + const userId = await seed(); + const { guard, events } = makeGuard(); + + await expect(guard.filterGranted(userId, requirements)).resolves.toEqual([]); + expect(events.emit).not.toHaveBeenCalled(); + }); +}); + describe('AdminGuard.assertUser (real PG)', () => { it('returns the caller for an admin holding the grant, with no client metadata', async () => { const userId = await seedUser('admin'); diff --git a/packages/core/src/server/auth/__tests__/permissions.test.ts b/packages/core/src/server/auth/__tests__/permissions.test.ts index 12f683aee..f5bcc9ec3 100644 --- a/packages/core/src/server/auth/__tests__/permissions.test.ts +++ b/packages/core/src/server/auth/__tests__/permissions.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { adminStatement } from '@openora/core/contracts'; -import { adminRole, statement } from '../permissions.js'; +import { adminRole, holdsGrant, isRoleName, statement } from '../permissions.js'; describe('admin permission catalog', () => { it('serves the same catalog the browser imports from contracts', () => { @@ -29,3 +29,51 @@ describe('admin permission catalog', () => { ).toBe(true); }); }); + +describe('isRoleName', () => { + it.each(['admin', 'support', 'content-manager'])('knows the static %s role', (role) => { + expect(isRoleName(role)).toBe(true); + }); + + it.each(['player', 'constructor', '__proto__', 'toString'])('does not know %s', (role) => { + expect(isRoleName(role)).toBe(false); + }); +}); + +describe('holdsGrant', () => { + it('decides from the DB grants alone when the user holds an assignment', () => { + const grants = [{ resource: 'player', action: 'view' }]; + + expect(holdsGrant({ role: 'admin', grants }, 'player', 'view')).toBe(true); + expect(holdsGrant({ role: 'admin', grants }, 'mcp-access', 'use')).toBe(false); + expect(holdsGrant({ role: 'support', grants: [] }, 'player', 'view')).toBe(false); + }); + + it('falls back to the static role table without an assignment', () => { + expect(holdsGrant({ role: 'admin', grants: null }, 'mcp-access', 'use')).toBe(true); + expect(holdsGrant({ role: 'support', grants: null }, 'player', 'view')).toBe(true); + expect(holdsGrant({ role: 'support', grants: null }, 'mcp-access', 'use')).toBe(false); + }); + + it('holds nothing for a role the static table does not know, grants or not', () => { + const grants = [{ resource: 'mcp-access', action: 'use' }]; + + expect(holdsGrant({ role: 'player', grants }, 'mcp-access', 'use')).toBe(false); + expect(holdsGrant({ role: 'constructor', grants: null }, 'mcp-access', 'use')).toBe(false); + }); + + it('declares the agent resources and grants them to the built-in admin role', () => { + expect(adminStatement).toMatchObject({ + agent: ['view', 'create', 'update', 'publish', 'run'], + 'agent-proposal': ['view', 'approve', 'reject'], + 'agent-config': ['view', 'update'], + }); + expect( + adminRole.authorize({ + agent: ['view', 'create', 'update', 'publish', 'run'], + 'agent-proposal': ['view', 'approve', 'reject'], + 'agent-config': ['view', 'update'], + }).success, + ).toBe(true); + }); +}); diff --git a/packages/core/src/server/auth/admin-guard.ts b/packages/core/src/server/auth/admin-guard.ts index e4e05e886..3741ef4e0 100644 --- a/packages/core/src/server/auth/admin-guard.ts +++ b/packages/core/src/server/auth/admin-guard.ts @@ -12,7 +12,7 @@ import { import { DrizzleService } from '../db/index.js'; import { sql } from 'drizzle-orm'; import { SessionResolver } from './session-resolver.js'; -import { roles, type ResourceName, type ActionOf } from './permissions.js'; +import { holdsGrant, isRoleName, type ResourceName, type ActionOf } from './permissions.js'; import type { OssContext, EventBus } from '../kernel/index.js'; import { createLogger } from '../kernel/logger.js'; import { extractClientMeta } from '../kernel/router-utils.js'; @@ -23,6 +23,8 @@ const logger = createLogger('admin-guard'); export type AdminCaller = { userId: string; role: string } & ClientMeta; +type GrantRequirement = { resource: string; action: string }; + /** * The single admin-enforcement point - every admin route calls `assert()` as its * first line, never re-implementing the role check. Overload without @@ -99,7 +101,8 @@ export class AdminGuard { /** * `assert` for a caller that is not an HTTP request (an agent run, an MCP token): the same * user, role, grant and 2FA-enrolment checks and the same denial event, without a session - - * so no session-integrity check, and `ip`/`userAgent` are null. + * so no session-integrity check, and `ip`/`userAgent` are null. A deactivated account is + * refused too: with no session to revoke, nothing else stops a credential it issued. */ async assertUser( userId: string, @@ -110,9 +113,22 @@ export class AdminGuard { userId, clientMeta: { ip: null, userAgent: null }, permission: { resource, action }, + requireActive: true, }); } + /** + * The requirements `userId` holds, decided as `assertUser` decides but with no denial event + * and no 2FA check: it narrows what a caller is shown and never authorizes anything. + */ + async filterGranted( + userId: string, + requirements: readonly T[], + ): Promise { + const { account, granted } = await this.resolveAccess(userId, requirements); + return account?.isActive && account.isAdmin ? granted : []; + } + async assertSuperAdmin(context: unknown): Promise { const caller = await this.assert(context); const isSuper = this.permissionResolver @@ -141,53 +157,39 @@ export class AdminGuard { clientMeta: { ip, userAgent }, permission, session, + requireActive = false, }: { userId: string; clientMeta: ClientMeta; permission: { resource: R; action: ActionOf } | undefined; session?: { sessionId: string | null }; + requireActive?: boolean; }): Promise { const deniedResource = permission?.resource ?? 'admin'; const deniedAction = permission?.action ?? 'access'; - const result = await this.drizzle.db.execute( - sql`SELECT id, role FROM "user" WHERE id = ${userId} LIMIT 1`, - ); - const userRecord = result.rows[0] as { id: string; role: string } | undefined; - if (!userRecord) { - this.emitUnauthorized(userId, undefined, deniedResource, deniedAction, ip, userAgent); + const { account, granted } = await this.resolveAccess(userId, permission ? [permission] : []); + if (!account || (requireActive && !account.isActive) || !account.isAdmin) { + this.emitUnauthorized(userId, account?.role, deniedResource, deniedAction, ip, userAgent); throw new ORPCError('FORBIDDEN', { message: 'Admin access required', data: { reason: AuthGuardReasonSchema.enum.admin_required }, }); } - const userRole = roles[userRecord.role as keyof typeof roles]; - if (!userRole) { - this.emitUnauthorized(userId, userRecord.role, deniedResource, deniedAction, ip, userAgent); + if (permission && granted.length === 0) { + const { resource, action } = permission; + this.emitUnauthorized(userId, account.role, resource, action, ip, userAgent); throw new ORPCError('FORBIDDEN', { - message: 'Admin access required', - data: { reason: AuthGuardReasonSchema.enum.admin_required }, + message: `Missing permission: ${String(resource)}:${String(action)}`, + data: { + reason: AuthGuardReasonSchema.enum.permission_denied, + resource: String(resource), + action: String(action), + }, }); } - if (permission) { - const { resource, action } = permission; - const grants = await this.resolveGrants(userId); - const allowed = this.checkGrant(grants, userRole, resource, action); - if (!allowed) { - this.emitUnauthorized(userId, userRecord.role, resource, action, ip, userAgent); - throw new ORPCError('FORBIDDEN', { - message: `Missing permission: ${String(resource)}:${String(action)}`, - data: { - reason: AuthGuardReasonSchema.enum.permission_denied, - resource: String(resource), - action: String(action), - }, - }); - } - } - if (this.securityPolicy) { const securityContext = { userId, sessionId: session?.sessionId ?? null, ip, userAgent }; await this.securityPolicy.assertEnrolled(securityContext); @@ -196,7 +198,37 @@ export class AdminGuard { } } - return { userId, role: userRecord.role, ip, userAgent }; + return { userId, role: account.role, ip, userAgent }; + } + + /** + * The account behind `userId` and which of `requirements` it holds under `holdsGrant`. Grants + * are read only for an account whose role the static table knows, and only when there is + * something to check. + */ + private async resolveAccess( + userId: string, + requirements: readonly T[], + ) { + const account = await this.findAccount(userId); + if (!account?.isAdmin || requirements.length === 0) { + return { account, granted: [] }; + } + const grants = await this.resolveGrants(userId); + return { + account, + granted: requirements.filter(({ resource, action }) => + holdsGrant({ role: account.role, grants }, resource, action), + ), + }; + } + + private async findAccount(userId: string) { + const result = await this.drizzle.db.execute<{ role: string; is_active: boolean }>( + sql`SELECT role, is_active FROM "user" WHERE id = ${userId} LIMIT 1`, + ); + const row = result.rows[0]; + return row && { role: row.role, isActive: row.is_active, isAdmin: isRoleName(row.role) }; } // null only when no resolver is bound - an unassigned admin under a bound resolver gets []. @@ -206,18 +238,6 @@ export class AdminGuard { : Promise.resolve(null); } - private checkGrant( - grants: AdminGrant[] | null, - userRole: (typeof roles)[keyof typeof roles] | undefined, - resource: string, - action: string, - ): boolean { - if (grants !== null) { - return grants.some((g) => g.resource === resource && g.action === action); - } - return userRole?.authorize({ [resource]: [action] }).success ?? false; - } - private emitUnauthorized( userId: string, role: string | undefined, diff --git a/packages/core/src/server/auth/index.ts b/packages/core/src/server/auth/index.ts index 402e951b8..4d3f8b370 100644 --- a/packages/core/src/server/auth/index.ts +++ b/packages/core/src/server/auth/index.ts @@ -4,7 +4,7 @@ export { AdminGuard, ADMIN_GUARD } from './admin-guard.js'; export type { AdminCaller } from './admin-guard.js'; export { SessionResolver, AUTH_SESSION } from './session-resolver.js'; export { signSessionCookie, type SessionCookieConfig } from './sign-session-cookie.js'; -export { ac, roles, statement } from './permissions.js'; +export { ac, roles, statement, isRoleName, holdsGrant } from './permissions.js'; export type { RoleName, ResourceName, ActionOf } from './permissions.js'; export { PERMISSION_LEVELS, diff --git a/packages/core/src/server/auth/permissions.ts b/packages/core/src/server/auth/permissions.ts index c9b3b586d..3d547341a 100644 --- a/packages/core/src/server/auth/permissions.ts +++ b/packages/core/src/server/auth/permissions.ts @@ -1,5 +1,5 @@ import { createAccessControl } from 'better-auth/plugins/access'; -import { adminStatement } from '@openora/core/contracts'; +import { adminStatement, type AdminGrant } from '@openora/core/contracts'; // The catalog itself lives in contracts so the browser can import it too; this // name stays because better-auth calls it a statement. @@ -38,6 +38,8 @@ export const adminRole = ac.newRole({ agent: ['view', 'create', 'update', 'publish', 'run'], 'agent-proposal': ['view', 'approve', 'reject'], 'agent-config': ['view', 'update'], + 'mcp-access': ['use'], + 'mcp-token': ['view', 'revoke'], }); export const supportRole = ac.newRole({ @@ -66,3 +68,26 @@ export type { AdminResource as ResourceName, AdminActionOf as ActionOf, } from '@openora/core/contracts'; + +export function isRoleName(name: string): name is RoleName { + return Object.hasOwn(roles, name); +} + +/** + * AdminGuard's grant rule. A user holding any DB role assignment has exactly the grants those + * roles give (`grants`); a user with none (`null`) falls back to the static role table by + * `user.role`. A role missing from that table holds nothing either way. + */ +export function holdsGrant( + { role, grants }: { role: string; grants: readonly AdminGrant[] | null }, + resource: string, + action: string, +) { + if (!isRoleName(role)) { + return false; + } + if (grants !== null) { + return grants.some((grant) => grant.resource === resource && grant.action === action); + } + return roles[role].authorize({ [resource]: [action] }).success; +} diff --git a/packages/core/src/server/kernel/__tests__/platform-config-loader.test.ts b/packages/core/src/server/kernel/__tests__/platform-config-loader.test.ts index 08c932aff..648859ab5 100644 --- a/packages/core/src/server/kernel/__tests__/platform-config-loader.test.ts +++ b/packages/core/src/server/kernel/__tests__/platform-config-loader.test.ts @@ -91,6 +91,23 @@ describe('loadPlatformConfig yaml parsing', () => { expect(cfg.supportedLanguages).toEqual(['en', 'uk']); }); + it('parses a list item that is a URL as one string', () => { + const cfg = loadYaml( + 'agents:\n mcp:\n allowedOrigins:\n - https://backoffice.example.com\n - "https://ops.example.com"\n', + ); + + expect(cfg.agents.mcp.allowedOrigins).toEqual([ + 'https://backoffice.example.com', + 'https://ops.example.com', + ]); + }); + + it('keeps a list item whose colon is not followed by a space as one string', () => { + const cfg = loadYaml('reservedUsernames:\n - ops:admin\n - support\n'); + + expect(cfg.reservedUsernames).toEqual(['ops:admin', 'support']); + }); + it('parses a list of objects', () => { const cfg = loadYaml( 'brands:\n - id: main\n name: Main\n - id: vip\n name: VIP\nactiveBrand: vip\n', diff --git a/packages/core/src/server/kernel/__tests__/redis-rate-limiter.int.test.ts b/packages/core/src/server/kernel/__tests__/redis-rate-limiter.int.test.ts index e163f4508..e729404a1 100644 --- a/packages/core/src/server/kernel/__tests__/redis-rate-limiter.int.test.ts +++ b/packages/core/src/server/kernel/__tests__/redis-rate-limiter.int.test.ts @@ -68,12 +68,39 @@ describe('RedisRateLimiter', () => { expect(await offlineLimiter().consume('login:a', WINDOW)).toEqual({ allowed: true, retryAfterMs: 0, + unavailable: true, }); }); it('fails closed with windowMs when not ready and the key opts into deny', async () => { expect(await offlineLimiter().consume('login:a', { ...WINDOW, onUnavailable: 'deny' })).toEqual( - { allowed: false, retryAfterMs: WINDOW.windowMs }, + { allowed: false, retryAfterMs: WINDOW.windowMs, unavailable: true }, ); }); + + it('marks a result unavailable when the store rejects the script, in both modes', async () => { + const limiter = new RedisRateLimiter(redis.client); + await redis.client.lPush('rl:login:list', 'not-a-counter'); + + expect(await limiter.consume('login:list', WINDOW)).toEqual({ + allowed: true, + retryAfterMs: 0, + unavailable: true, + }); + expect(await limiter.consume('login:list', { ...WINDOW, onUnavailable: 'deny' })).toEqual({ + allowed: false, + retryAfterMs: WINDOW.windowMs, + unavailable: true, + }); + }); + + it('leaves a result from a reachable store unmarked', async () => { + const limiter = new RedisRateLimiter(redis.client); + + for (let i = 0; i < WINDOW.limit; i++) { + await limiter.consume('login:a', WINDOW); + } + + expect(await limiter.consume('login:a', WINDOW)).not.toHaveProperty('unavailable'); + }); }); diff --git a/packages/core/src/server/kernel/platform-config-loader.ts b/packages/core/src/server/kernel/platform-config-loader.ts index c4f8e32c1..ee12413e9 100644 --- a/packages/core/src/server/kernel/platform-config-loader.ts +++ b/packages/core/src/server/kernel/platform-config-loader.ts @@ -68,6 +68,8 @@ export function resolvePlatformConfigPath( return undefined; } +const MAPPING_KEY_SEPARATOR = /:(?=\s|$)/; + function parseTrivialYaml(input: string): unknown { type Node = { indent: number; @@ -99,7 +101,7 @@ function parseTrivialYaml(input: string): unknown { if (body.startsWith('- ')) { const inner = body.slice(2).trim(); const item: Node = { indent, isListItem: true, children: [] }; - const colon = inner.indexOf(':'); + const colon = inner.search(MAPPING_KEY_SEPARATOR); if (colon !== -1) { item.children.push({ indent: indent + 2, diff --git a/packages/core/src/server/kernel/redis-rate-limiter.ts b/packages/core/src/server/kernel/redis-rate-limiter.ts index 654af4d12..d330a4618 100644 --- a/packages/core/src/server/kernel/redis-rate-limiter.ts +++ b/packages/core/src/server/kernel/redis-rate-limiter.ts @@ -31,9 +31,10 @@ return { count, redis.call('PTTL', KEYS[1]) } * past the limit. On a backend error/unreachable client it fails OPEN * (allows the request) by default; callers that pass `onUnavailable: 'deny'` * (credential-guessing surfaces, where an unthrottled window is worse than a - * false-positive 429) fail CLOSED instead. Never logs the raw key - it can - * embed a token or email (`pwreset:`, `login:`) - only the - * prefix before the first `:` is recorded. + * false-positive 429) fail CLOSED instead. Either way the result is marked + * `unavailable`, so a caller can tell an outage from a spent window. Never + * logs the raw key - it can embed a token or email (`pwreset:`, + * `login:`) - only the prefix before the first `:` is recorded. */ export class RedisRateLimiter implements RateLimiterAdapter { private readonly logger = createLogger('redis-rate-limiter'); @@ -81,8 +82,8 @@ export class RedisRateLimiter implements RateLimiterAdapter { 'rate limiter backend unavailable', ); if (opts.onUnavailable === 'deny') { - return { allowed: false, retryAfterMs: opts.windowMs }; + return { allowed: false, retryAfterMs: opts.windowMs, unavailable: true }; } - return { allowed: true, retryAfterMs: 0 }; + return { allowed: true, retryAfterMs: 0, unavailable: true }; } } diff --git a/packages/core/src/server/mcp/__tests__/create-app-mount.test.ts b/packages/core/src/server/mcp/__tests__/create-app-mount.test.ts new file mode 100644 index 000000000..4ae358dd8 --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/create-app-mount.test.ts @@ -0,0 +1,267 @@ +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AUDIT_WRITER, + CACHE, + JOB_QUEUE, + MCP_TOKEN_AUTHENTICATOR, + MESSAGE_BROKER, + PLATFORM_CONFIG, + RATE_LIMITER, + REALTIME_TRANSPORT, + definePlatformConfig, + type ClientMeta, + type McpTokenAuthentication, + type MessageBrokerAdapter, + type PlatformConfigInput, +} from '@openora/core/contracts'; +import { + adminCaller, + makeAuditWriter, + makeCache, + makeJobQueue, + makeRateLimiter, + makeRealtimeTransport, + mock, +} from '../../../testing/mock.js'; +import { + ADMIN_GUARD, + AUTH_SESSION, + type AdminGuard, + type SessionResolver, +} from '../../auth/index.js'; +import { getCurrentClientMeta } from '../../kernel/request-context.js'; +import { createApp } from '../../runtime/create-app.js'; + +const DUMMY_DATABASE_URL = 'postgres://test:test@127.0.0.1:1/mcp_mount_test'; +const BACKOFFICE_HOST = 'backoffice.example.com'; +const MCP_URL = `http://${BACKOFFICE_HOST}/mcp`; +const PING = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }); +const MCP_HEADERS = { + authorization: 'Bearer ora_mcp_mount_test_token', + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', +}; +const PEER_ADDRESS = '203.0.113.7'; +const SPOOFED_ADDRESS = '198.51.100.1'; + +const tokenId = randomUUID(); +const adminId = randomUUID(); + +const fromPeer = (remoteAddress: string) => ({ incoming: { socket: { remoteAddress } } }); + +async function bootApp({ + mcp = { enabled: true, allowedHosts: [BACKOFFICE_HOST] }, + authenticator = true, + audit = true, +}: { + mcp?: NonNullable['mcp']; + authenticator?: boolean; + audit?: boolean; +} = {}) { + const seenClientMeta: ClientMeta[] = []; + const resolveSession = vi.fn(async () => null); + const authenticate = vi.fn(async (): Promise => { + seenClientMeta.push(getCurrentClientMeta()); + return { ok: true, tokenId, adminId }; + }); + const auditWriter = makeAuditWriter(); + const rateLimiter = makeRateLimiter(); + const app = await createApp({ plugins: [], databaseUrl: DUMMY_DATABASE_URL }, (container) => { + container.register(MESSAGE_BROKER, () => + mock({ + publish: vi.fn(), + subscribe: vi.fn(() => () => undefined), + close: vi.fn(async () => undefined), + }), + ); + container.register(JOB_QUEUE, () => makeJobQueue()); + container.register(CACHE, () => makeCache()); + container.register(RATE_LIMITER, () => rateLimiter); + container.register(REALTIME_TRANSPORT, () => makeRealtimeTransport()); + container.register(PLATFORM_CONFIG, () => definePlatformConfig({ agents: { mcp } })); + container.register(AUTH_SESSION, () => mock({ resolveSession })); + container.register(ADMIN_GUARD, () => + mock({ + assertUser: vi.fn(async () => adminCaller({ userId: adminId })), + filterGranted: vi.fn(async () => []), + }), + ); + if (authenticator) { + container.register(MCP_TOKEN_AUTHENTICATOR, () => ({ + authenticate, + recordCall: vi.fn(async () => undefined), + })); + } + if (audit) { + container.register(AUDIT_WRITER, () => auditWriter); + } + }); + return { app, resolveSession, authenticate, seenClientMeta, auditWriter, rateLimiter }; +} + +const savedRedisUrl = process.env['REDIS_URL']; + +beforeEach(() => { + delete process.env['REDIS_URL']; +}); + +afterEach(() => { + if (savedRedisUrl !== undefined) { + process.env['REDIS_URL'] = savedRedisUrl; + } +}); + +describe('createApp refusing to boot the MCP transport', () => { + it('names the missing token authenticator', async () => { + await expect(bootApp({ authenticator: false })).rejects.toThrow( + /agents\.mcp\.enabled is on.*\n.*MCP_TOKEN_AUTHENTICATOR: load the iam module/, + ); + }); + + it('names the missing audit writer', async () => { + await expect(bootApp({ audit: false })).rejects.toThrow(/AUDIT_WRITER: load the audit module/); + }); +}); + +describe('createApp with the MCP transport on', () => { + it('answers on the bound host without running the session middleware', async () => { + const { app, resolveSession } = await bootApp(); + + const response = await app.app.request(MCP_URL, { + method: 'POST', + headers: MCP_HEADERS, + body: PING, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ jsonrpc: '2.0', id: 1, result: {} }); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(resolveSession).not.toHaveBeenCalled(); + await app.close(); + }); + + it('keeps the app CORS policy off a refused browser origin', async () => { + const { app } = await bootApp(); + + const response = await app.app.request(MCP_URL, { + method: 'POST', + headers: { ...MCP_HEADERS, origin: 'https://evil.example' }, + body: PING, + }); + + expect(response.status).toBe(403); + expect(response.headers.get('access-control-allow-origin')).toBeNull(); + expect(response.headers.get('access-control-allow-credentials')).toBeNull(); + await app.close(); + }); + + it('serves the request inside the request context, under the client address', async () => { + const { app, seenClientMeta } = await bootApp(); + + await app.app.request( + MCP_URL, + { + method: 'POST', + headers: { ...MCP_HEADERS, 'user-agent': 'mcp-client/1.0', 'x-real-ip': SPOOFED_ADDRESS }, + body: PING, + }, + fromPeer(PEER_ADDRESS), + ); + + expect(seenClientMeta).toEqual([{ ip: PEER_ADDRESS, userAgent: 'mcp-client/1.0' }]); + await app.close(); + }); + + it('limits the client address the proxy boundary resolved, not the one a header claims', async () => { + const { app, rateLimiter } = await bootApp(); + + await app.app.request( + MCP_URL, + { + method: 'POST', + headers: { ...MCP_HEADERS, 'x-real-ip': SPOOFED_ADDRESS }, + body: PING, + }, + fromPeer(PEER_ADDRESS), + ); + + expect(rateLimiter.consume).toHaveBeenCalledWith(`mcp-ip-min:${PEER_ADDRESS}`, { + limit: 300, + windowMs: 60_000, + onUnavailable: 'deny', + }); + expect(rateLimiter.consume).not.toHaveBeenCalledWith( + `mcp-ip-min:${SPOOFED_ADDRESS}`, + expect.anything(), + ); + await app.close(); + }); + + it('audits a refused tool name through the bound audit writer', async () => { + const { app, auditWriter } = await bootApp(); + + const response = await app.app.request( + MCP_URL, + { + method: 'POST', + headers: { ...MCP_HEADERS, 'user-agent': 'mcp-client/1.0' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'player_delete', arguments: {} }, + }), + }, + fromPeer(PEER_ADDRESS), + ); + + expect(await response.json()).toMatchObject({ + id: 2, + error: { code: -32602, message: expect.stringContaining('Unknown tool') }, + }); + expect(auditWriter.record).toHaveBeenCalledTimes(1); + expect(auditWriter.record).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: adminId, + action: 'mcp.tool.failed', + resourceId: 'player_delete', + ip: PEER_ADDRESS, + userAgent: 'mcp-client/1.0', + after: expect.objectContaining({ error: 'not_exposed', tokenId }), + }), + ); + await app.close(); + }); + + it('falls through to the normal 404 on a host it is not bound to', async () => { + const { app, authenticate, resolveSession } = await bootApp(); + + const response = await app.app.request('http://player.example.com/mcp', { + method: 'POST', + headers: MCP_HEADERS, + body: PING, + }); + + expect(response.status).toBe(404); + expect(authenticate).not.toHaveBeenCalled(); + expect(resolveSession).toHaveBeenCalledTimes(1); + await app.close(); + }); + + it('refuses a body over 1 MiB with a no-store JSON-RPC 413, before authenticating', async () => { + const { app, authenticate } = await bootApp(); + + const response = await app.app.request(MCP_URL, { + method: 'POST', + headers: MCP_HEADERS, + body: 'x'.repeat(1_048_577), + }); + + expect(response.status).toBe(413); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).toMatchObject({ jsonrpc: '2.0', error: { code: -32000 } }); + expect(authenticate).not.toHaveBeenCalled(); + await app.close(); + }); +}); diff --git a/packages/core/src/server/mcp/__tests__/create-app-without-mcp-sdk.test.ts b/packages/core/src/server/mcp/__tests__/create-app-without-mcp-sdk.test.ts new file mode 100644 index 000000000..deee97a8f --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/create-app-without-mcp-sdk.test.ts @@ -0,0 +1,127 @@ +import { readFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as z from 'zod'; +import { + AUDIT_WRITER, + CACHE, + JOB_QUEUE, + MCP_TOKEN_AUTHENTICATOR, + MESSAGE_BROKER, + PLATFORM_CONFIG, + RATE_LIMITER, + REALTIME_TRANSPORT, + definePlatformConfig, + type MessageBrokerAdapter, + type PlatformConfigInput, +} from '@openora/core/contracts'; +import { + makeAuditWriter, + makeCache, + makeJobQueue, + makeRateLimiter, + makeRealtimeTransport, + mock, +} from '../../../testing/mock.js'; +import { AUTH_SESSION, type SessionResolver } from '../../auth/index.js'; +import { createApp } from '../../runtime/create-app.js'; + +const missingSdk = vi.hoisted( + () => () => + Object.assign( + new Error( + "Cannot find package '@modelcontextprotocol/sdk' imported from /srv/app/node_modules/@openora/core/dist/server/mcp/http-transport.js", + ), + { code: 'ERR_MODULE_NOT_FOUND' }, + ), +); +vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => { + throw missingSdk(); +}); +vi.mock('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js', () => { + throw missingSdk(); +}); +vi.mock('@modelcontextprotocol/sdk/types.js', () => { + throw missingSdk(); +}); +vi.mock('@modelcontextprotocol/sdk/validation/ajv', () => { + throw missingSdk(); +}); + +const MCP_SDK_PACKAGE = '@modelcontextprotocol/sdk'; +const DUMMY_DATABASE_URL = 'postgres://test:test@127.0.0.1:1/mcp_sdk_missing_test'; +const BACKOFFICE_HOST = 'backoffice.example.com'; + +const CorePackageSchema = z.object({ + peerDependencies: z.object({ [MCP_SDK_PACKAGE]: z.string() }), + peerDependenciesMeta: z.object({ [MCP_SDK_PACKAGE]: z.object({ optional: z.literal(true) }) }), +}); + +function declaredSdkPeer() { + const corePackage = CorePackageSchema.parse( + JSON.parse(readFileSync(new URL('../../../../package.json', import.meta.url), 'utf8')), + ); + return corePackage.peerDependencies[MCP_SDK_PACKAGE]; +} + +function bootApp(mcp: NonNullable['mcp']) { + return createApp({ plugins: [], databaseUrl: DUMMY_DATABASE_URL }, (container) => { + container.register(MESSAGE_BROKER, () => + mock({ + publish: vi.fn(), + subscribe: vi.fn(() => () => undefined), + close: vi.fn(async () => undefined), + }), + ); + container.register(JOB_QUEUE, () => makeJobQueue()); + container.register(CACHE, () => makeCache()); + container.register(RATE_LIMITER, () => makeRateLimiter()); + container.register(REALTIME_TRANSPORT, () => makeRealtimeTransport()); + container.register(PLATFORM_CONFIG, () => definePlatformConfig({ agents: { mcp } })); + container.register(AUTH_SESSION, () => + mock({ resolveSession: vi.fn(async () => null) }), + ); + container.register(MCP_TOKEN_AUTHENTICATOR, () => ({ + authenticate: vi.fn(async () => ({ + ok: true as const, + tokenId: randomUUID(), + adminId: randomUUID(), + })), + recordCall: vi.fn(async () => undefined), + })); + container.register(AUDIT_WRITER, () => makeAuditWriter()); + }); +} + +const savedRedisUrl = process.env['REDIS_URL']; + +beforeEach(() => { + delete process.env['REDIS_URL']; +}); + +afterEach(() => { + if (savedRedisUrl !== undefined) { + process.env['REDIS_URL'] = savedRedisUrl; + } +}); + +describe('createApp without the optional MCP SDK installed', () => { + it('boots and serves with the transport off, never loading the SDK', async () => { + const app = await bootApp({ allowedHosts: [BACKOFFICE_HOST] }); + + const response = await app.app.request(`http://${BACKOFFICE_HOST}/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }), + }); + + expect(response.status).toBe(404); + await app.close(); + }); + + it('refuses to boot with the transport on, naming the optional peer and the version core declares', async () => { + await expect(bootApp({ enabled: true, allowedHosts: [BACKOFFICE_HOST] })).rejects.toThrow( + `${MCP_SDK_PACKAGE} is not installed. @openora/core declares it as an optional peer dependency: install ${MCP_SDK_PACKAGE}@${declaredSdkPeer()}, or turn agents.mcp.enabled off.`, + ); + }); +}); diff --git a/packages/core/src/server/mcp/__tests__/fixtures/transport-kernel.ts b/packages/core/src/server/mcp/__tests__/fixtures/transport-kernel.ts new file mode 100644 index 000000000..c636f0a01 --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/fixtures/transport-kernel.ts @@ -0,0 +1,93 @@ +import { vi } from 'vitest'; +import * as z from 'zod'; +import { + MoneyAmountSchema, + UuidSchema, + defineMcpTool, + type AuditWritePort, + type McpToolHandler, + type TokenCatalog, +} from '@openora/core/contracts'; +import { Container } from '../../../kernel/index.js'; +import { ModuleRegistryImpl } from '../../../plugin-host/module-registry.js'; +import { createMcpKernel, type McpAuthorizer } from '../../kernel.js'; + +const summaryInput = z.object({ playerId: UuidSchema }); + +const summaryOutput = z + .object({ + playerId: UuidSchema, + email: z.string(), + status: z.string(), + balance: MoneyAmountSchema, + note: z.string().optional(), + riskScore: z.number(), + }) + .refine((output) => output.status.length > 0, { message: 'status is required' }); + +export const playerSummaryTool = defineMcpTool({ + id: 'player.summary', + title: 'Player summary', + description: 'Status and balance of one player', + class: 'read', + schemaVersion: 1, + iam: { resource: 'player', action: 'view' }, + inputSchema: summaryInput, + outputSchema: summaryOutput, + redact: { allow: ['playerId', 'email', 'status', 'balance', 'note'], personal: ['email'] }, + errors: ['player_not_found'], +}); + +export const flagPlayerTool = defineMcpTool({ + id: 'player.flag', + title: 'Flag player', + description: 'Proposes a review flag on one player', + class: 'propose', + schemaVersion: 1, + iam: { resource: 'player', action: 'update' }, + inputSchema: z.object({ playerId: UuidSchema }), + outputSchema: z.object({ flagged: z.boolean() }), + redact: { allow: ['flagged'] }, + errors: [], +}); + +export type SummaryHandler = McpToolHandler; + +export const PLAYER_EMAIL = 'player@example.com'; + +export const summaryHandler: SummaryHandler = async ({ playerId }) => ({ + playerId, + email: PLAYER_EMAIL, + status: 'active', + balance: '12.50', + riskScore: 7, +}); + +function auditWriter() { + return { + record: vi.fn(async () => undefined), + recordInTransaction: vi.fn(async () => undefined), + recordManyInTransaction: vi.fn( + async () => undefined, + ), + }; +} + +export function transportKernel({ + handler = summaryHandler, + authorize = async () => 'allowed', +}: { handler?: SummaryHandler; authorize?: McpAuthorizer } = {}) { + const registry = new ModuleRegistryImpl(new Container()); + registry.setOwner('players'); + registry.mcp.tool(playerSummaryTool, () => handler); + registry.mcp.tool(flagPlayerTool, () => async () => ({ flagged: true })); + const audit = auditWriter(); + const kernel = createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container: new Container(), + authorize, + audit, + }); + return { kernel, audit }; +} diff --git a/packages/core/src/server/mcp/__tests__/http-transport.test.ts b/packages/core/src/server/mcp/__tests__/http-transport.test.ts new file mode 100644 index 000000000..243594043 --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/http-transport.test.ts @@ -0,0 +1,749 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { inspect } from 'node:util'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { CallToolResultSchema, type CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { ORPCError } from '@orpc/server'; +import { + McpToolError, + McpTransportConfigSchema, + type ClientMeta, + type McpKernel, + type McpTokenAuthentication, + type RateLimitKey, + type RateLimitResult, + type RateLimiterAdapter, +} from '@openora/core/contracts'; +import { NO_CLIENT_META, adminCaller, makeRateLimiter, mock } from '../../../testing/mock.js'; +import type { AdminGuard } from '../../auth/admin-guard.js'; +import { sha256Hex } from '../canonical-json.js'; +import { createMcpHttpTransport, type McpHttpTransport } from '../http-transport.js'; +import type { McpAuthorizer } from '../kernel.js'; +import { + PLAYER_EMAIL, + summaryHandler, + transportKernel, + type SummaryHandler, +} from './fixtures/transport-kernel.js'; + +const log = vi.hoisted(() => ({ + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), +})); +vi.mock('../../kernel/logger.js', () => ({ createLogger: () => log })); + +const MCP_URL = 'http://localhost/mcp'; +const BACKOFFICE = 'https://backoffice.example.com'; +const TOKEN = `ora_mcp_${'Tk3n_'.repeat(8)}abc`; +const TOKEN_HASH = createHash('sha256').update(TOKEN).digest('hex'); +const PING = { jsonrpc: '2.0', id: 1, method: 'ping' }; +const SECRET_FAILURE = 'connection to db failed: password=hunter2'; +const CLIENT_META: ClientMeta = { ip: '203.0.113.7', userAgent: 'mcp-client/1.0' }; +const MAX_AUDITED_NAME_LENGTH = 128; +const ALLOWED: RateLimitResult = { allowed: true, retryAfterMs: 0 }; +const STORE_DOWN: RateLimitResult = { allowed: false, retryAfterMs: 60_000, unavailable: true }; +const WINDOW_PREFIXES = { ip: 'mcp-ip-min:', min: 'mcp-token-min:', day: 'mcp-token-day:' }; + +const tokenId = randomUUID(); +const adminId = randomUUID(); +const playerId = randomUUID(); + +type Requirement = { resource: string; action: string }; + +type Setup = { + authenticate?: (bearer: string) => Promise; + handler?: SummaryHandler; + authorize?: McpAuthorizer; + wrapKernel?: (kernel: McpKernel) => McpKernel; + config?: Record; + assertUser?: () => Promise; + filterGranted?: (requirements: readonly T[]) => Promise; + rateLimiter?: RateLimiterAdapter; + recordCall?: () => Promise; +}; + +function setup({ + authenticate = async () => ({ ok: true, tokenId, adminId }), + handler = summaryHandler, + authorize, + wrapKernel = (kernel) => kernel, + config = {}, + assertUser = async () => adminCaller({ userId: adminId }), + filterGranted = async (requirements) => [...requirements], + rateLimiter = makeRateLimiter(), + recordCall = async () => undefined, +}: Setup = {}) { + const { kernel, audit } = transportKernel({ handler, authorize }); + const invokeTool = vi.fn(kernel.invokeTool); + const authenticator = { authenticate: vi.fn(authenticate), recordCall: vi.fn(recordCall) }; + const guard = { + assertUser: vi.fn(assertUser), + filterGranted: vi.fn(async (_userId: string, requirements: readonly Requirement[]) => + filterGranted(requirements), + ), + }; + const transport = createMcpHttpTransport({ + kernel: wrapKernel({ ...kernel, invokeTool }), + authenticator, + adminGuard: mock>(guard), + rateLimiter, + audit, + config: McpTransportConfigSchema.parse({ + enabled: true, + allowedHosts: ['localhost'], + ...config, + }), + }); + return { transport, kernel, invokeTool, audit, authenticator, guard }; +} + +function failingWindowLimiter(window: keyof typeof WINDOW_PREFIXES, failure: RateLimitResult) { + const consume = vi.fn(async (key: RateLimitKey) => + key.startsWith(WINDOW_PREFIXES[window]) ? failure : ALLOWED, + ); + return { rateLimiter: mock>({ consume }), consume }; +} + +async function connect( + transport: McpHttpTransport, + { + headers = { Authorization: `Bearer ${TOKEN}` }, + clientMeta = NO_CLIENT_META, + }: { headers?: Record; clientMeta?: ClientMeta } = {}, +) { + const client = new Client({ name: 'transport-test', version: '1.0.0' }); + await client.connect( + new StreamableHTTPClientTransport(new URL(MCP_URL), { + fetch: (url, init) => transport.handle(new Request(url, init), clientMeta), + requestInit: { headers }, + }), + ); + return client; +} + +function send( + transport: McpHttpTransport, + { + method = 'POST', + headers = {}, + body = JSON.stringify(PING), + clientMeta = NO_CLIENT_META, + }: { + method?: string; + headers?: Record; + body?: string | null; + clientMeta?: ClientMeta; + } = {}, +) { + return transport.handle( + new Request(MCP_URL, { + method, + headers: { + authorization: `Bearer ${TOKEN}`, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...headers, + }, + body: method === 'GET' || method === 'HEAD' || method === 'OPTIONS' ? null : body, + }), + clientMeta, + ); +} + +function textOf(result: CallToolResult) { + const [block] = result.content; + if (block?.type !== 'text') { + throw new Error('expected a text block'); + } + return JSON.parse(block.text); +} + +async function callSummary(client: Client, args: Record = { playerId }) { + await client.listTools(); + return CallToolResultSchema.parse( + await client.callTool({ name: 'player_summary', arguments: args }), + ); +} + +const loggedOutput = () => + inspect( + Object.values(log).map((method) => method.mock.calls), + { depth: null }, + ); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('MCP HTTP transport through the SDK client', () => { + it('initializes as openora at the kernel catalog version', async () => { + const { transport, kernel } = setup(); + + const client = await connect(transport); + + expect(client.getServerVersion()).toEqual({ name: 'openora', version: kernel.catalogVersion }); + expect(client.getServerCapabilities()).toEqual({ tools: { listChanged: false } }); + await client.close(); + }); + + it('lists only read tools, under model names, with the output schema the client receives', async () => { + const { transport } = setup(); + const client = await connect(transport); + + const { tools } = await client.listTools(); + + expect(tools.map((tool) => tool.name)).toEqual(['player_summary']); + const [summary] = tools; + expect(Object.keys(summary?.outputSchema?.properties ?? {})).toEqual([ + 'playerId', + 'status', + 'balance', + 'note', + ]); + expect(summary?.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }); + await client.close(); + }); + + it('lists only the tools the admin is granted', async () => { + const { transport, guard } = setup({ filterGranted: async () => [] }); + const client = await connect(transport); + + const { tools } = await client.listTools(); + + expect(tools).toEqual([]); + expect(guard.filterGranted).toHaveBeenCalledWith(adminId, [ + expect.objectContaining({ resource: 'player', action: 'view' }), + ]); + await client.close(); + }); + + it('answers a call that passes the client output validation, without personal keys', async () => { + const { transport, audit } = setup(); + const client = await connect(transport, { clientMeta: CLIENT_META }); + + const result = await callSummary(client); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ playerId, status: 'active', balance: '12.50' }); + expect(textOf(result)).toEqual(result.structuredContent); + expect(JSON.stringify(result)).not.toContain(PLAYER_EMAIL); + const correlationId = result._meta?.['openora/correlationId']; + expect(correlationId).toEqual(expect.any(String)); + expect(audit.record).toHaveBeenCalledTimes(1); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: adminId, + action: 'mcp.tool.invoked', + resourceId: 'player.summary', + correlationId, + ...CLIENT_META, + after: expect.objectContaining({ + actorKind: 'mcp_token', + tokenId, + personalDropped: true, + outputHash: sha256Hex(result.structuredContent), + }), + }), + ); + await client.close(); + }); + + it('runs every call under the client address and the operator personal-field mode', async () => { + const dropping = setup(); + const including = setup({ config: { personalFields: 'include' } }); + + for (const { transport } of [dropping, including]) { + const client = await connect(transport, { clientMeta: CLIENT_META }); + await callSummary(client); + await client.close(); + } + + expect(dropping.invokeTool).toHaveBeenCalledWith( + 'player.summary', + { playerId }, + expect.objectContaining({ clientMeta: CLIENT_META, dropPersonal: true }), + ); + expect(including.invokeTool).toHaveBeenCalledWith( + 'player.summary', + { playerId }, + expect.objectContaining({ clientMeta: CLIENT_META, dropPersonal: false }), + ); + }); + + it('lets the personal keys through when the operator includes them', async () => { + const { transport, audit } = setup({ config: { personalFields: 'include' } }); + const client = await connect(transport); + + const result = await callSummary(client); + + expect(result.structuredContent).toEqual({ + playerId, + email: PLAYER_EMAIL, + status: 'active', + balance: '12.50', + }); + const after = audit.record.mock.calls[0]?.[0].after; + expect(after).toMatchObject({ outputHash: sha256Hex(result.structuredContent) }); + expect(after).not.toHaveProperty('personalDropped'); + await client.close(); + }); + + it('records one call per tool call and none for the handshake or the listing', async () => { + const { transport, authenticator } = setup(); + const client = await connect(transport); + + await callSummary(client); + await client.callTool({ name: 'player_summary', arguments: { playerId } }); + + expect(authenticator.recordCall).toHaveBeenCalledTimes(2); + expect(authenticator.recordCall).toHaveBeenCalledWith(tokenId); + await client.close(); + }); + + it('answers the call even when recording it fails', async () => { + const { transport } = setup({ + recordCall: async () => { + throw new Error(SECRET_FAILURE); + }, + }); + const client = await connect(transport); + + const result = await callSummary(client); + + expect(result.isError).toBeFalsy(); + expect(log.error).toHaveBeenCalledWith( + { err: { name: 'Error' }, tokenId }, + 'mcp token call was not recorded', + ); + expect(loggedOutput()).not.toContain('hunter2'); + await client.close(); + }); + + it('returns a declared kernel failure as an error result with its code alone', async () => { + const { transport } = setup({ + handler: async () => { + throw new McpToolError('player_not_found'); + }, + }); + const client = await connect(transport); + + const result = await callSummary(client); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); + expect(textOf(result)).toStrictEqual({ error: 'player_not_found' }); + await client.close(); + }); + + it('returns invalid input with the issues the kernel reported', async () => { + const { transport } = setup(); + const client = await connect(transport); + + const result = await callSummary(client, { playerId: 'not-a-uuid' }); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); + expect(textOf(result)).toStrictEqual({ + error: 'invalid_input', + issues: [{ path: 'playerId', message: expect.any(String) }], + }); + await client.close(); + }); + + it('returns the kernel refusing the tool permission as forbidden', async () => { + const { transport } = setup({ authorize: async () => 'denied' }); + const client = await connect(transport); + + const result = await callSummary(client); + + expect(textOf(result)).toStrictEqual({ error: 'forbidden' }); + await client.close(); + }); + + it('turns a thrown kernel error into internal_error without its message', async () => { + const { transport } = setup({ + wrapKernel: (kernel) => ({ + ...kernel, + invokeTool: async () => { + throw new Error(SECRET_FAILURE); + }, + }), + }); + const client = await connect(transport); + + const result = await callSummary(client); + + expect(result.isError).toBe(true); + expect(textOf(result)).toStrictEqual({ error: 'internal_error' }); + expect(JSON.stringify(result)).not.toContain('hunter2'); + await client.close(); + }); + + it('audits a name it does not serve and refuses it without calling the kernel', async () => { + const { transport, kernel, invokeTool, audit, authenticator } = setup(); + const client = await connect(transport, { clientMeta: CLIENT_META }); + const names = ['player_flag', 'player.summary', 'nope']; + + for (const name of names) { + await expect(client.callTool({ name, arguments: { playerId } })).rejects.toThrow( + /Unknown tool/, + ); + } + + expect(invokeTool).not.toHaveBeenCalled(); + expect(authenticator.recordCall).not.toHaveBeenCalled(); + expect(audit.record.mock.calls.map(([entry]) => entry)).toEqual( + names.map((name) => ({ + actorId: adminId, + actorType: 'admin', + action: 'mcp.tool.failed', + resourceType: 'mcp-tool', + resourceId: name, + correlationId: expect.any(String), + ...CLIENT_META, + after: { + toolId: name, + error: 'not_exposed', + actorKind: 'mcp_token', + tokenId, + runId: expect.any(String), + catalogVersion: kernel.catalogVersion, + inputHash: sha256Hex({ playerId }), + }, + })), + ); + await client.close(); + }); + + it('records at most 128 characters of a refused name', async () => { + const { transport, audit } = setup(); + const client = await connect(transport); + const truncated = 'x'.repeat(MAX_AUDITED_NAME_LENGTH); + + await expect(client.callTool({ name: 'x'.repeat(5_000) })).rejects.toThrow(/Unknown tool/); + + expect(audit.record.mock.calls[0]?.[0]).toMatchObject({ + resourceId: truncated, + after: { toolId: truncated, inputHash: sha256Hex({}) }, + }); + await client.close(); + }); + + it('answers an internal error, not a refusal, when the refusal cannot be audited', async () => { + const { transport, audit } = setup(); + audit.record.mockRejectedValueOnce(new Error(SECRET_FAILURE)); + const client = await connect(transport); + + const refused = client.callTool({ name: 'nope', arguments: {} }); + + await expect(refused).rejects.toThrow(/Internal error/); + await expect(refused).rejects.not.toThrow(/Unknown tool|hunter2/); + expect(log.error).toHaveBeenCalledWith( + { err: { name: 'Error' }, tokenId }, + 'mcp refusal of an unexposed tool was not audited', + ); + expect(loggedOutput()).not.toContain('hunter2'); + await client.close(); + }); + + it('hides why a listing failed', async () => { + const { transport } = setup({ + filterGranted: async () => { + throw new Error(SECRET_FAILURE); + }, + }); + const client = await connect(transport); + + const listing = client.listTools(); + + await expect(listing).rejects.toThrow(/Internal error/); + await expect(listing).rejects.not.toThrow(/hunter2/); + await client.close(); + }); +}); + +describe('MCP HTTP transport refusals', () => { + it('answers a ping without a handshake, no-store and without CORS headers', async () => { + const { transport } = setup(); + + const response = await send(transport); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ jsonrpc: '2.0', id: 1, result: {} }); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('access-control-allow-origin')).toBeNull(); + expect(response.headers.get('vary')).toBeNull(); + }); + + it('refuses a missing bearer with a bare challenge, before authenticating', async () => { + const { transport, authenticator } = setup(); + + const response = await send(transport, { headers: { authorization: '' } }); + const rejected = connect(transport, { headers: {} }); + + expect(response.status).toBe(401); + expect(response.headers.get('www-authenticate')).toBe('Bearer'); + await expect(rejected).rejects.toMatchObject({ code: 401 }); + expect(authenticator.authenticate).not.toHaveBeenCalled(); + }); + + it.each([ + { ok: false, reason: 'unknown' }, + { ok: false, reason: 'expired', tokenId, adminId }, + { ok: false, reason: 'revoked', tokenId, adminId }, + { ok: false, reason: 'credentials_changed', tokenId, adminId }, + ] as const)('refuses a $reason token as invalid_token', async (authentication) => { + const { transport, guard } = setup({ authenticate: async () => authentication }); + + const response = await send(transport); + + expect(response.status).toBe(401); + expect(response.headers.get('www-authenticate')).toBe('Bearer error="invalid_token"'); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(guard.assertUser).not.toHaveBeenCalled(); + expect(log.warn).toHaveBeenCalledWith( + expect.objectContaining({ reason: authentication.reason }), + 'mcp token refused', + ); + }); + + it('refuses an admin without MCP access with a 403 that names no challenge', async () => { + const { transport, guard } = setup({ + assertUser: async () => { + throw new ORPCError('FORBIDDEN', { message: 'Missing permission: mcp-access:use' }); + }, + }); + + const response = await send(transport); + const rejected = connect(transport); + + expect(response.status).toBe(403); + expect(response.headers.get('www-authenticate')).toBeNull(); + expect(guard.assertUser).toHaveBeenCalledWith(adminId, 'mcp-access', 'use'); + await expect(rejected).rejects.toMatchObject({ code: 403 }); + }); + + it('fails closed with a constant 500 when the grant check breaks', async () => { + const { transport } = setup({ + assertUser: async () => { + throw new Error(SECRET_FAILURE); + }, + }); + + const response = await send(transport); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + jsonrpc: '2.0', + id: null, + error: { code: -32603, message: 'Internal error' }, + }); + }); + + it('refuses a browser origin that is not allowed, before anything else', async () => { + const { transport, authenticator } = setup({ config: { allowedOrigins: [BACKOFFICE] } }); + + const post = await send(transport, { headers: { origin: 'https://evil.example' } }); + const preflight = await send(transport, { + method: 'OPTIONS', + headers: { origin: 'https://evil.example' }, + }); + + expect(post.status).toBe(403); + expect(preflight.status).toBe(403); + expect(post.headers.get('access-control-allow-origin')).toBeNull(); + expect(authenticator.authenticate).not.toHaveBeenCalled(); + }); + + it('refuses every browser origin when none is allowed', async () => { + const { transport } = setup(); + + const response = await send(transport, { headers: { origin: BACKOFFICE } }); + + expect(response.status).toBe(403); + }); + + it('answers the preflight of an allowed origin and marks its responses for it', async () => { + const { transport } = setup({ config: { allowedOrigins: [BACKOFFICE] } }); + + const preflight = await send(transport, { method: 'OPTIONS', headers: { origin: BACKOFFICE } }); + const post = await send(transport, { headers: { origin: BACKOFFICE } }); + + expect(preflight.status).toBe(204); + expect(preflight.headers.get('access-control-allow-origin')).toBe(BACKOFFICE); + expect(preflight.headers.get('access-control-allow-methods')).toBe('POST'); + expect(post.status).toBe(200); + expect(post.headers.get('access-control-allow-origin')).toBe(BACKOFFICE); + expect(post.headers.get('vary')).toBe('Origin'); + expect(post.headers.get('access-control-allow-credentials')).toBeNull(); + }); + + it.each(['GET', 'DELETE', 'PUT', 'OPTIONS'])( + 'answers %s with 405 before looking at the bearer', + async (method) => { + const { transport, authenticator } = setup(); + + const response = await send(transport, { method, headers: { authorization: '' } }); + + expect(response.status).toBe(405); + expect(response.headers.get('allow')).toBe('POST'); + expect(authenticator.authenticate).not.toHaveBeenCalled(); + }, + ); + + it('limits the client address before looking up the token', async () => { + const { rateLimiter, consume } = failingWindowLimiter('ip', { + allowed: false, + retryAfterMs: 41_200, + }); + const { transport, authenticator } = setup({ rateLimiter }); + + const response = await send(transport, { clientMeta: CLIENT_META }); + + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('42'); + expect(consume).toHaveBeenCalledWith(`mcp-ip-min:${CLIENT_META.ip}`, { + limit: 300, + windowMs: 60_000, + onUnavailable: 'deny', + }); + expect(authenticator.authenticate).not.toHaveBeenCalled(); + }); + + it('refuses a malformed bearer without touching the limiter', async () => { + const { rateLimiter, consume } = failingWindowLimiter('ip', STORE_DOWN); + const { transport, authenticator } = setup({ rateLimiter }); + + const response = await send(transport, { + headers: { authorization: 'Bearer two tokens' }, + clientMeta: CLIENT_META, + }); + + expect(response.status).toBe(401); + expect(consume).not.toHaveBeenCalled(); + expect(authenticator.authenticate).not.toHaveBeenCalled(); + }); + + it.each(['ip', 'min', 'day'] as const)( + 'answers 503 with a fixed Retry-After when the %s window cannot reach its store', + async (window) => { + const { rateLimiter } = failingWindowLimiter(window, STORE_DOWN); + const { transport, guard } = setup({ rateLimiter }); + + const response = await send(transport, { clientMeta: CLIENT_META }); + + expect(response.status).toBe(503); + expect(response.headers.get('retry-after')).toBe('30'); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.json()).toEqual({ + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: 'Service unavailable' }, + }); + expect(guard.assertUser).not.toHaveBeenCalled(); + }, + ); + + it('limits the token with a Retry-After, before checking the grant', async () => { + const consume = vi.fn(async () => ({ allowed: false, retryAfterMs: 1500 })); + const { transport, guard } = setup({ + rateLimiter: mock>({ consume }), + }); + + const response = await send(transport); + + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('2'); + expect(consume).toHaveBeenCalledWith(`mcp-token-min:${tokenId}`, expect.anything()); + expect(guard.assertUser).not.toHaveBeenCalled(); + }); + + it('refuses a JSON-RPC batch without running any of it', async () => { + const { transport, audit } = setup(); + const call = { + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'player_summary', arguments: { playerId } }, + }; + + const response = await send(transport, { + body: JSON.stringify([ + { ...call, id: 1 }, + { ...call, id: 2 }, + ]), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: -32600 } }); + expect(audit.record).not.toHaveBeenCalled(); + }); + + it('refuses a body that is not JSON', async () => { + const { transport } = setup(); + + const response = await send(transport, { body: '{"jsonrpc":' }); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: -32700 } }); + }); +}); + +describe('MCP HTTP transport logging', () => { + it('logs a failed request by error name and code, never its message', async () => { + const queryError = new Error( + `Failed query: select * from mcp_token where token_hash = $1\nparams: ${TOKEN_HASH}`, + { cause: Object.assign(new Error('terminating connection'), { code: '57P01' }) }, + ); + const { transport } = setup({ + authenticate: async () => { + throw queryError; + }, + }); + + const response = await send(transport); + + expect(response.status).toBe(500); + expect(log.error).toHaveBeenCalledWith( + { err: { name: 'Error', code: '57P01' } }, + 'mcp request failed', + ); + expect(loggedOutput()).not.toContain(TOKEN_HASH); + expect(loggedOutput()).not.toContain('Failed query'); + }); + + it('never logs the bearer token, the Authorization header or an error message', async () => { + const refused = setup({ + authenticate: async () => ({ ok: false, reason: 'revoked', tokenId, adminId }), + }); + const broken = setup({ + wrapKernel: (kernel) => ({ + ...kernel, + invokeTool: async () => { + throw new Error(SECRET_FAILURE); + }, + }), + }); + const failing = setup({ + assertUser: async () => { + throw new Error(SECRET_FAILURE); + }, + }); + + await send(refused.transport); + await send(failing.transport); + const client = await connect(broken.transport); + await callSummary(client); + await client.close(); + + expect(log.warn).toHaveBeenCalled(); + expect(log.error).toHaveBeenCalled(); + expect(loggedOutput()).not.toContain(TOKEN); + expect(loggedOutput().toLowerCase()).not.toContain('authorization'); + expect(loggedOutput()).not.toContain('hunter2'); + }); +}); diff --git a/packages/core/src/server/mcp/__tests__/kernel.test.ts b/packages/core/src/server/mcp/__tests__/kernel.test.ts index 6111ecad6..1aa09b11e 100644 --- a/packages/core/src/server/mcp/__tests__/kernel.test.ts +++ b/packages/core/src/server/mcp/__tests__/kernel.test.ts @@ -61,6 +61,7 @@ type HoldImplementation = ActionTypeImplementation; const SQL_ERROR = 'SELECT email FROM players WHERE id=991'; const PLAYER_EMAIL = 'player@example.com'; +const CLIENT_META = { ip: '203.0.113.7', userAgent: 'mcp-client/1.0' }; const REQUEST_BODY_NESTING = 400_000; const adminId = randomUUID(); @@ -262,6 +263,34 @@ describe('McpKernel.invokeTool', () => { expect(audit?.record.mock.calls[0]?.[0].after).not.toHaveProperty('playerPseudonym'); }); + it('records the client address and agent the run carries', async () => { + const { kernel, audit } = setup(); + + await kernel.invokeTool( + 'player.summary', + { playerId, limit: 5 }, + runContext({ clientMeta: CLIENT_META }), + ); + + expect(audit?.record.mock.calls[0]?.[0]).toMatchObject(CLIENT_META); + }); + + it('drops the keys the tool marks as personal when the run asks, hashing only what it returns', async () => { + const { kernel, audit } = setup(); + + const result = await kernel.invokeTool( + 'player.summary', + { playerId, limit: 5 }, + runContext({ dropPersonal: true }), + ); + + expect(result).toEqual({ ok: true, output: { balance: '12.50' } }); + expect(audit?.record.mock.calls[0]?.[0].after).toMatchObject({ + personalDropped: true, + outputHash: sha256({ balance: '12.50' }), + }); + }); + it('audits a call whose input nests deeper than the call stack reaches', async () => { const { kernel, audit, handler } = setup(); const deep = nestedArrays(REQUEST_BODY_NESTING); @@ -679,9 +708,13 @@ describe('McpKernel catalog', () => { type: 'object', properties: expect.objectContaining({ limit: expect.objectContaining({ maximum: 50 }) }), }), - outputJsonSchema: expect.objectContaining({ type: 'object' }), + outputJsonSchema: expect.objectContaining({ + type: 'object', + required: ['playerId', 'balance'], + }), }), ]); + expect(kernel.listTools()[0]?.outputJsonSchema).not.toHaveProperty('properties.email'); expect(kernel.listActionTypes()).toEqual([ expect.objectContaining({ id: 'hold_withdrawal', diff --git a/packages/core/src/server/mcp/__tests__/loggable-error.test.ts b/packages/core/src/server/mcp/__tests__/loggable-error.test.ts new file mode 100644 index 000000000..5561276ab --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/loggable-error.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { loggableError } from '../loggable-error.js'; + +describe('loggableError', () => { + it('keeps the name and code of an error and drops its message', () => { + const err = Object.assign(new TypeError('token hash 9f86d081 leaked'), { code: 'E_TOKEN' }); + + expect(loggableError(err)).toStrictEqual({ name: 'TypeError', code: 'E_TOKEN' }); + }); + + it('reads the code off the cause when the error carries none', () => { + const cause = Object.assign(new Error('terminating connection'), { code: '57P01' }); + const err = new Error('Failed query: select * from mcp_token\nparams: 9f86d081', { cause }); + + expect(loggableError(err)).toStrictEqual({ name: 'Error', code: '57P01' }); + }); + + it('turns a numeric code into a string', () => { + expect(loggableError(Object.assign(new Error('x'), { code: 503 }))).toStrictEqual({ + name: 'Error', + code: '503', + }); + }); + + it('names an error without a code by its class alone', () => { + expect(loggableError(new RangeError('Maximum call stack size exceeded'))).toStrictEqual({ + name: 'RangeError', + }); + }); + + it('names a thrown value that is not an error by its type', () => { + expect(loggableError('password=hunter2')).toStrictEqual({ name: 'string' }); + expect(loggableError({ code: 'E_X', message: 'secret' })).toStrictEqual({ name: 'object' }); + }); +}); diff --git a/packages/core/src/server/mcp/__tests__/token-rate-limit.int.test.ts b/packages/core/src/server/mcp/__tests__/token-rate-limit.int.test.ts new file mode 100644 index 000000000..fb9462869 --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/token-rate-limit.int.test.ts @@ -0,0 +1,137 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createClient } from 'redis'; +import { createTestRedis, type TestRedis } from '@openora/core/testing'; +import { RedisRateLimiter } from '../../kernel/redis-rate-limiter.js'; +import { + consumeAddressRateLimit, + consumeTokenRateLimit, + type RateLimitVerdict, +} from '../transport-gate.js'; + +const DAY_SECONDS = 86_400; +const CLIENT_IP = '203.0.113.7'; +const OTHER_CLIENT_IP = '2001:db8::7'; + +let redis: TestRedis; + +function retryAfterOf(verdict: RateLimitVerdict) { + if (verdict.status !== 'limited') { + throw new Error(`expected a spent window, got ${verdict.status}`); + } + return verdict.retryAfterSeconds; +} + +function offlineLimiter() { + const client = createClient({ url: 'redis://127.0.0.1:1' }); + client.on('error', () => undefined); + return new RedisRateLimiter(client); +} + +beforeAll(async () => { + redis = await createTestRedis(); +}); + +afterAll(async () => { + await redis.quit(); +}); + +beforeEach(async () => { + await redis.flush(); +}); + +describe('consumeTokenRateLimit on the Redis limiter', () => { + it('allows the minute limit, then refuses until the minute window resets', async () => { + const limiter = new RedisRateLimiter(redis.client); + const tokenId = randomUUID(); + const limits = { perMinute: 3, perDay: 100 }; + + const allowed = []; + for (let call = 0; call < limits.perMinute; call += 1) { + allowed.push(await consumeTokenRateLimit(limiter, tokenId, limits)); + } + const refused = await consumeTokenRateLimit(limiter, tokenId, limits); + + expect(allowed).toEqual([{ status: 'allowed' }, { status: 'allowed' }, { status: 'allowed' }]); + expect(retryAfterOf(refused)).toBeGreaterThanOrEqual(1); + expect(retryAfterOf(refused)).toBeLessThanOrEqual(60); + }); + + it('caps the day: a two-a-day token waits about a day for its third call', async () => { + const limiter = new RedisRateLimiter(redis.client); + const tokenId = randomUUID(); + const limits = { perMinute: 100, perDay: 2 }; + + await consumeTokenRateLimit(limiter, tokenId, limits); + await consumeTokenRateLimit(limiter, tokenId, limits); + const third = await consumeTokenRateLimit(limiter, tokenId, limits); + + expect(retryAfterOf(third)).toBeGreaterThan(DAY_SECONDS - 60); + expect(retryAfterOf(third)).toBeLessThanOrEqual(DAY_SECONDS); + }); + + it('does not count a request the minute window refused against the day', async () => { + const limiter = new RedisRateLimiter(redis.client); + const tokenId = randomUUID(); + const limits = { perMinute: 1, perDay: 5 }; + + await consumeTokenRateLimit(limiter, tokenId, limits); + await consumeTokenRateLimit(limiter, tokenId, limits); + await consumeTokenRateLimit(limiter, tokenId, limits); + + expect(await redis.client.get(`rl:mcp-token-min:${tokenId}`)).toBe('3'); + expect(await redis.client.get(`rl:mcp-token-day:${tokenId}`)).toBe('1'); + }); + + it('keeps every token on its own counters', async () => { + const limiter = new RedisRateLimiter(redis.client); + const limits = { perMinute: 1, perDay: 10 }; + const exhausted = randomUUID(); + + await consumeTokenRateLimit(limiter, exhausted, limits); + + expect((await consumeTokenRateLimit(limiter, exhausted, limits)).status).toBe('limited'); + expect(await consumeTokenRateLimit(limiter, randomUUID(), limits)).toEqual({ + status: 'allowed', + }); + }); + + it('reports the limiter as unavailable while it is unreachable', async () => { + expect( + await consumeTokenRateLimit(offlineLimiter(), randomUUID(), { perMinute: 60, perDay: 2000 }), + ).toEqual({ status: 'unavailable' }); + }); +}); + +describe('consumeAddressRateLimit on the Redis limiter', () => { + it('allows the per-address limit, then refuses until the minute window resets', async () => { + const limiter = new RedisRateLimiter(redis.client); + const perIpPerMinute = 2; + + await consumeAddressRateLimit(limiter, CLIENT_IP, perIpPerMinute); + await consumeAddressRateLimit(limiter, CLIENT_IP, perIpPerMinute); + const refused = await consumeAddressRateLimit(limiter, CLIENT_IP, perIpPerMinute); + + expect(retryAfterOf(refused)).toBeGreaterThanOrEqual(1); + expect(retryAfterOf(refused)).toBeLessThanOrEqual(60); + expect(await redis.client.get(`rl:mcp-ip-min:${CLIENT_IP}`)).toBe('3'); + }); + + it('keeps every address, an IPv6 one included, on its own counter', async () => { + const limiter = new RedisRateLimiter(redis.client); + + await consumeAddressRateLimit(limiter, CLIENT_IP, 1); + + expect((await consumeAddressRateLimit(limiter, CLIENT_IP, 1)).status).toBe('limited'); + expect(await consumeAddressRateLimit(limiter, OTHER_CLIENT_IP, 1)).toEqual({ + status: 'allowed', + }); + expect(await redis.client.get(`rl:mcp-ip-min:${OTHER_CLIENT_IP}`)).toBe('1'); + }); + + it('reports the limiter as unavailable while it is unreachable', async () => { + expect(await consumeAddressRateLimit(offlineLimiter(), CLIENT_IP, 300)).toEqual({ + status: 'unavailable', + }); + }); +}); diff --git a/packages/core/src/server/mcp/__tests__/transport-gate.test.ts b/packages/core/src/server/mcp/__tests__/transport-gate.test.ts new file mode 100644 index 000000000..2ac27da83 --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/transport-gate.test.ts @@ -0,0 +1,339 @@ +import { randomUUID } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import type { + RateLimitKey, + RateLimitOptions, + RateLimitResult, + RateLimiterAdapter, +} from '@openora/core/contracts'; +import { mock } from '../../../testing/mock.js'; +import { + accessDenied, + batchRefused, + bearerRequired, + consumeAddressRateLimit, + consumeTokenRateLimit, + internalError, + invalidToken, + limiterUnavailable, + methodNotAllowed, + noStore, + originRefused, + originVerdict, + parseBearer, + parseError, + payloadTooLarge, + preflight, + rateLimitRefused, + rateLimited, + retryAfterSeconds, + servesHost, +} from '../transport-gate.js'; + +const BACKOFFICE = 'https://backoffice.example.com'; +const CLIENT_IP = '203.0.113.7'; +const LIMITS = { perMinute: 60, perDay: 2000 }; +const PER_IP_PER_MINUTE = 300; +const ALLOWED: RateLimitResult = { allowed: true, retryAfterMs: 0 }; +const STORE_DOWN: RateLimitResult = { allowed: false, retryAfterMs: 60_000, unavailable: true }; + +type Window = 'ip' | 'min' | 'day'; + +const WINDOW_PREFIXES: Record = { + ip: 'mcp-ip-min:', + min: 'mcp-token-min:', + day: 'mcp-token-day:', +}; + +function windowOf(key: RateLimitKey): Window | undefined { + return (['ip', 'min', 'day'] as const).find((window) => key.startsWith(WINDOW_PREFIXES[window])); +} + +function scriptedLimiter(results: Partial>) { + const consume = vi.fn( + async (key: RateLimitKey, _options: RateLimitOptions): Promise => { + const window = windowOf(key); + return (window && results[window]) ?? ALLOWED; + }, + ); + return { limiter: mock>({ consume }), consume }; +} + +describe('parseBearer', () => { + it.each([ + ['Bearer ora_mcp_abc-DEF_123', 'ora_mcp_abc-DEF_123'], + ['bearer token', 'token'], + ['BEARER token', 'token'], + ['Bearer spaced', 'spaced'], + ['Bearer dG9rZW4=', 'dG9rZW4='], + ['Bearer a.b~c+d/e', 'a.b~c+d/e'], + ])('reads the token of %j', (header, token) => { + expect(parseBearer(header)).toBe(token); + }); + + it.each([ + [null], + [''], + ['Bearer'], + ['Bearer '], + ['Bearertoken'], + ['Basic dXNlcjpwYXNz'], + ['Bearer two tokens'], + ['Bearer x=y'], + ['Bearer tok"en'], + ])('refuses %j', (header) => { + expect(parseBearer(header)).toBeNull(); + }); + + it('refuses a header longer than 512 characters', () => { + expect(parseBearer(`Bearer ${'a'.repeat(505)}`)).toBe('a'.repeat(505)); + expect(parseBearer(`Bearer ${'a'.repeat(506)}`)).toBeNull(); + }); +}); + +describe('servesHost', () => { + it('serves no host when none is bound', () => { + expect(servesHost('player.example.com', [])).toBe(false); + }); + + it('serves a bound host whatever its case or port', () => { + expect( + servesHost(new URL('https://Backoffice.Example.com:8443/mcp').hostname, [ + 'backoffice.example.com', + ]), + ).toBe(true); + expect(servesHost('BACKOFFICE.example.com', ['backoffice.example.com'])).toBe(true); + }); + + it('refuses any other host, a subdomain of a bound one included', () => { + expect(servesHost('player.example.com', ['backoffice.example.com'])).toBe(false); + expect(servesHost('evil.backoffice.example.com', ['backoffice.example.com'])).toBe(false); + }); +}); + +describe('originVerdict', () => { + it('sees no browser when the request carries no Origin', () => { + expect(originVerdict(null, [])).toBe('none'); + expect(originVerdict(null, [BACKOFFICE])).toBe('none'); + }); + + it('refuses every Origin when none is allowed', () => { + expect(originVerdict(BACKOFFICE, [])).toBe('refused'); + expect(originVerdict('null', [])).toBe('refused'); + }); + + it('allows only an exact configured origin', () => { + expect(originVerdict(BACKOFFICE, [BACKOFFICE])).toBe('allowed'); + expect(originVerdict('http://backoffice.example.com', [BACKOFFICE])).toBe('refused'); + expect(originVerdict(`${BACKOFFICE}:8443`, [BACKOFFICE])).toBe('refused'); + expect(originVerdict('https://evil.example', [BACKOFFICE])).toBe('refused'); + }); +}); + +describe('retryAfterSeconds', () => { + it.each([ + [0, 1], + [1, 1], + [1000, 1], + [1001, 2], + [59_001, 60], + [86_400_000, 86_400], + ])('rounds %i ms up to %i s', (ms, seconds) => { + expect(retryAfterSeconds(ms)).toBe(seconds); + }); +}); + +describe('consumeAddressRateLimit', () => { + it('consumes the address minute window, failing closed', async () => { + const { limiter, consume } = scriptedLimiter({}); + + expect(await consumeAddressRateLimit(limiter, CLIENT_IP, PER_IP_PER_MINUTE)).toEqual({ + status: 'allowed', + }); + expect(consume.mock.calls).toEqual([ + [`mcp-ip-min:${CLIENT_IP}`, { limit: 300, windowMs: 60_000, onUnavailable: 'deny' }], + ]); + }); + + it('does not limit a request whose address is unknown', async () => { + const { limiter, consume } = scriptedLimiter({ ip: STORE_DOWN }); + + expect(await consumeAddressRateLimit(limiter, null, PER_IP_PER_MINUTE)).toEqual({ + status: 'allowed', + }); + expect(consume).not.toHaveBeenCalled(); + }); + + it('refuses a spent window with the time until it resets', async () => { + const { limiter } = scriptedLimiter({ ip: { allowed: false, retryAfterMs: 41_200 } }); + + expect(await consumeAddressRateLimit(limiter, CLIENT_IP, PER_IP_PER_MINUTE)).toEqual({ + status: 'limited', + retryAfterSeconds: 42, + }); + }); + + it('reports a store it cannot reach as unavailable', async () => { + const { limiter } = scriptedLimiter({ ip: STORE_DOWN }); + + expect(await consumeAddressRateLimit(limiter, CLIENT_IP, PER_IP_PER_MINUTE)).toEqual({ + status: 'unavailable', + }); + }); +}); + +describe('consumeTokenRateLimit', () => { + const tokenId = randomUUID(); + + it('consumes the minute window, then the day window, both failing closed', async () => { + const { limiter, consume } = scriptedLimiter({}); + + expect(await consumeTokenRateLimit(limiter, tokenId, LIMITS)).toEqual({ status: 'allowed' }); + expect(consume.mock.calls).toEqual([ + [`mcp-token-min:${tokenId}`, { limit: 60, windowMs: 60_000, onUnavailable: 'deny' }], + [`mcp-token-day:${tokenId}`, { limit: 2000, windowMs: 86_400_000, onUnavailable: 'deny' }], + ]); + }); + + it('refuses on the minute window without spending the day window', async () => { + const { limiter, consume } = scriptedLimiter({ + min: { allowed: false, retryAfterMs: 12_300 }, + }); + + expect(await consumeTokenRateLimit(limiter, tokenId, LIMITS)).toEqual({ + status: 'limited', + retryAfterSeconds: 13, + }); + expect(consume).toHaveBeenCalledTimes(1); + }); + + it('refuses on the day window with the time until it resets', async () => { + const { limiter } = scriptedLimiter({ day: { allowed: false, retryAfterMs: 3_600_000 } }); + + expect(await consumeTokenRateLimit(limiter, tokenId, LIMITS)).toEqual({ + status: 'limited', + retryAfterSeconds: 3600, + }); + }); + + it('reports either window losing its store as unavailable, never as a spent window', async () => { + const minute = scriptedLimiter({ min: STORE_DOWN }); + const day = scriptedLimiter({ day: STORE_DOWN }); + + expect(await consumeTokenRateLimit(minute.limiter, tokenId, LIMITS)).toEqual({ + status: 'unavailable', + }); + expect(minute.consume).toHaveBeenCalledTimes(1); + expect(await consumeTokenRateLimit(day.limiter, tokenId, LIMITS)).toEqual({ + status: 'unavailable', + }); + }); + + it('refuses when the store is unreachable even if the limiter let the request through', async () => { + const { limiter } = scriptedLimiter({ min: { ...ALLOWED, unavailable: true } }); + + expect(await consumeTokenRateLimit(limiter, tokenId, LIMITS)).toEqual({ + status: 'unavailable', + }); + }); +}); + +describe('rateLimitRefused', () => { + it('answers a spent window with 429 and its retry time', () => { + const response = rateLimitRefused({ status: 'limited', retryAfterSeconds: 42 }); + + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('42'); + }); + + it('answers a store it cannot reach with 503, retrying after 30 seconds', async () => { + const response = rateLimitRefused({ status: 'unavailable' }); + + expect(response.status).toBe(503); + expect(response.headers.get('retry-after')).toBe('30'); + expect(await response.json()).toEqual({ + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: 'Service unavailable' }, + }); + }); +}); + +describe('error responses', () => { + it.each([ + ['origin refused', originRefused, 403, -32000], + ['method not allowed', methodNotAllowed, 405, -32000], + ['bearer required', bearerRequired, 401, -32000], + ['invalid token', invalidToken, 401, -32000], + ['rate limited', () => rateLimited(5), 429, -32000], + ['limiter unavailable', limiterUnavailable, 503, -32000], + ['access denied', accessDenied, 403, -32000], + ['payload too large', payloadTooLarge, 413, -32000], + ['parse error', parseError, 400, -32700], + ['batch refused', batchRefused, 400, -32600], + ['internal error', internalError, 500, -32603], + ])('answers %s as a no-store JSON-RPC error', async (_name, build, status, code) => { + const response = build(); + + expect(response.status).toBe(status); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('content-type')).toBe('application/json'); + expect(await response.json()).toEqual({ + jsonrpc: '2.0', + id: null, + error: { code, message: expect.any(String) }, + }); + }); + + it('challenges a missing bearer with the bare scheme', () => { + expect(bearerRequired().headers.get('www-authenticate')).toBe('Bearer'); + }); + + it('challenges a refused token as invalid_token, naming no resource metadata', () => { + expect(invalidToken().headers.get('www-authenticate')).toBe('Bearer error="invalid_token"'); + }); + + it('names no challenge when the grant is missing', () => { + expect(accessDenied().headers.get('www-authenticate')).toBeNull(); + }); + + it('allows POST only', () => { + expect(methodNotAllowed().headers.get('allow')).toBe('POST'); + }); + + it('tells a limited client when to retry', () => { + expect(rateLimited(42).headers.get('retry-after')).toBe('42'); + }); +}); + +describe('preflight', () => { + it('allows POST with the MCP request headers for that origin alone', () => { + const response = preflight(BACKOFFICE); + + expect(response.status).toBe(204); + expect(Object.fromEntries(response.headers)).toEqual({ + 'access-control-allow-origin': BACKOFFICE, + 'access-control-allow-methods': 'POST', + 'access-control-allow-headers': 'authorization, content-type, accept, mcp-protocol-version', + 'cache-control': 'no-store', + vary: 'Origin', + }); + }); +}); + +describe('noStore', () => { + it('marks a copy no-store, keeping the status, body and other headers of the original', async () => { + const original = new Response('{"ok":true}', { + status: 202, + headers: { 'Cache-Control': 'no-cache', 'X-Kept': '1' }, + }); + + const copy = noStore(original); + + expect(copy.status).toBe(202); + expect(copy.headers.get('cache-control')).toBe('no-store'); + expect(copy.headers.get('x-kept')).toBe('1'); + expect(original.headers.get('cache-control')).toBe('no-cache'); + expect(await copy.text()).toBe('{"ok":true}'); + }); +}); diff --git a/packages/core/src/server/mcp/__tests__/transport-tools.test.ts b/packages/core/src/server/mcp/__tests__/transport-tools.test.ts new file mode 100644 index 000000000..9ea796d02 --- /dev/null +++ b/packages/core/src/server/mcp/__tests__/transport-tools.test.ts @@ -0,0 +1,186 @@ +import { randomUUID } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { + CORRELATION_ID_META_KEY, + exposedTools, + projectOutput, + toCallToolResult, +} from '../transport-tools.js'; +import { transportKernel } from './fixtures/transport-kernel.js'; + +const descriptors = () => transportKernel().kernel.listTools(); + +const correlationId = randomUUID(); + +function textOf(result: CallToolResult) { + const [block] = result.content; + if (block?.type !== 'text') { + throw new Error('expected a text block'); + } + return JSON.parse(block.text); +} + +describe('exposedTools', () => { + it('serves only read-class tools, under their model names, marked read-only', () => { + const tools = exposedTools(descriptors(), 'drop'); + + expect(tools.map((tool) => tool.definition.name)).toEqual(['player_summary']); + expect(tools[0]).toMatchObject({ + id: 'player.summary', + iam: { resource: 'player', action: 'view' }, + definition: { + title: 'Player summary', + description: 'Status and balance of one player', + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + }); + }); + + it('publishes the input schema as a JSON object schema', () => { + const [tool] = exposedTools(descriptors(), 'drop'); + + expect(tool.definition.inputSchema).toMatchObject({ + type: 'object', + properties: { playerId: { type: 'string' } }, + required: ['playerId'], + }); + }); + + it('drops the personal keys from the exposed keys and the published output schema', () => { + const [tool] = exposedTools(descriptors(), 'drop'); + + expect(tool.keys).toEqual(['playerId', 'status', 'balance', 'note']); + expect(Object.keys(tool.definition.outputSchema?.properties ?? {})).toEqual([ + 'playerId', + 'status', + 'balance', + 'note', + ]); + expect(tool.definition.outputSchema?.required).toEqual(['playerId', 'status', 'balance']); + }); + + it('keeps the personal keys in include mode and still never a key the tool does not allow', () => { + const [tool] = exposedTools(descriptors(), 'include'); + + expect(tool.keys).toEqual(['playerId', 'email', 'status', 'balance', 'note']); + expect(Object.keys(tool.definition.outputSchema?.properties ?? {})).toEqual([ + 'playerId', + 'email', + 'status', + 'balance', + 'note', + ]); + expect(tool.definition.outputSchema?.required).toEqual([ + 'playerId', + 'email', + 'status', + 'balance', + ]); + }); + + it('publishes the kernel output schema itself, narrowed by the personal keys in drop mode', () => { + const [summary] = descriptors().filter((descriptor) => descriptor.class === 'read'); + const outputJsonSchema = { + type: 'object', + title: 'Published by the kernel', + properties: { playerId: { type: 'string' }, email: { type: 'string' } }, + required: ['playerId', 'email'], + additionalProperties: false, + }; + + const [dropped] = exposedTools([{ ...summary, outputJsonSchema }], 'drop'); + const [included] = exposedTools([{ ...summary, outputJsonSchema }], 'include'); + + expect(dropped.definition.outputSchema).toEqual({ + type: 'object', + title: 'Published by the kernel', + properties: { playerId: { type: 'string' } }, + required: ['playerId'], + additionalProperties: false, + }); + expect(included.definition.outputSchema).toEqual(outputJsonSchema); + }); + + it('refuses at boot a tool whose input schema is not a JSON object schema', () => { + const [summary] = descriptors().filter((descriptor) => descriptor.class === 'read'); + + expect(() => + exposedTools([{ ...summary, inputJsonSchema: { type: 'string' } }], 'drop'), + ).toThrow(/"player\.summary": its input JSON Schema is not an object schema/); + }); + + it('refuses at boot a tool whose output schema is not a JSON object schema', () => { + const [summary] = descriptors().filter((descriptor) => descriptor.class === 'read'); + + expect(() => + exposedTools([{ ...summary, outputJsonSchema: { type: 'array' } }], 'drop'), + ).toThrow(/"player\.summary": its output JSON Schema is not an object schema/); + }); +}); + +describe('projectOutput', () => { + it('keeps only the listed keys that carry a value', () => { + expect( + projectOutput({ playerId: 'p-1', email: 'e', note: undefined, extra: 1 }, [ + 'playerId', + 'note', + 'absent', + ]), + ).toEqual({ playerId: 'p-1' }); + }); + + it('never reads a listed key off the prototype', () => { + expect(projectOutput({}, ['constructor', 'toString'])).toEqual({}); + }); +}); + +describe('toCallToolResult', () => { + it('returns the projected output as structured content and as the same JSON text', () => { + const result = toCallToolResult( + { ok: true, output: { playerId: 'p-1', email: 'e@example.com', status: 'active' } }, + ['playerId', 'status'], + correlationId, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify({ playerId: 'p-1', status: 'active' }) }], + structuredContent: { playerId: 'p-1', status: 'active' }, + _meta: { [CORRELATION_ID_META_KEY]: correlationId }, + }); + }); + + it('returns a failure as an error result carrying only its code, with no structured content', () => { + const result = toCallToolResult( + { ok: false, error: 'player_not_found' }, + ['playerId'], + correlationId, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: '{"error":"player_not_found"}' }], + isError: true, + _meta: { [CORRELATION_ID_META_KEY]: correlationId }, + }); + expect(result).not.toHaveProperty('structuredContent'); + }); + + it('carries input issues beside the code and nothing else', () => { + const issues = [{ path: 'playerId', message: 'Invalid UUID' }]; + + const result = toCallToolResult( + { ok: false, error: 'invalid_input', issues }, + ['playerId'], + correlationId, + ); + + expect(result.isError).toBe(true); + expect(result).not.toHaveProperty('structuredContent'); + expect(textOf(result)).toStrictEqual({ error: 'invalid_input', issues }); + }); +}); diff --git a/packages/core/src/server/mcp/http-transport.ts b/packages/core/src/server/mcp/http-transport.ts new file mode 100644 index 000000000..4fd3ce3a4 --- /dev/null +++ b/packages/core/src/server/mcp/http-transport.ts @@ -0,0 +1,298 @@ +import { randomUUID } from 'node:crypto'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { + CallToolRequestSchema, + ErrorCode, + ListToolsRequestSchema, + McpError, + type CallToolRequest, +} from '@modelcontextprotocol/sdk/types.js'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; +import type { + AuditWritePort, + ClientMeta, + McpIamRequirement, + McpKernel, + McpTokenAuthentication, + McpTokenAuthenticator, + McpTransportConfig, + RateLimitKey, + RateLimiterAdapter, + RunContext, +} from '@openora/core/contracts'; +import type { AdminGuard } from '../auth/admin-guard.js'; +import { createLogger } from '../kernel/logger.js'; +import { authorizeWithAdminGuard } from './authorize.js'; +import { sha256Hex } from './canonical-json.js'; +import { + accessDenied, + batchRefused, + bearerRequired, + consumeAddressRateLimit, + consumeTokenRateLimit, + corsHeaders, + internalError, + invalidToken, + methodNotAllowed, + noStore, + originRefused, + originVerdict, + parseBearer, + parseError, + preflight, + rateLimitRefused, + servesHost, + withHeaders, +} from './transport-gate.js'; +import { loggableError } from './loggable-error.js'; +import { exposedTools, toCallToolResult, type ExposedTool } from './transport-tools.js'; + +const logger = createLogger('mcp-transport'); + +const SERVER_NAME = 'openora'; +const MCP_ACCESS: McpIamRequirement = { resource: 'mcp-access', action: 'use' }; +const MAX_REFUSED_TOOL_NAME_LENGTH = 128; + +export type McpHttpTransportDeps = { + kernel: McpKernel; + authenticator: McpTokenAuthenticator; + adminGuard: Pick; + rateLimiter: RateLimiterAdapter; + audit: AuditWritePort; + config: McpTransportConfig; +}; + +export type McpHttpTransport = { + servesHost(hostname: string): boolean; + handle(request: Request, clientMeta: ClientMeta): Promise; +}; + +type TransportState = McpHttpTransportDeps & { + tools: readonly ExposedTool[]; + toolsByName: ReadonlyMap; + validator: AjvJsonSchemaValidator; +}; + +type Caller = Extract & { clientMeta: ClientMeta }; + +type ToolCall = Caller & { correlationId: RunContext['correlationId'] }; + +/** + * The MCP Streamable HTTP endpoint for admin tokens, stateless and answering in JSON. Each + * request passes, in order: the Origin check, POST only, the bearer's format, the client + * address's minute limit, the token, the token's minute and day limits, and the admin's MCP + * grant; only then does the SDK see it. A limiter that cannot reach its store answers 503. + * Only read-class tools are served, every call runs through the kernel under the token's + * admin, and a call naming any other tool is audited and refused. Throws at construction when + * a tool's schemas cannot be published. + */ +export function createMcpHttpTransport(deps: McpHttpTransportDeps): McpHttpTransport { + const tools = exposedTools(deps.kernel.listTools(), deps.config.personalFields); + const state: TransportState = { + ...deps, + tools, + toolsByName: new Map(tools.map((tool) => [tool.definition.name, tool])), + validator: new AjvJsonSchemaValidator(), + }; + return { + servesHost: (hostname) => servesHost(hostname, deps.config.allowedHosts), + handle: (request, clientMeta) => handle(state, request, clientMeta), + }; +} + +async function handle(state: TransportState, request: Request, clientMeta: ClientMeta) { + const origin = request.headers.get('origin'); + const verdict = originVerdict(origin, state.config.allowedOrigins); + if (verdict === 'refused') { + return originRefused(); + } + const allowedOrigin = verdict === 'allowed' ? origin : null; + if (allowedOrigin === null) { + return serve(state, request, clientMeta); + } + if (request.method === 'OPTIONS') { + return preflight(allowedOrigin); + } + return withHeaders(await serve(state, request, clientMeta), corsHeaders(allowedOrigin)); +} + +async function serve(state: TransportState, request: Request, clientMeta: ClientMeta) { + // In stateless mode the SDK answers a GET with an SSE stream that never ends; its client + // reads a 405 as "no stream" and carries on over POST. + if (request.method !== 'POST') { + return methodNotAllowed(); + } + const bearer = parseBearer(request.headers.get('authorization')); + if (bearer === null) { + return bearerRequired(); + } + try { + return await serveBearer(state, { request, bearer, clientMeta }); + } catch (err) { + logger.error({ err: loggableError(err) }, 'mcp request failed'); + return internalError(); + } +} + +async function serveBearer( + state: TransportState, + { request, bearer, clientMeta }: { request: Request; bearer: string; clientMeta: ClientMeta }, +) { + const addressLimit = await consumeAddressRateLimit( + state.rateLimiter, + clientMeta.ip, + state.config.rateLimit.perIpPerMinute, + ); + if (addressLimit.status !== 'allowed') { + return rateLimitRefused(addressLimit); + } + const authentication = await state.authenticator.authenticate(bearer); + if (!authentication.ok) { + logger.warn( + authentication.reason === 'unknown' + ? { reason: authentication.reason, ip: clientMeta.ip } + : { reason: authentication.reason, tokenId: authentication.tokenId, ip: clientMeta.ip }, + 'mcp token refused', + ); + return invalidToken(); + } + const tokenLimit = await consumeTokenRateLimit( + state.rateLimiter, + authentication.tokenId, + state.config.rateLimit, + ); + if (tokenLimit.status !== 'allowed') { + return rateLimitRefused(tokenLimit); + } + if ( + (await authorizeWithAdminGuard(state.adminGuard, authentication.adminId, MCP_ACCESS)) === + 'denied' + ) { + return accessDenied(); + } + return serveProtocol(state, request, { ...authentication, clientMeta }); +} + +async function readJson(request: Request): Promise<{ ok: true; body: unknown } | { ok: false }> { + try { + return { ok: true, body: await request.json() }; + } catch { + return { ok: false }; + } +} + +async function serveProtocol(state: TransportState, request: Request, caller: Caller) { + const parsed = await readJson(request); + if (!parsed.ok) { + return parseError(); + } + if (Array.isArray(parsed.body)) { + return batchRefused(); + } + // The SDK's stateless transport refuses a second request, so every request gets its own pair. + const server = protocolServer(state, { ...caller, correlationId: randomUUID() }); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + await server.connect(transport); + try { + return noStore(await transport.handleRequest(request, { parsedBody: parsed.body })); + } finally { + await server.close(); + } +} + +// The SDK copies every request header, Authorization included, into each handler's +// `extra.requestInfo`, and sends a thrown error's message to the client verbatim: the handlers +// never read `extra` and never let an internal error escape. +function protocolServer(state: TransportState, call: ToolCall) { + const server = new McpServer( + { name: SERVER_NAME, version: state.kernel.catalogVersion }, + { capabilities: { tools: { listChanged: false } }, jsonSchemaValidator: state.validator }, + ); + server.server.setRequestHandler(ListToolsRequestSchema, () => listTools(state, call)); + server.server.setRequestHandler(CallToolRequestSchema, (request) => + callTool(state, call, request.params), + ); + return server; +} + +async function listTools(state: TransportState, { adminId }: ToolCall) { + try { + const granted = await state.adminGuard.filterGranted( + adminId, + state.tools.map((tool) => ({ ...tool.iam, tool })), + ); + return { tools: granted.map(({ tool }) => tool.definition) }; + } catch (err) { + logger.error({ err: loggableError(err) }, 'mcp tools/list failed'); + throw new McpError(ErrorCode.InternalError, 'Internal error'); + } +} + +async function callTool(state: TransportState, call: ToolCall, params: CallToolRequest['params']) { + const tool = state.toolsByName.get(params.name); + if (!tool) { + return refuseUnexposedTool(state, call, params); + } + try { + const result = await state.kernel.invokeTool(tool.id, params.arguments ?? {}, { + runId: randomUUID(), + actor: { kind: 'mcp_token', tokenId: call.tokenId, adminId: call.adminId }, + catalogVersion: state.kernel.catalogVersion, + correlationId: call.correlationId, + clientMeta: call.clientMeta, + dropPersonal: state.config.personalFields === 'drop', + }); + await recordCall(state.authenticator, call); + return toCallToolResult(result, tool.keys, call.correlationId); + } catch (err) { + logger.error({ err: loggableError(err), toolId: tool.id }, 'mcp tools/call failed'); + return toCallToolResult({ ok: false, error: 'internal_error' }, tool.keys, call.correlationId); + } +} + +async function refuseUnexposedTool( + state: TransportState, + call: ToolCall, + params: CallToolRequest['params'], +): Promise { + const toolId = params.name.slice(0, MAX_REFUSED_TOOL_NAME_LENGTH); + try { + await state.audit.record({ + actorId: call.adminId, + actorType: 'admin', + action: 'mcp.tool.failed', + resourceType: 'mcp-tool', + resourceId: toolId, + correlationId: call.correlationId, + ...call.clientMeta, + after: { + toolId, + error: 'not_exposed', + actorKind: 'mcp_token', + tokenId: call.tokenId, + runId: randomUUID(), + catalogVersion: state.kernel.catalogVersion, + inputHash: sha256Hex(params.arguments ?? {}), + }, + }); + } catch (err) { + logger.error( + { err: loggableError(err), tokenId: call.tokenId }, + 'mcp refusal of an unexposed tool was not audited', + ); + throw new McpError(ErrorCode.InternalError, 'Internal error'); + } + throw new McpError(ErrorCode.InvalidParams, 'Unknown tool'); +} + +async function recordCall(authenticator: McpTokenAuthenticator, { tokenId }: ToolCall) { + try { + await authenticator.recordCall(tokenId); + } catch (err) { + logger.error({ err: loggableError(err), tokenId }, 'mcp token call was not recorded'); + } +} diff --git a/packages/core/src/server/mcp/kernel.ts b/packages/core/src/server/mcp/kernel.ts index 39bd04f10..6924d7b72 100644 --- a/packages/core/src/server/mcp/kernel.ts +++ b/packages/core/src/server/mcp/kernel.ts @@ -34,6 +34,8 @@ import type { import { createLogger } from '../kernel/logger.js'; import { sha256Hex } from './canonical-json.js'; import { mcpToolModelName } from './contract-validation.js'; +import { projectedOutputJsonSchema } from './output-schema.js'; +import { loggableError } from './loggable-error.js'; const logger = createLogger('mcp-kernel'); @@ -134,7 +136,7 @@ function serveTool( owner, modelName: mcpToolModelName(contract.id), inputJsonSchema: z.toJSONSchema(contract.inputSchema, { target: 'draft-7', io: 'input' }), - outputJsonSchema: z.toJSONSchema(contract.outputSchema, { target: 'draft-7', io: 'output' }), + outputJsonSchema: projectedOutputJsonSchema(contract.outputSchema, contract.redact.allow), }, }; } @@ -233,7 +235,10 @@ function parseInput( ? { success: true, data: parsed.data } : { success: false, failure: invalidInput(parsed.error) }; } catch (err) { - logger.error({ err, ...logContext }, 'mcp input schema threw while parsing'); + logger.error( + { err: loggableError(err), ...logContext }, + 'mcp input schema threw while parsing', + ); return { success: false, failure: failure('internal_error') }; } } @@ -265,7 +270,10 @@ async function authorizeActor( try { return await authorize(runActorAdminId(actor), iam); } catch (err) { - logger.error({ err, resource: iam.resource, action: iam.action }, 'mcp authorization failed'); + logger.error( + { err: loggableError(err), resource: iam.resource, action: iam.action }, + 'mcp authorization failed', + ); return 'failed'; } } @@ -285,7 +293,7 @@ function declaredCode( if (err instanceof McpToolError && declared.includes(err.code)) { return err.code; } - logger.error({ err, ...context }, 'mcp call failed with an undeclared error'); + logger.error({ err: loggableError(err), ...context }, 'mcp call failed with an undeclared error'); return 'internal_error'; } @@ -296,6 +304,13 @@ function pickAllowed( return withoutUndefined(Object.fromEntries(allow.map((key) => [key, output[key]]))); } +function releasedKeys( + { allow, personal = [] }: McpToolContract['redact'], + dropPersonal: RunContext['dropPersonal'], +): readonly string[] { + return dropPersonal ? allow.filter((key) => !personal.includes(key)) : allow; +} + async function recordAudit(audit: AuditWritePort | null, entry: AuditEntry): Promise { if (!audit) { return false; @@ -305,7 +320,7 @@ async function recordAudit(audit: AuditWritePort | null, entry: AuditEntry): Pro return true; } catch (err) { logger.error( - { err, action: entry.action, resourceId: entry.resourceId }, + { err: loggableError(err), action: entry.action, resourceId: entry.resourceId }, 'mcp audit write failed', ); return false; @@ -353,6 +368,7 @@ async function invokeTool( resourceType: 'mcp-tool', resourceId: toolId, correlationId: run.data.correlationId, + ...run.data.clientMeta, after: withoutUndefined({ toolId, toolClass: tool?.contract.class, @@ -361,6 +377,7 @@ async function invokeTool( ...actorAuditFields(run.data.actor), playerPseudonym: run.data.playerPseudonym, catalogVersion: run.data.catalogVersion, + personalDropped: run.data.dropPersonal ? true : undefined, inputHash: outcome.inputHash, outputHash: outcome.outputHash, error: outcome.result.ok ? undefined : outcome.result.error, @@ -384,7 +401,10 @@ async function toolOutcome( } return await runTool(state, tool, input, run); } catch (err) { - logger.error({ err, toolId, runId: run.runId }, 'mcp tool call failed outside its handler'); + logger.error( + { err: loggableError(err), toolId, runId: run.runId }, + 'mcp tool call failed outside its handler', + ); return { result: failure('internal_error') }; } } @@ -417,7 +437,7 @@ async function runTool( ); return { result: failure('output_invalid'), inputHash }; } - const redacted = pickAllowed(output.data, tool.contract.redact.allow); + const redacted = pickAllowed(output.data, releasedKeys(tool.contract.redact, run.dropPersonal)); return { result: { ok: true, output: redacted }, inputHash, outputHash: sha256Hex(redacted) }; } catch (err) { return { result: failure(declaredCode(err, tool.contract.errors, logContext)), inputHash }; diff --git a/packages/core/src/server/mcp/loggable-error.ts b/packages/core/src/server/mcp/loggable-error.ts new file mode 100644 index 000000000..a4190ee6b --- /dev/null +++ b/packages/core/src/server/mcp/loggable-error.ts @@ -0,0 +1,19 @@ +function errorCode(err: unknown) { + if (typeof err !== 'object' || err === null || !('code' in err)) { + return undefined; + } + const { code } = err; + return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined; +} + +/** + * Never the message or stack: a drizzle query error's message carries the query parameters, + * such as a token hash or a player's data. + */ +export function loggableError(err: unknown) { + if (!(err instanceof Error)) { + return { name: typeof err }; + } + const code = errorCode(err) ?? errorCode(err.cause); + return code === undefined ? { name: err.name } : { name: err.name, code }; +} diff --git a/packages/core/src/server/mcp/output-schema.ts b/packages/core/src/server/mcp/output-schema.ts new file mode 100644 index 000000000..ecb6a707b --- /dev/null +++ b/packages/core/src/server/mcp/output-schema.ts @@ -0,0 +1,18 @@ +import * as z from 'zod'; + +/** + * The JSON Schema of a tool's output once only `keys` remain. Clients validate structured + * output against it, so it must not require a key the kernel strips. zod's `.pick()` would + * do this, but it throws on an object schema that carries refinements. + */ +export function projectedOutputJsonSchema( + outputSchema: z.ZodObject, + keys: readonly string[], +): Record { + const keep = new Set(keys); + const entries = Object.entries(outputSchema.shape).filter( + (entry): entry is [string, z.core.$ZodType] => + keep.has(entry[0]) && entry[1] instanceof z.core.$ZodType, + ); + return z.toJSONSchema(z.object(Object.fromEntries(entries)), { target: 'draft-7', io: 'output' }); +} diff --git a/packages/core/src/server/mcp/transport-gate.ts b/packages/core/src/server/mcp/transport-gate.ts new file mode 100644 index 000000000..dc2285cd5 --- /dev/null +++ b/packages/core/src/server/mcp/transport-gate.ts @@ -0,0 +1,254 @@ +import { + RATE_LIMIT_KEYS, + makeRateLimitKey, + type ClientMeta, + type McpTransportConfig, + type RateLimitKey, + type RateLimitOptions, + type RateLimitResult, + type RateLimiterAdapter, + type Uuid, +} from '@openora/core/contracts'; + +const MAX_AUTHORIZATION_HEADER_LENGTH = 512; +const BEARER_PATTERN = /^Bearer +([\w\-.~+/]+=*)$/i; + +const MINUTE_MS = 60_000; +const DAY_MS = 24 * 60 * MINUTE_MS; +const LIMITER_UNAVAILABLE_RETRY_AFTER_SECONDS = 30; + +const JSON_RPC_SERVER_ERROR = -32000; +const JSON_RPC_INVALID_REQUEST = -32600; +const JSON_RPC_INTERNAL_ERROR = -32603; +const JSON_RPC_PARSE_ERROR = -32700; + +const NO_STORE = { 'Cache-Control': 'no-store' }; + +export type OriginVerdict = 'none' | 'allowed' | 'refused'; + +export type RateLimitVerdict = + | { status: 'allowed' } + | { status: 'limited'; retryAfterSeconds: number } + | { status: 'unavailable' }; + +export type RateLimitRefusal = Exclude; + +export function parseBearer(header: string | null) { + if (header === null || header.length > MAX_AUTHORIZATION_HEADER_LENGTH) { + return null; + } + return BEARER_PATTERN.exec(header)?.[1] ?? null; +} + +export function servesHost(hostname: string, allowedHosts: readonly string[]) { + return allowedHosts.includes(hostname.toLowerCase()); +} + +/** + * An empty allow-list refuses every request that carries an Origin header. + */ +export function originVerdict( + origin: string | null, + allowedOrigins: readonly string[], +): OriginVerdict { + if (origin === null) { + return 'none'; + } + return allowedOrigins.includes(origin) ? 'allowed' : 'refused'; +} + +export function retryAfterSeconds(retryAfterMs: number) { + return Math.max(1, Math.ceil(retryAfterMs / 1000)); +} + +function rateLimitVerdict({ + allowed, + retryAfterMs, + unavailable, +}: RateLimitResult): RateLimitVerdict { + if (unavailable) { + return { status: 'unavailable' }; + } + if (!allowed) { + return { status: 'limited', retryAfterSeconds: retryAfterSeconds(retryAfterMs) }; + } + return { status: 'allowed' }; +} + +async function consumeWindow( + limiter: RateLimiterAdapter, + key: RateLimitKey, + window: Pick, +) { + return rateLimitVerdict(await limiter.consume(key, { ...window, onUnavailable: 'deny' })); +} + +/** + * Consumes one request from the client address's minute window, failing closed when the + * limiter is unreachable. A request whose address is unknown is not limited here. + */ +export async function consumeAddressRateLimit( + limiter: RateLimiterAdapter, + ip: ClientMeta['ip'], + perIpPerMinute: McpTransportConfig['rateLimit']['perIpPerMinute'], +): Promise { + if (ip === null) { + return { status: 'allowed' }; + } + return consumeWindow(limiter, makeRateLimitKey(RATE_LIMIT_KEYS.MCP_IP_MINUTE, ip), { + limit: perIpPerMinute, + windowMs: MINUTE_MS, + }); +} + +/** + * Consumes one request from the token's minute window, then from its day window. Both fail + * closed when the limiter is unreachable; a request the minute window refuses does not count + * against the day. + */ +export async function consumeTokenRateLimit( + limiter: RateLimiterAdapter, + tokenId: Uuid, + limits: Pick, +) { + const minute = await consumeWindow( + limiter, + makeRateLimitKey(RATE_LIMIT_KEYS.MCP_TOKEN_MINUTE, tokenId), + { limit: limits.perMinute, windowMs: MINUTE_MS }, + ); + if (minute.status !== 'allowed') { + return minute; + } + return consumeWindow(limiter, makeRateLimitKey(RATE_LIMIT_KEYS.MCP_TOKEN_DAY, tokenId), { + limit: limits.perDay, + windowMs: DAY_MS, + }); +} + +function jsonRpcError( + status: number, + error: { code: number; message: string }, + headers: Record = {}, +) { + return new Response(JSON.stringify({ jsonrpc: '2.0', error, id: null }), { + status, + headers: { 'Content-Type': 'application/json', ...NO_STORE, ...headers }, + }); +} + +export function originRefused() { + return jsonRpcError(403, { + code: JSON_RPC_SERVER_ERROR, + message: 'Forbidden: origin not allowed', + }); +} + +export function methodNotAllowed() { + return jsonRpcError( + 405, + { code: JSON_RPC_SERVER_ERROR, message: 'Method not allowed' }, + { Allow: 'POST' }, + ); +} + +/** + * No `resource_metadata` in the challenge: it would send an MCP client into OAuth discovery. + */ +export function bearerRequired() { + return jsonRpcError( + 401, + { code: JSON_RPC_SERVER_ERROR, message: 'Unauthorized: bearer token required' }, + { 'WWW-Authenticate': 'Bearer' }, + ); +} + +export function invalidToken() { + return jsonRpcError( + 401, + { code: JSON_RPC_SERVER_ERROR, message: 'Unauthorized: invalid token' }, + { 'WWW-Authenticate': 'Bearer error="invalid_token"' }, + ); +} + +export function rateLimited(retryAfter: number) { + return jsonRpcError( + 429, + { code: JSON_RPC_SERVER_ERROR, message: 'Too many requests' }, + { 'Retry-After': String(retryAfter) }, + ); +} + +export function limiterUnavailable() { + return jsonRpcError( + 503, + { code: JSON_RPC_SERVER_ERROR, message: 'Service unavailable' }, + { 'Retry-After': String(LIMITER_UNAVAILABLE_RETRY_AFTER_SECONDS) }, + ); +} + +export function rateLimitRefused(refusal: RateLimitRefusal) { + return refusal.status === 'unavailable' + ? limiterUnavailable() + : rateLimited(refusal.retryAfterSeconds); +} + +/** + * No WWW-Authenticate: the SDK client answers an `insufficient_scope` 403 by starting OAuth. + */ +export function accessDenied() { + return jsonRpcError(403, { + code: JSON_RPC_SERVER_ERROR, + message: 'Forbidden: MCP access not granted', + }); +} + +export function payloadTooLarge() { + return jsonRpcError(413, { code: JSON_RPC_SERVER_ERROR, message: 'Payload too large' }); +} + +export function parseError() { + return jsonRpcError(400, { code: JSON_RPC_PARSE_ERROR, message: 'Parse error: invalid JSON' }); +} + +export function batchRefused() { + return jsonRpcError(400, { + code: JSON_RPC_INVALID_REQUEST, + message: 'Invalid Request: batch requests are not supported', + }); +} + +export function internalError() { + return jsonRpcError(500, { code: JSON_RPC_INTERNAL_ERROR, message: 'Internal error' }); +} + +export function corsHeaders(origin: string) { + return { 'Access-Control-Allow-Origin': origin, Vary: 'Origin' }; +} + +export function preflight(origin: string) { + return new Response(null, { + status: 204, + headers: { + ...corsHeaders(origin), + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'authorization, content-type, accept, mcp-protocol-version', + ...NO_STORE, + }, + }); +} + +export function withHeaders(response: Response, extra: Record) { + const headers = new Headers(response.headers); + for (const [name, value] of Object.entries(extra)) { + headers.set(name, value); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export function noStore(response: Response) { + return withHeaders(response, NO_STORE); +} diff --git a/packages/core/src/server/mcp/transport-tools.ts b/packages/core/src/server/mcp/transport-tools.ts new file mode 100644 index 000000000..51af98de1 --- /dev/null +++ b/packages/core/src/server/mcp/transport-tools.ts @@ -0,0 +1,130 @@ +import { ToolSchema, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { + McpFailure, + McpIamRequirement, + McpPersonalFieldMode, + McpToolDescriptor, + McpToolResult, + RunContext, +} from '@openora/core/contracts'; + +export const CORRELATION_ID_META_KEY = 'openora/correlationId'; + +const READ_TOOL_ANNOTATIONS = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +}; + +const ObjectJsonSchema = ToolSchema.shape.inputSchema; + +export type ExposedTool = { + id: McpToolDescriptor['id']; + iam: McpIamRequirement; + /** The output keys that reach the client. */ + keys: readonly string[]; + definition: Tool; +}; + +function objectJsonSchema( + toolId: McpToolDescriptor['id'], + role: 'input' | 'output', + schema: Record, +) { + const parsed = ObjectJsonSchema.safeParse(schema); + if (!parsed.success) { + throw new Error( + `[mcp] tool "${toolId}": its ${role} JSON Schema is not an object schema - MCP requires type "object" at the root`, + ); + } + return parsed.data; +} + +function droppedKeys(descriptor: McpToolDescriptor, personalFields: McpPersonalFieldMode) { + return new Set(personalFields === 'drop' ? (descriptor.redact.personal ?? []) : []); +} + +function withoutKeys(schema: Tool['inputSchema'], dropped: ReadonlySet) { + const { properties, required } = schema; + return { + ...schema, + ...(properties && { + properties: Object.fromEntries( + Object.entries(properties).filter(([key]) => !dropped.has(key)), + ), + }), + ...(required && { required: required.filter((key) => !dropped.has(key)) }), + }; +} + +/** + * The read-class tools as MCP tool definitions, named by their model names. Each output schema + * is the kernel's, already narrowed to the allow-listed keys; in 'drop' mode the keys a tool + * marks as personal leave both it and every result. Throws when a tool's input or output + * schema is not a JSON object schema. + */ +export function exposedTools( + descriptors: readonly McpToolDescriptor[], + personalFields: McpPersonalFieldMode, +): ExposedTool[] { + return descriptors + .filter((descriptor) => descriptor.class === 'read') + .map((descriptor) => { + const dropped = droppedKeys(descriptor, personalFields); + return { + id: descriptor.id, + iam: descriptor.iam, + keys: descriptor.redact.allow.filter((key) => !dropped.has(key)), + definition: { + name: descriptor.modelName, + title: descriptor.title, + description: descriptor.description, + inputSchema: objectJsonSchema(descriptor.id, 'input', descriptor.inputJsonSchema), + outputSchema: withoutKeys( + objectJsonSchema(descriptor.id, 'output', descriptor.outputJsonSchema), + dropped, + ), + annotations: READ_TOOL_ANNOTATIONS, + }, + }; + }); +} + +export function projectOutput(output: Record, keys: readonly string[]) { + return Object.fromEntries( + keys + .filter((key) => Object.hasOwn(output, key) && output[key] !== undefined) + .map((key) => [key, output[key]]), + ); +} + +function failureBody({ error, issues }: McpFailure) { + return issues ? { error, issues } : { error }; +} + +/** + * A kernel result as an MCP tool result. A failure carries only its code and input issues, and + * no `structuredContent`: the SDK client validates any structured content against the tool's + * output schema, error results included. + */ +export function toCallToolResult( + result: McpToolResult, + keys: readonly string[], + correlationId: RunContext['correlationId'], +): CallToolResult { + const _meta = { [CORRELATION_ID_META_KEY]: correlationId }; + if (!result.ok) { + return { + content: [{ type: 'text', text: JSON.stringify(failureBody(result)) }], + isError: true, + _meta, + }; + } + const structuredContent = projectOutput(result.output, keys); + return { + content: [{ type: 'text', text: JSON.stringify(structuredContent) }], + structuredContent, + _meta, + }; +} diff --git a/packages/core/src/server/plugin-host/load-plugins.ts b/packages/core/src/server/plugin-host/load-plugins.ts index 2fa96ee73..552b9d2bc 100644 --- a/packages/core/src/server/plugin-host/load-plugins.ts +++ b/packages/core/src/server/plugin-host/load-plugins.ts @@ -5,6 +5,10 @@ import { ModuleRegistryImpl } from './module-registry.js'; export type PluginEntry = { id: string; + /** + * An absolute path. The plugin host `import()`s it from inside @openora/core, so a relative + * path or a bare package name resolves from core's location, not from the file that lists it. + */ path: string; // 'module' (default) = a domain module, selectable by a service manifest. // 'infra' = a broker/queue driver overlay that always loads, even for a diff --git a/packages/core/src/server/runtime/core-token-catalog.ts b/packages/core/src/server/runtime/core-token-catalog.ts index 12249be88..ecba79611 100644 --- a/packages/core/src/server/runtime/core-token-catalog.ts +++ b/packages/core/src/server/runtime/core-token-catalog.ts @@ -49,6 +49,8 @@ import { MAIL_DISPATCH, MAIL_RECIPIENT_DIRECTORY, MCP_KERNEL, + MCP_TOKEN_AUTHENTICATOR, + MCP_TOKEN_REVOCATION, MESSAGE_BROKER, OUTBOX, PAYMENT_ADAPTER, @@ -135,6 +137,8 @@ const coreTokenCatalog = { MAIL_DISPATCH, MAIL_RECIPIENT_DIRECTORY, MCP_KERNEL, + MCP_TOKEN_AUTHENTICATOR, + MCP_TOKEN_REVOCATION, MESSAGE_BROKER, OUTBOX, PAYMENT_ADAPTER, diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index 3a5e6ad80..1b83040d9 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -3,7 +3,7 @@ import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'; import { implement, onError, ORPCError, type AnyRouter } from '@orpc/server'; import { ResponseHeadersPlugin } from '@orpc/server/plugins'; import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'; -import { Hono } from 'hono'; +import { Hono, type Context } from 'hono'; import { bodyLimit } from 'hono/body-limit'; import { cors } from 'hono/cors'; import { etag } from 'hono/etag'; @@ -35,6 +35,7 @@ import { AUDIT_WRITER, IDENTITY_READER, MCP_KERNEL, + MCP_TOKEN_AUTHENTICATOR, RATE_LIMITER, CACHE, REALTIME_TRANSPORT, @@ -42,6 +43,7 @@ import { healthContract, IGAMING_CONFIG, type IgamingConfig, + type McpKernel, PLATFORM_CONFIG, PLAYER_ACTIVITY_TRACKER, SESSION_IDLE_POLICY, @@ -52,8 +54,14 @@ import { AdminGuard, ADMIN_GUARD, SessionResolver, AUTH_SESSION } from '../auth/ import { loadPlugins, type PluginEntry } from '../plugin-host/index.js'; import { createMcpKernel } from '../mcp/index.js'; import { authorizeWithAdminGuard } from '../mcp/authorize.js'; +import type { McpHttpTransport } from '../mcp/http-transport.js'; +import { payloadTooLarge } from '../mcp/transport-gate.js'; import { assertDurableSeamsBound } from './assert-durable-seams.js'; -import { applyClientAddress, resolveTrustedProxies } from './client-address.js'; +import { + applyClientAddress, + resolveTrustedProxies, + type TrustedProxies, +} from './client-address.js'; import { loadPlatformConfig, resolvePlatformConfigPath } from '../kernel/platform-config-loader.js'; import type { CoreTokenCatalog } from './core-token-catalog.js'; @@ -176,6 +184,119 @@ function headersToRecord(headers: Headers): Record { return out; } +function requestMeta(c: Context, trustedProxies: TrustedProxies) { + const headers = headersToRecord(c.req.raw.headers); + const remoteAddress = (c.env as { incoming?: { socket?: { remoteAddress?: string } } })?.incoming + ?.socket?.remoteAddress; + applyClientAddress(headers, remoteAddress, trustedProxies); + return { + headers, + clientMeta: extractClientMeta(headers), + traceId: headers['x-trace-id'] ?? randomUUID(), + }; +} + +const MCP_MAX_BODY_BYTES = 1_048_576; +const MCP_SDK_PACKAGE = '@modelcontextprotocol/sdk'; +const MCP_SDK_VERSION = '1.29.0'; + +type MountedMcpTransport = { path: string; transport: McpHttpTransport }; + +function causeChain(err: unknown) { + const chain: Error[] = []; + for ( + let current = err; + current instanceof Error && !chain.includes(current); + current = current.cause + ) { + chain.push(current); + } + return chain; +} + +function isMissingMcpSdk(err: unknown) { + return causeChain(err).some( + (link) => + 'code' in link && + link.code === 'ERR_MODULE_NOT_FOUND' && + link.message.includes(MCP_SDK_PACKAGE), + ); +} + +async function importMcpHttpTransport() { + try { + return await import('../mcp/http-transport.js'); + } catch (err) { + if (!isMissingMcpSdk(err)) { + throw err; + } + throw new Error( + `[create-app] agents.mcp.enabled is on, but ${MCP_SDK_PACKAGE} is not installed. ` + + `@openora/core declares it as an optional peer dependency: install ` + + `${MCP_SDK_PACKAGE}@${MCP_SDK_VERSION}, or turn agents.mcp.enabled off.`, + { cause: err }, + ); + } +} + +async function loadMcpTransport(container: Container, kernel: McpKernel) { + const config = container.get(PLATFORM_CONFIG).agents.mcp; + if (!config.enabled) { + return null; + } + const missing = [ + { + name: 'MCP_TOKEN_AUTHENTICATOR', + bound: container.has(MCP_TOKEN_AUTHENTICATOR), + fix: 'load the iam module, which binds it', + }, + { + name: 'AUDIT_WRITER', + bound: container.has(AUDIT_WRITER), + fix: 'load the audit module - the kernel refuses every call it cannot audit', + }, + ].filter((seam) => !seam.bound); + if (missing.length > 0) { + throw new Error( + '[create-app] agents.mcp.enabled is on, but the MCP transport has no binding for:\n' + + missing.map((seam) => ` - ${seam.name}: ${seam.fix}`).join('\n') + + '\nBind them, or turn agents.mcp.enabled off.', + ); + } + const { createMcpHttpTransport } = await importMcpHttpTransport(); + return { + path: config.path, + transport: createMcpHttpTransport({ + kernel, + authenticator: container.get(MCP_TOKEN_AUTHENTICATOR), + adminGuard: container.get(ADMIN_GUARD), + rateLimiter: container.get(RATE_LIMITER), + audit: container.get(AUDIT_WRITER), + config, + }), + }; +} + +function mountMcpTransport( + app: Hono, + { path, transport }: MountedMcpTransport, + trustedProxies: TrustedProxies, +) { + const limitBody = bodyLimit({ maxSize: MCP_MAX_BODY_BYTES, onError: () => payloadTooLarge() }); + app.all(path, async (c, next) => { + if (!transport.servesHost(new URL(c.req.url).hostname)) { + return next(); + } + const { clientMeta, traceId } = requestMeta(c, trustedProxies); + const refused = await limitBody(c, async () => { + c.res = await withRequestContext({ traceId, clientMeta }, () => + transport.handle(c.req.raw, clientMeta), + ); + }); + return refused ?? c.res; + }); +} + // Webhook signature schemes (eg aggregator callbacks) must hash the verbatim body, // so the runtime captures it before oRPC parses the request. Bounded to keep an // oversized payload from buffering into memory - past the cap rawBody stays unset @@ -376,7 +497,7 @@ export async function createApp( router[namespace] = factory(container) as AnyRouter; } // Built now so a broken tool or action-type factory fails boot, not the first call. - container.get(MCP_KERNEL); + const mcpTransport = await loadMcpTransport(container, container.get(MCP_KERNEL)); for (const registration of registry.jobs.getAll()) { jobQueue.registerWorker(registration); @@ -441,6 +562,12 @@ export async function createApp( return c.json({ error: 'Internal Server Error' }, 500); }); + // Ahead of every app-wide middleware: a served MCP request returns from its own handler, so + // CORS, the HTTP cache, raw-body capture and session resolution never run for it. + if (mcpTransport) { + mountMcpTransport(app, mcpTransport, trustedProxies); + } + if (corsOrigins) { app.use('/*', cors({ origin: corsOrigins, credentials: true })); } @@ -514,13 +641,10 @@ export async function createApp( const sessions = container.get(AUTH_SESSION); app.use('/*', async (c, next) => { - const headers = headersToRecord(c.req.raw.headers); - const remoteAddress = (c.env as { incoming?: { socket?: { remoteAddress?: string } } }) - ?.incoming?.socket?.remoteAddress; - applyClientAddress(headers, remoteAddress, trustedProxies); + const { headers, clientMeta, traceId } = requestMeta(c, trustedProxies); const context: OssContext = { request: { headers }, - clientMeta: extractClientMeta(headers), + clientMeta, rawBody: await captureRawBody(c.req.raw), }; @@ -536,8 +660,6 @@ export async function createApp( return c.res; }; - const traceId = headers['x-trace-id'] ?? randomUUID(); - if (!resolved) { // No valid session - context.auth stays undefined so getUserId 401s. Auth and // public routes still work. Run inside the request context for trace correlation. diff --git a/packages/core/src/testing/index.ts b/packages/core/src/testing/index.ts index 26598adc1..641151ea0 100644 --- a/packages/core/src/testing/index.ts +++ b/packages/core/src/testing/index.ts @@ -9,6 +9,7 @@ export { redisUrlForWorker, waitForAdvisoryLockWaiter, waitForRowLockWaiter, + waitForTableLockWaiter, waitForConsumerGroup, type Migration, type TestDb, diff --git a/packages/core/src/testing/mock.ts b/packages/core/src/testing/mock.ts index 99c2d0330..e9ed8a948 100644 --- a/packages/core/src/testing/mock.ts +++ b/packages/core/src/testing/mock.ts @@ -116,9 +116,11 @@ export const makeEventBus = (): MockedEventBus => export const makeAuditWriter = (): AuditWritePort & { record: Mock; recordInTransaction: Mock; + recordManyInTransaction: Mock; } => ({ record: vi.fn(async () => undefined), recordInTransaction: vi.fn(async () => undefined), + recordManyInTransaction: vi.fn(async () => undefined), }); /** diff --git a/packages/core/src/testing/real-infra.ts b/packages/core/src/testing/real-infra.ts index d74b288fe..e2e376a5c 100644 --- a/packages/core/src/testing/real-infra.ts +++ b/packages/core/src/testing/real-infra.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { Pool } from 'pg'; -import { sql } from 'drizzle-orm'; +import { getTableName, sql, type SQL } from 'drizzle-orm'; +import type { PgTable } from 'drizzle-orm/pg-core'; import { createClient } from 'redis'; import { DrizzleService } from '@openora/core/server'; @@ -58,18 +59,16 @@ export async function waitForConsumerGroup( } } +type LockWaiterDb = Pick; + /** Resolves once a session is blocked on a lock; times out quietly so the test fails on its own assertion. */ -async function waitForLockWaiter( - db: TestDb, - locktype: 'advisory' | 'transactionid', - timeoutMs: number, -): Promise { +async function waitForLockWaiter(db: LockWaiterDb, lock: SQL, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const { rows } = await db.drizzle.db.execute<{ waiting: number }>( sql`select count(*)::int as waiting from pg_locks l join pg_stat_activity a on a.pid = l.pid - where l.locktype = ${locktype} and not l.granted + where ${lock} and not l.granted and a.datname = current_database()`, ); if ((rows[0]?.waiting ?? 0) > 0) { @@ -79,12 +78,24 @@ async function waitForLockWaiter( } } -export function waitForAdvisoryLockWaiter(db: TestDb, timeoutMs = 3000): Promise { - return waitForLockWaiter(db, 'advisory', timeoutMs); +export function waitForAdvisoryLockWaiter(db: LockWaiterDb, timeoutMs = 3000): Promise { + return waitForLockWaiter(db, sql`l.locktype = 'advisory'`, timeoutMs); +} + +export function waitForRowLockWaiter(db: LockWaiterDb, timeoutMs = 3000): Promise { + return waitForLockWaiter(db, sql`l.locktype = 'transactionid'`, timeoutMs); } -export function waitForRowLockWaiter(db: TestDb, timeoutMs = 3000): Promise { - return waitForLockWaiter(db, 'transactionid', timeoutMs); +export function waitForTableLockWaiter( + db: LockWaiterDb, + table: PgTable, + timeoutMs = 3000, +): Promise { + return waitForLockWaiter( + db, + sql`l.locktype = 'relation' and l.relation = ${getTableName(table)}::regclass`, + timeoutMs, + ); } function isConnectionError(err: unknown): boolean { diff --git a/packages/testing/package.json b/packages/testing/package.json index a30ca2390..35ad8551f 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -38,6 +38,7 @@ "zod": "4.4.3" }, "devDependencies": { + "@modelcontextprotocol/sdk": "1.29.0", "@types/node": "25.9.2", "@types/pg": "8.20.0", "typescript": "6.0.3", diff --git a/packages/testing/src/__tests__/fixtures/test-failing-mcp-token-revocation-plugin.ts b/packages/testing/src/__tests__/fixtures/test-failing-mcp-token-revocation-plugin.ts new file mode 100644 index 000000000..104aac795 --- /dev/null +++ b/packages/testing/src/__tests__/fixtures/test-failing-mcp-token-revocation-plugin.ts @@ -0,0 +1,14 @@ +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; +import { MCP_TOKEN_REVOCATION } from '@openora/core/contracts'; + +export default { + id: 'test-failing-mcp-token-revocation', + dependsOn: ['iam'], + register(ctx) { + ctx.provide(MCP_TOKEN_REVOCATION, () => ({ + revokeAllForUser: async () => { + throw new Error('MCP token revocation is unavailable'); + }, + })); + }, +} as const satisfies Plugin; diff --git a/packages/testing/src/__tests__/fixtures/test-mcp-transport-plugin.ts b/packages/testing/src/__tests__/fixtures/test-mcp-transport-plugin.ts new file mode 100644 index 000000000..126a466f7 --- /dev/null +++ b/packages/testing/src/__tests__/fixtures/test-mcp-transport-plugin.ts @@ -0,0 +1,44 @@ +import * as z from 'zod'; +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; +import { + PLATFORM_CONFIG, + UuidSchema, + defineMcpTool, + definePlatformConfig, +} from '@openora/core/contracts'; + +export const MCP_TEST_RATE_LIMIT = { perMinute: 20, perDay: 1_000 } as const; + +const flagPlayerTool = defineMcpTool({ + id: 'flag_player', + title: 'Flag player', + description: 'Proposes a review flag on one player.', + class: 'propose', + schemaVersion: 1, + iam: { resource: 'player', action: 'view' }, + inputSchema: z.object({ playerId: UuidSchema }), + outputSchema: z.object({ flagged: z.boolean() }), + redact: { allow: ['flagged'] }, + errors: [], +}); + +export default { + id: 'test-mcp-transport', + dependsOn: ['identity'], + register(ctx) { + ctx.provide(PLATFORM_CONFIG, () => + definePlatformConfig({ + registration: { termsVersion: 'test-v1' }, + agents: { + mcp: { + enabled: true, + allowedHosts: ['localhost'], + rateLimit: MCP_TEST_RATE_LIMIT, + tokenIssuance: { maxActivePerAdmin: 50, perHour: 1_000 }, + }, + }, + }), + ); + ctx.mcp.tool(flagPlayerTool, () => async () => ({ flagged: true })); + }, +} as const satisfies Plugin; diff --git a/packages/testing/src/__tests__/mcp-credential-change.e2e.test.ts b/packages/testing/src/__tests__/mcp-credential-change.e2e.test.ts new file mode 100644 index 000000000..15f83f366 --- /dev/null +++ b/packages/testing/src/__tests__/mcp-credential-change.e2e.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { and, eq } from 'drizzle-orm'; +import * as z from 'zod'; +import { loadExtensions, DRIZZLE } from '@openora/core/server'; +import { + adminRole, + adminRolePermission, + adminRoleAssignment, + mcpToken, +} from '@openora/core/iam/schema'; +import { account, user } from '@openora/core/pam/schema/identity'; +import { + setupTestDb, + bootTestApp, + seedMinimal, + registerPlayer, + asAdmin, + asPlayer, + waitForEmail, + clearCapturedEmails, + type TestDb, + type TestApp, + type TestClient, +} from '../index.js'; + +const MCP_URL = 'http://localhost/mcp'; +const PING = { jsonrpc: '2.0', id: 1, method: 'ping' } as const; +const PASSWORD = 'password1234'; +const NEW_PASSWORD = 'brand-new-staff-passw0rd'; +const RESET_SUBJECT = 'Reset your password'; +const TWO_FACTOR_SUBJECT = 'Your verification code'; + +const IssuedTokenSchema = z.object({ id: z.string(), token: z.string() }); + +let db: TestDb; +let app: TestApp; + +const drizzle = () => app.container.get(DRIZZLE).db; + +function postJson(path: string, body: unknown, cookie?: string) { + return Promise.resolve( + app.app.request(path, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, + body: JSON.stringify(body), + }), + ); +} + +function ping(token: string) { + return Promise.resolve( + app.app.request(MCP_URL, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(PING), + }), + ); +} + +function cookiesOf(res: Response) { + return (res.headers.getSetCookie?.() ?? []) + .map((cookie) => cookie.split(';')[0]?.trim()) + .filter((pair): pair is string => Boolean(pair)) + .join('; '); +} + +async function codeMailedTo(email: string, subject: string) { + const mail = await waitForEmail(email, (sent) => sent.subject === subject); + const code = /\b(\d{6})\b/.exec(mail.text)?.[1]; + if (!code) { + throw new Error(`no six-digit code in "${subject}" for ${email}`); + } + return code; +} + +async function newPlayer() { + const email = `credential-stamp-${randomUUID()}@e2e.test`; + const userId = await registerPlayer(app, { email, password: PASSWORD }); + return { email, userId, client: await asPlayer(app.app, { email, password: PASSWORD }) }; +} + +async function newStaff() { + const email = `mcp-credentials-${randomUUID()}@e2e.test`; + const userId = await registerPlayer(app, { email, password: PASSWORD }); + await drizzle().update(user).set({ role: 'admin' }).where(eq(user.id, userId)); + await grantMcpAccess(userId); + return { email, userId, client: await asAdmin(app.app, { email, password: PASSWORD }) }; +} + +/** A staff account holds nothing until a role is assigned, so an MCP token owner needs one. */ +async function grantMcpAccess(userId: string) { + const [role] = await drizzle() + .insert(adminRole) + .values({ name: `MCP ${userId}` }) + .returning({ id: adminRole.id }); + if (!role) { + throw new Error('grantMcpAccess: role insert returned no row'); + } + await drizzle() + .insert(adminRolePermission) + .values({ roleId: role.id, resource: 'mcp-access', level: 'read_write' }); + await drizzle().insert(adminRoleAssignment).values({ userId, roleId: role.id }); +} + +async function issueToken(client: TestClient) { + const res = await client.post('/iam/my-mcp-tokens', { label: 'laptop' }); + expect(res.status).toBe(200); + return IssuedTokenSchema.parse(await res.json()); +} + +async function credentialStamp(userId: string) { + const [row] = await drizzle() + .select({ updatedAt: account.updatedAt }) + .from(account) + .where(and(eq(account.userId, userId), eq(account.providerId, 'credential'))); + if (!row) { + throw new Error(`no credential account for ${userId}`); + } + return row.updatedAt; +} + +async function revocationOf(tokenId: string) { + const [row] = await drizzle() + .select({ revokedAt: mcpToken.revokedAt, revokeReason: mcpToken.revokeReason }) + .from(mcpToken) + .where(eq(mcpToken.id, tokenId)); + return row; +} + +async function resetPassword(email: string, newPassword: string) { + expect((await postJson('/identity/password/forgot', { email })).status).toBe(200); + const otp = await codeMailedTo(email, RESET_SUBJECT); + return postJson('/identity/password/reset', { email, otp, newPassword }); +} + +beforeAll(async () => { + process.env['BETTER_AUTH_SECRET'] ??= 'e2e-test-better-auth-secret-please-change-000000'; + process.env['AUTH_SECRET'] ??= process.env['BETTER_AUTH_SECRET']; + process.env['WITHDRAWAL_PIN_HMAC_SECRET'] ??= 'e2e-test-withdrawal-pin-hmac-secret-000000'; + process.env['NODE_ENV'] ??= 'test'; + + db = await setupTestDb(); + const fixture = (name: string) => fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); + app = await bootTestApp({ + plugins: [ + ...(await loadExtensions()), + { id: 'test-mcp-transport', path: fixture('test-mcp-transport-plugin.ts') }, + { + id: 'test-failing-mcp-token-revocation', + path: fixture('test-failing-mcp-token-revocation-plugin.ts'), + }, + ], + databaseUrl: db.url, + }); + await seedMinimal(app.container, { playerCount: 0 }); +}, 60_000); + +afterAll(async () => { + await app?.close(); + await db?.dispose(); +}); + +describe('the credential stamp the MCP token gate reads', () => { + it('moves on a password change and again on a password reset', async () => { + const { email, userId, client } = await newPlayer(); + const registered = await credentialStamp(userId); + + const changed = await client.post('/identity/password/change', { + currentPassword: PASSWORD, + newPassword: NEW_PASSWORD, + }); + const afterChange = await credentialStamp(userId); + const reset = await resetPassword(email, `${NEW_PASSWORD}-again`); + const afterReset = await credentialStamp(userId); + + expect(changed.status).toBe(200); + expect(reset.status).toBe(200); + expect(afterChange.getTime()).toBeGreaterThan(registered.getTime()); + expect(afterReset.getTime()).toBeGreaterThan(afterChange.getTime()); + }); + + it('stays put through sign-ins, a failed sign-in, and a two-factor enrolment and sign-in', async () => { + const { email, userId, client } = await newPlayer(); + const registered = await credentialStamp(userId); + + expect((await postJson('/identity/login', { email, password: PASSWORD })).status).toBe(200); + expect((await postJson('/identity/login', { email, password: 'wrong-password' })).ok).toBe( + false, + ); + clearCapturedEmails(); + expect( + (await client.post('/identity/2fa/enable', { password: PASSWORD, method: 'email' })).status, + ).toBe(200); + const enrolled = await client.post('/identity/2fa/verify', { + code: await codeMailedTo(email, TWO_FACTOR_SUBJECT), + method: 'otp', + }); + const challenge = await postJson('/identity/login', { email, password: PASSWORD }); + const pending = cookiesOf(challenge); + clearCapturedEmails(); + const sent = await app.app.request('/identity/2fa/otp/send', { + method: 'POST', + headers: { cookie: pending }, + }); + const signedIn = await postJson( + '/identity/2fa/verify', + { code: await codeMailedTo(email, TWO_FACTOR_SUBJECT), method: 'otp' }, + pending, + ); + + expect(enrolled.status).toBe(200); + expect(await challenge.json()).toMatchObject({ twoFactorRedirect: true }); + expect(sent.status).toBe(200); + expect(signedIn.status).toBe(200); + expect(await credentialStamp(userId)).toEqual(registered); + }); +}); + +describe('MCP tokens when revoking them after a credential change fails', () => { + it('refuses a token issued before a password change and serves one issued after it', async () => { + const staff = await newStaff(); + const bystander = await newStaff(); + const before = await issueToken(staff.client); + const bystanders = await issueToken(bystander.client); + expect((await ping(before.token)).status).toBe(200); + + const changed = await staff.client.post('/identity/password/change', { + currentPassword: PASSWORD, + newPassword: NEW_PASSWORD, + }); + + expect(changed.status).toBe(200); + expect(await revocationOf(before.id)).toEqual({ revokedAt: null, revokeReason: null }); + expect((await ping(before.token)).status).toBe(401); + expect((await ping(bystanders.token)).status).toBe(200); + const signedInAgain = await asAdmin(app.app, { email: staff.email, password: NEW_PASSWORD }); + const after = await issueToken(signedInAgain); + expect((await ping(after.token)).status).toBe(200); + }); + + it('refuses a token issued before a password reset, and the old session cannot issue another', async () => { + const staff = await newStaff(); + const before = await issueToken(staff.client); + expect((await ping(before.token)).status).toBe(200); + + const reset = await resetPassword(staff.email, NEW_PASSWORD); + + expect(reset.status).toBe(200); + expect(await revocationOf(before.id)).toEqual({ revokedAt: null, revokeReason: null }); + expect((await ping(before.token)).status).toBe(401); + expect( + (await staff.client.post('/iam/my-mcp-tokens', { label: 'stolen session' })).status, + ).toBe(401); + const signedInAgain = await asAdmin(app.app, { email: staff.email, password: NEW_PASSWORD }); + const after = await issueToken(signedInAgain); + expect((await ping(after.token)).status).toBe(200); + }); +}); diff --git a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts index b71e2f1ae..88788c1ff 100644 --- a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts +++ b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts @@ -804,6 +804,27 @@ describe('agent tag, note and enhanced-KYC actions are replay-safe', () => { }); }); +describe('MCP transport while agents.mcp is off', () => { + it('refuses token issuance with a typed reason', async () => { + const res = await admin.post('/iam/my-mcp-tokens', { label: 'off' }); + + expect(res.status).toBe(409); + expect( + z.object({ data: z.object({ reason: z.string() }) }).parse(await res.json()).data.reason, + ).toBe('mcp_disabled'); + }); + + it('serves no endpoint', async () => { + const res = await app.app.request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }), + }); + + expect(res.status).toBe(404); + }); +}); + describe('add_note writes one note per approved proposal', () => { it('writes a note for each of two proposals carrying the same text from the same approver', async () => { const target = await newPlayer('notes-two-proposals'); diff --git a/packages/testing/src/__tests__/mcp-transport.e2e.test.ts b/packages/testing/src/__tests__/mcp-transport.e2e.test.ts new file mode 100644 index 000000000..857fdbaae --- /dev/null +++ b/packages/testing/src/__tests__/mcp-transport.e2e.test.ts @@ -0,0 +1,667 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { and, asc, eq, sql } from 'drizzle-orm'; +import * as z from 'zod'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { loadExtensions, DRIZZLE, EVENT_BUS, type DrizzleTx } from '@openora/core/server'; +import { IDENTITY_READER } from '@openora/core/contracts'; +import { auditLog } from '@openora/core/audit/schema'; +import { AuditService } from '@openora/core/audit/server'; +import { adminRolePermission, mcpToken } from '@openora/core/iam/schema'; +import { user } from '@openora/core/pam/schema/identity'; +import { waitForRowLockWaiter, waitForTableLockWaiter } from '@openora/core/testing'; +import { + setupTestDb, + bootTestApp, + seedMinimal, + registerAndMaterializePlayer, + registerPlayer, + asAdmin, + type TestDb, + type TestApp, + type TestClient, +} from '../index.js'; +import { MCP_TEST_RATE_LIMIT } from './fixtures/test-mcp-transport-plugin.js'; + +const MCP_URL = 'http://localhost/mcp'; +const PING = { jsonrpc: '2.0', id: 1, method: 'ping' } as const; +const PERSONAL_KEYS = [ + 'userId', + 'username', + 'email', + 'firstName', + 'lastName', + 'dateOfBirth', + 'phone', + 'timezone', + 'timezoneUpdatedAt', +]; + +const IssuedTokenSchema = z.object({ id: z.string(), token: z.string(), tokenPrefix: z.string() }); +const TokenSchema = z.object({ + id: z.string(), + status: z.string(), + callCount: z.number(), + lastUsedAt: z.string().nullable(), + revokeReason: z.string().nullable(), +}); +const TokenPageSchema = z.object({ items: z.array(TokenSchema.loose()) }); +const IdSchema = z.object({ id: z.string() }); +const ErrorReasonSchema = z.object({ data: z.object({ reason: z.string() }) }); +const ListItemSchema = z.object({ + id: z.string(), + admin: z.object({ email: z.string().nullable() }), +}); +const NEW_MODERATOR_PASSWORD = 'N3w-moderator-Passw0rd!'; +const TextContentSchema = z.object({ + content: z.array(z.object({ type: z.literal('text'), text: z.string() })), +}); + +let db: TestDb; +let app: TestApp; +let admin: TestClient; +let moderator: TestClient; +let adminId: string; +let moderatorId: string; +let playerId: string; + +const drizzle = () => app.container.get(DRIZZLE).db; + +async function userIdOf(email: string): Promise { + const [row] = await drizzle().select({ id: user.id }).from(user).where(eq(user.email, email)); + if (!row) { + throw new Error(`no user ${email}`); + } + return row.id; +} + +async function issueToken(client: TestClient, label = 'e2e') { + const res = await client.post('/iam/my-mcp-tokens', { label }); + expect(res.status).toBe(200); + return IssuedTokenSchema.parse(await res.json()); +} + +async function myToken(client: TestClient, tokenId: string) { + const res = await client.get('/iam/my-mcp-tokens'); + expect(res.status).toBe(200); + const token = TokenPageSchema.parse(await res.json()).items.find((item) => item.id === tokenId); + if (!token) { + throw new Error(`token ${tokenId} is not listed`); + } + return token; +} + +async function revokeReasonOf(tokenId: string): Promise { + const { rows } = await drizzle().execute<{ revoke_reason: string | null }>( + sql`SELECT revoke_reason FROM mcp_token WHERE id = ${tokenId}`, + ); + return rows[0]?.revoke_reason ?? null; +} + +const appFetch = (url: string | URL, init?: RequestInit): Promise => + Promise.resolve(app.app.request(String(url), init)); + +async function connect(token: string): Promise { + const client = new Client({ name: 'e2e', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { + fetch: appFetch, + requestInit: { headers: { authorization: `Bearer ${token}` } }, + }) as Transport; + await client.connect(transport); + return client; +} + +function rawPost({ + token, + url = MCP_URL, + method = 'POST', + headers = {}, + body = JSON.stringify(PING), +}: { + token?: string; + url?: string; + method?: string; + headers?: Record; + body?: string; +}): Promise { + const requestHeaders = new Headers({ + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...headers, + }); + if (token) { + requestHeaders.set('authorization', `Bearer ${token}`); + } + return Promise.resolve( + app.app.request(url, { + method, + headers: requestHeaders, + ...(method === 'GET' ? {} : { body }), + }), + ); +} + +function notExposedAuditRows(tokenId: string) { + return drizzle() + .select() + .from(auditLog) + .where( + and( + eq(auditLog.action, 'mcp.tool.failed'), + sql`${auditLog.after}->>'tokenId' = ${tokenId}`, + sql`${auditLog.after}->>'error' = 'not_exposed'`, + ), + ) + .orderBy(asc(auditLog.seq)); +} + +function tokenIdsOf(adminUserId: string) { + return drizzle() + .select({ id: mcpToken.id }) + .from(mcpToken) + .where(eq(mcpToken.adminUserId, adminUserId)); +} + +function mcpAccessDenialsOf(userId: string) { + return drizzle() + .select() + .from(auditLog) + .where( + and( + eq(auditLog.action, 'identity.user.unauthorized_access'), + eq(auditLog.actorId, userId), + eq(auditLog.resourceId, 'mcp-access:use'), + ), + ); +} + +function holdRowLocks(lock: (tx: DrizzleTx) => Promise) { + let markHeld = () => {}; + let release = () => {}; + const held = new Promise((resolve) => { + markHeld = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + const committed = drizzle().transaction(async (tx) => { + await lock(tx); + markHeld(); + await released; + }); + return { held, release, committed }; +} + +function toolAuditRows(tokenId: string) { + return drizzle() + .select() + .from(auditLog) + .where( + and(eq(auditLog.action, 'mcp.tool.invoked'), sql`${auditLog.after}->>'tokenId' = ${tokenId}`), + ) + .orderBy(asc(auditLog.seq)); +} + +beforeAll(async () => { + process.env['BETTER_AUTH_SECRET'] ??= 'e2e-test-better-auth-secret-please-change-000000'; + process.env['AUTH_SECRET'] ??= process.env['BETTER_AUTH_SECRET']; + process.env['WITHDRAWAL_PIN_HMAC_SECRET'] ??= 'e2e-test-withdrawal-pin-hmac-secret-000000'; + process.env['NODE_ENV'] ??= 'test'; + + db = await setupTestDb(); + const fixturePath = fileURLToPath( + new URL('./fixtures/test-mcp-transport-plugin.ts', import.meta.url), + ); + app = await bootTestApp({ + plugins: [...(await loadExtensions()), { id: 'test-mcp-transport', path: fixturePath }], + databaseUrl: db.url, + }); + await seedMinimal(app.container, { playerCount: 0 }); + admin = await asAdmin(app.app); + moderator = await asAdmin(app.app, { email: 'moderator@oss.dev' }); + adminId = await userIdOf('admin@oss.dev'); + moderatorId = await userIdOf('moderator@oss.dev'); + ({ playerId } = await registerAndMaterializePlayer(app, { email: 'mcp-transport@e2e.test' })); +}, 60_000); + +afterAll(async () => { + await app?.close(); + await db?.dispose(); +}); + +describe('MCP tokens', () => { + it('shows the plaintext once and never the hash, and audits the issue without either', async () => { + const issued = await issueToken(admin, 'claude-code'); + expect(issued.token).toMatch(/^ora_mcp_[A-Za-z0-9_-]{43}$/); + expect(issued.token.startsWith(issued.tokenPrefix)).toBe(true); + + const res = await admin.get('/iam/my-mcp-tokens'); + const listed = JSON.stringify(await res.json()); + const hash = createHash('sha256').update(issued.token, 'utf8').digest('hex'); + expect(listed).toContain(issued.id); + expect(listed).not.toContain(issued.token); + expect(listed).not.toContain(hash); + + const audit = await vi.waitFor(async () => { + const rows = await drizzle() + .select() + .from(auditLog) + .where( + and(eq(auditLog.action, 'iam.mcp_token.created'), eq(auditLog.resourceId, issued.id)), + ); + expect(rows).toHaveLength(1); + return rows; + }); + const serialized = JSON.stringify(audit); + expect(serialized).not.toContain(issued.token); + expect(serialized).not.toContain(hash); + }); + + it('refuses a lifetime above the cap with a typed reason', async () => { + const res = await admin.post('/iam/my-mcp-tokens', { label: 'too-long', ttlDays: 91 }); + + expect(res.status).toBe(400); + expect(ErrorReasonSchema.parse(await res.json()).data.reason).toBe('ttl_exceeds_max'); + }); + + it("answers another admin's token on the owner route as not found", async () => { + const theirs = await issueToken(moderator, 'moderator-own'); + + const res = await admin.post(`/iam/my-mcp-tokens/${theirs.id}/revoke`, {}); + + expect(res.status).toBe(404); + expect(await revokeReasonOf(theirs.id)).toBeNull(); + }); + + it("lets an overseer list and revoke another admin's token", async () => { + const theirs = await issueToken(moderator, 'moderator-overseen'); + + const listed = await admin.get(`/iam/mcp-tokens?adminUserId=${moderatorId}`); + const items = z.object({ items: z.array(ListItemSchema) }).parse(await listed.json()).items; + const revoked = await admin.post(`/iam/mcp-tokens/${theirs.id}/revoke`, {}); + + expect(listed.status).toBe(200); + expect(items.find((item) => item.id === theirs.id)?.admin.email).toBe('moderator@oss.dev'); + expect(revoked.status).toBe(200); + expect(await revokeReasonOf(theirs.id)).toBe('manual'); + expect((await rawPost({ token: theirs.token })).status).toBe(401); + }); +}); + +describe('MCP transport, happy path through the SDK client', () => { + it('lists the four read tools under model names, without propose tools or personal keys', async () => { + const { token } = await issueToken(admin); + const client = await connect(token); + + const { tools } = await client.listTools(); + + expect(tools.map((tool) => tool.name).sort()).toEqual([ + 'ggr_summary', + 'kyc_status', + 'player_summary', + 'wallet_activity', + ]); + const summary = tools.find((tool) => tool.name === 'player_summary'); + const properties = Object.keys(summary?.outputSchema?.properties ?? {}); + expect(properties).toContain('status'); + for (const key of [...PERSONAL_KEYS, 'bio']) { + expect(properties).not.toContain(key); + } + expect(summary?.annotations?.readOnlyHint).toBe(true); + await client.close(); + }); + + it('answers a tool call without personal data, audits it under the token and counts it', async () => { + const { id, token } = await issueToken(admin); + const client = await connect(token); + + const result = await client.callTool({ name: 'player_summary', arguments: { playerId } }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject({ playerId }); + for (const key of PERSONAL_KEYS) { + expect(result.structuredContent).not.toHaveProperty(key); + } + const [row, ...rest] = await vi.waitFor(async () => { + const rows = await toolAuditRows(id); + expect(rows).toHaveLength(1); + return rows; + }); + expect(rest).toEqual([]); + expect(row).toMatchObject({ actorId: adminId, resourceId: 'player.summary' }); + expect(row?.after).toMatchObject({ + actorKind: 'mcp_token', + tokenId: id, + personalDropped: true, + inputHash: expect.stringMatching(/^[0-9a-f]{64}$/), + outputHash: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + const listed = await myToken(admin, id); + expect(listed.callCount).toBe(1); + expect(listed.lastUsedAt).not.toBeNull(); + await client.close(); + }); + + it('returns kernel failures as tool errors carrying only the code', async () => { + const { token } = await issueToken(admin); + const client = await connect(token); + + const missing = await client.callTool({ + name: 'player_summary', + arguments: { playerId: '00000000-0000-4000-8000-000000000000' }, + }); + const invalid = await client.callTool({ name: 'player_summary', arguments: { playerId: 'x' } }); + + expect(missing.isError).toBe(true); + expect(missing.structuredContent).toBeUndefined(); + expect(JSON.parse(TextContentSchema.parse(missing).content[0]?.text ?? '{}')).toEqual({ + error: 'player_not_found', + }); + expect(invalid.isError).toBe(true); + expect(JSON.parse(TextContentSchema.parse(invalid).content[0]?.text ?? '{}')).toMatchObject({ + error: 'invalid_input', + }); + await client.close(); + }); + + it('refuses a propose tool and an action type as unknown tools, and audits both', async () => { + const { id, token } = await issueToken(admin); + const client = await connect(token); + + await expect(client.callTool({ name: 'flag_player', arguments: { playerId } })).rejects.toThrow( + /Unknown tool/, + ); + await expect(client.callTool({ name: 'hold_withdrawal', arguments: {} })).rejects.toThrow( + /Unknown tool/, + ); + + const rows = await notExposedAuditRows(id); + expect(rows.map((row) => row.resourceId)).toEqual(['flag_player', 'hold_withdrawal']); + await client.close(); + }); +}); + +describe('MCP transport, refusals before the protocol', () => { + it('answers a missing or unknown bearer with 401 and no OAuth metadata', async () => { + const missing = await rawPost({}); + const unknown = await rawPost({ token: `ora_mcp_${randomBytes(32).toString('base64url')}` }); + + expect(missing.status).toBe(401); + expect(missing.headers.get('www-authenticate')).toBe('Bearer'); + expect(unknown.status).toBe(401); + expect(unknown.headers.get('www-authenticate')).toBe('Bearer error="invalid_token"'); + }); + + it('refuses a revoked and an expired token', async () => { + const revoked = await issueToken(admin); + const expired = await issueToken(admin); + expect((await admin.post(`/iam/my-mcp-tokens/${revoked.id}/revoke`, {})).status).toBe(200); + await drizzle().execute( + sql`UPDATE mcp_token SET expires_at = now() - interval '1 minute' WHERE id = ${expired.id}`, + ); + + expect((await rawPost({ token: revoked.token })).status).toBe(401); + expect((await rawPost({ token: expired.token })).status).toBe(401); + expect(await revokeReasonOf(revoked.id)).toBe('manual'); + }); + + it('refuses a browser origin, a GET, an oversized body and an unbound host', async () => { + const { token } = await issueToken(admin); + + const origin = await rawPost({ token, headers: { origin: 'https://evil.example' } }); + const get = await rawPost({ token, method: 'GET' }); + const oversized = await rawPost({ token, body: 'x'.repeat(1_048_577) }); + const otherHost = await rawPost({ token, url: 'http://player.example.test/mcp' }); + + expect(origin.status).toBe(403); + expect(get.status).toBe(405); + expect(get.headers.get('allow')).toBe('POST'); + expect(oversized.status).toBe(413); + expect(otherHost.status).toBe(404); + }); + + it('marks every answer no-store and adds no CORS header without an origin', async () => { + const { token } = await issueToken(admin); + + const res = await rawPost({ token }); + + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(res.headers.get('access-control-allow-origin')).toBeNull(); + }); + + it('limits each token per minute with a Retry-After', async () => { + const { token } = await issueToken(admin); + + const statuses: number[] = []; + for (let attempt = 0; attempt < MCP_TEST_RATE_LIMIT.perMinute; attempt += 1) { + statuses.push((await rawPost({ token })).status); + } + const limited = await rawPost({ token }); + + expect(new Set(statuses)).toEqual(new Set([200])); + expect(limited.status).toBe(429); + expect(Number(limited.headers.get('retry-after'))).toBeGreaterThanOrEqual(1); + expect(Number(limited.headers.get('retry-after'))).toBeLessThanOrEqual(60); + }); +}); + +describe('MCP tokens, automatic revocation', () => { + it('revokes a user tokens when an admin revokes all of their sessions', async () => { + const { id, token } = await issueToken(moderator); + + const res = await admin.post('/identity/sessions/revoke-all', { userId: moderatorId }); + + expect(res.status).toBe(200); + expect(await revokeReasonOf(id)).toBe('sessions_revoked'); + expect((await rawPost({ token })).status).toBe(401); + moderator = await asAdmin(app.app, { email: 'moderator@oss.dev' }); + }); + + it('revokes on deactivation and keeps the token revoked after reactivation', async () => { + const { id, token } = await issueToken(moderator); + + expect( + (await admin.patch(`/backoffice/users/${moderatorId}`, { isActive: false })).status, + ).toBe(200); + expect(await revokeReasonOf(id)).toBe('admin_disabled'); + expect((await rawPost({ token })).status).toBe(401); + + expect((await admin.patch(`/backoffice/users/${moderatorId}`, { isActive: true })).status).toBe( + 200, + ); + expect((await rawPost({ token })).status).toBe(401); + moderator = await asAdmin(app.app, { email: 'moderator@oss.dev' }); + }); + + it('revokes on a password change', async () => { + const { id, token } = await issueToken(moderator); + + const res = await moderator.post('/identity/password/change', { + currentPassword: 'password1234', + newPassword: NEW_MODERATOR_PASSWORD, + }); + + expect(res.status).toBe(200); + expect(await revokeReasonOf(id)).toBe('sessions_revoked'); + expect((await rawPost({ token })).status).toBe(401); + moderator = await asAdmin(app.app, { + email: 'moderator@oss.dev', + password: NEW_MODERATOR_PASSWORD, + }); + }); +}); + +const WITH_MCP = [ + { resource: 'mcp-access', level: 'read_write' }, + { resource: 'player', level: 'read' }, +]; +const WITHOUT_MCP = [{ resource: 'player', level: 'read' }]; + +/** + * A fresh staff account holding exactly one new role with `grants` - the seeded admins + * carry a default role assignment, and grants are the union of every assignment, so a + * test about one narrow role needs an account that holds nothing else. + */ +async function staffWithRole(grants: { resource: string; level: string }[]) { + const email = `mcp-staff-${randomUUID()}@e2e.test`; + const userId = await registerPlayer(app, { email }); + await drizzle().update(user).set({ role: 'admin' }).where(eq(user.id, userId)); + const role = IdSchema.parse( + await (await admin.post('/iam/roles', { name: `MCP staff ${userId}` })).json(), + ); + const setGrants = (next: { resource: string; level: string }[]) => + admin.put(`/iam/roles/${role.id}/permissions`, { grants: next }); + expect((await setGrants(grants)).status).toBe(200); + expect((await admin.post('/iam/assignments', { userId, roleId: role.id })).status).toBe(200); + return { userId, email, roleId: role.id, setGrants, client: await asAdmin(app.app, { email }) }; +} + +describe('MCP transport, grants', () => { + it('lists only what a narrow role grants and refuses the rest, then revokes when MCP access goes', async () => { + const staff = await staffWithRole(WITH_MCP); + const { id, token } = await issueToken(staff.client); + const client = await connect(token); + + const { tools } = await client.listTools(); + const kyc = await client.callTool({ name: 'kyc_status', arguments: { playerId } }); + + expect(tools.map((tool) => tool.name)).toEqual(['player_summary']); + expect(kyc.isError).toBe(true); + expect(JSON.parse(TextContentSchema.parse(kyc).content[0]?.text ?? '{}')).toEqual({ + error: 'forbidden', + }); + expect((await staff.client.get('/iam/mcp-tokens')).status).toBe(403); + expect((await staff.client.post('/iam/mcp-tokens/revoke-all', {})).status).toBe(403); + await client.close(); + + expect((await staff.setGrants(WITHOUT_MCP)).status).toBe(200); + expect(await revokeReasonOf(id)).toBe('admin_role_removed'); + expect((await rawPost({ token })).status).toBe(401); + expect((await staff.client.post('/iam/my-mcp-tokens', { label: 'no-access' })).status).toBe( + 403, + ); + expect((await staff.client.get('/iam/my-mcp-tokens')).status).toBe(403); + }); +}); + +describe('MCP tokens, oversight', () => { + it('revokes every active token at once and audits each one in a valid chain', async () => { + const issued = [await issueToken(admin), await issueToken(admin), await issueToken(admin)]; + + const res = await admin.post('/iam/mcp-tokens/revoke-all', {}); + + expect(res.status).toBe(200); + const { revoked } = z.object({ revoked: z.number() }).parse(await res.json()); + expect(revoked).toBeGreaterThanOrEqual(issued.length); + const rows = await drizzle() + .select() + .from(auditLog) + .where( + and( + eq(auditLog.action, 'iam.mcp_token.revoked'), + sql`${auditLog.after}->>'reason' = 'revoked_all'`, + ), + ) + .orderBy(asc(auditLog.seq)); + expect(rows).toHaveLength(revoked); + expect(new Set(rows.map((row) => row.resourceId)).size).toBe(revoked); + expect(rows.map((row) => row.resourceId)).toEqual( + expect.arrayContaining(issued.map((token) => token.id)), + ); + for (const row of rows) { + expect(row).toMatchObject({ + actorId: adminId, + actorType: 'admin', + resourceType: 'mcp-token', + before: { revokedAt: null }, + after: expect.objectContaining({ reason: 'revoked_all', tokenPrefix: expect.any(String) }), + }); + } + for (const { token } of issued) { + expect((await rawPost({ token })).status).toBe(401); + } + const audit = new AuditService( + app.container.get(DRIZZLE), + app.container.get(EVENT_BUS), + app.container.get(IDENTITY_READER), + ); + expect(await audit.verifyChain()).toEqual({ valid: true }); + }); +}); + +describe('MCP tokens, issuing while a grant change takes MCP access away', () => { + it('refuses the issue the grant change held back, leaves no token, and issues again once access is back', async () => { + const staff = await staffWithRole(WITH_MCP); + const appDb = { drizzle: app.container.get(DRIZZLE) }; + const rows = holdRowLocks((tx) => + tx + .select({ id: adminRolePermission.id }) + .from(adminRolePermission) + .where(eq(adminRolePermission.roleId, staff.roleId)) + .for('update'), + ); + await rows.held; + + const removing = staff.setGrants(WITHOUT_MCP); + await waitForRowLockWaiter(appDb); + const issuing = staff.client.post('/iam/my-mcp-tokens', { label: 'raced' }); + await waitForTableLockWaiter(appDb, mcpToken); + rows.release(); + await rows.committed; + const [removed, issued] = await Promise.all([removing, issuing]); + + expect(removed.status).toBe(200); + expect(issued.status).toBe(403); + expect(ErrorReasonSchema.parse(await issued.json()).data.reason).toBe('owner_ineligible'); + expect(await tokenIdsOf(staff.userId)).toEqual([]); + await vi.waitFor(async () => { + expect(await mcpAccessDenialsOf(staff.userId)).toHaveLength(1); + }); + + expect((await staff.setGrants(WITH_MCP)).status).toBe(200); + const { id, token } = await issueToken(staff.client, 'after the race'); + expect(await tokenIdsOf(staff.userId)).toEqual([{ id }]); + expect((await rawPost({ token })).status).toBe(200); + }); +}); + +describe('MCP tokens, issuing while the issuing session is revoked', () => { + it('refuses the issue a revoke-all held back, adds no token, and issues again from a new session', async () => { + const { userId: staffId, client: staff, email } = await staffWithRole(WITH_MCP); + const existing = await issueToken(staff, 'before the race'); + const appDb = { drizzle: app.container.get(DRIZZLE) }; + const rows = holdRowLocks((tx) => + tx + .select({ id: mcpToken.id }) + .from(mcpToken) + .where(eq(mcpToken.id, existing.id)) + .for('update'), + ); + await rows.held; + + const revoking = admin.post('/identity/sessions/revoke-all', { userId: staffId }); + await waitForRowLockWaiter(appDb); + const issuing = staff.post('/iam/my-mcp-tokens', { label: 'raced' }); + await waitForTableLockWaiter(appDb, mcpToken); + rows.release(); + await rows.committed; + const [revoked, issued] = await Promise.all([revoking, issuing]); + + expect(revoked.status).toBe(200); + expect(issued.status).toBe(403); + expect(ErrorReasonSchema.parse(await issued.json()).data.reason).toBe('owner_ineligible'); + expect(await tokenIdsOf(staffId)).toEqual([{ id: existing.id }]); + expect(await revokeReasonOf(existing.id)).toBe('sessions_revoked'); + await vi.waitFor(async () => { + expect(await mcpAccessDenialsOf(staffId)).toHaveLength(1); + }); + + const signedInAgain = await asAdmin(app.app, { email }); + const { token } = await issueToken(signedInAgain, 'new session'); + expect((await rawPost({ token })).status).toBe(200); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1427f6190..4025acedf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: specifier: 4.4.3 version: 4.4.3 devDependencies: + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(zod@4.4.3) '@tanstack/react-query': specifier: 5.101.4 version: 5.101.4(react@19.2.7) @@ -273,6 +276,9 @@ importers: specifier: 4.4.3 version: 4.4.3 devDependencies: + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(zod@4.4.3) '@types/node': specifier: 25.9.2 version: 25.9.2 diff --git a/tools/templates/consumer/apps/api/src/extensions.config.ts.tpl b/tools/templates/consumer/apps/api/src/extensions.config.ts.tpl index f33fb5120..9291e17a6 100644 --- a/tools/templates/consumer/apps/api/src/extensions.config.ts.tpl +++ b/tools/templates/consumer/apps/api/src/extensions.config.ts.tpl @@ -1,4 +1,5 @@ import { corePlugins, type PluginEntry } from '@openora/core/server'; +import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -7,16 +8,27 @@ import { fileURLToPath } from 'node:url'; // later registration of a DI token wins, so an adapter override must come after // the module it replaces. Opt a core module out with a filter, eg // `corePlugins().filter((p) => p.id !== 'chat')`. +// +// Every `path` must be absolute: the plugin host imports it from inside +// @openora/core, where a relative path or a bare package name resolves from +// core's location instead of this file's. // Local overlays live alongside this file, under src/extensions//. const LOCAL = resolve(dirname(fileURLToPath(import.meta.url)), 'extensions'); +// Add-on packages installed from a registry resolve from this app. +const fromApp = createRequire(import.meta.url).resolve; + export const extensions: PluginEntry[] = [ ...corePlugins(), // Your overlays. Generate with `pnpm gen plugin` / `pnpm gen adapter`. // Example (loaded after `wallet` so its PAYMENT_ADAPTER binding wins): // { id: 'my-payment', path: `${LOCAL}/my-payment/plugin.ts` }, + + // An add-on package, after the modules it depends on: + // { id: 'igaming-poker', path: fromApp('@acme/igaming-poker/plugin') }, ]; void LOCAL; +void fromApp;