Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/harden-api-key-data-door-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/core': patch
'@objectstack/rest': patch
'@objectstack/runtime': patch
'@objectstack/mcp': patch
---

harden(data): changing an API key through the data door requires a signed-in session

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under `skills/**` names `sys_api_key` or teaches managing a key with an API key, and the docs teach key revocation and rotation from the API Keys list (`content/docs/ai/connect-mcp.mdx`, `content/docs/api/error-catalog.mdx`), a signed-in surface; every taught use stays true.

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime write door: the data doors stop taking writes to `sys_api_key` from a caller an API key authenticated, and the remedy is to make the same change from a signed-in session, never a rewrite of anyone's code or metadata. No exported declaration is removed or narrowed; four names are added to `@objectstack/core`'s entry (the widening this changeset's `yes` declares); all four packages publish; no ADR-0087 id is named or touched. -->

**BREAKING** (an accept-set narrowing).

Managing API keys is a signed-in-session act on every data door, as minting one through `POST /api/v1/keys` is. An API key keeps acting as its owner for data, automation and MCP.

**FROM.** The data doors judged a write to `sys_api_key` by the row and column rules alone, whichever credential authenticated the caller.

**TO.** A write to `sys_api_key` from a caller an API key authenticated is refused `403 PERMISSION_DENIED`, and nothing is written, on the REST data routes (`/api/v1/data/sys_api_key`, the bulk routes and the cross-object `POST /api/v1/batch`), on the runtime dispatcher's data faces (`/data`, the `/mcp` HTTP tools, the action doors) and on the stdio MCP transport's `create_record`, `update_record` and `delete_record` tools, whose principal is always the configured `OS_MCP_STDIO_API_KEY`. The message tells the caller to sign in and manage the key from the API Keys page.

- **Who is affected.** Only a script, integration or agent that changed `sys_api_key` rows through one of those doors while authenticated with an API key. **Fix:** make the change signed in, from the API Keys list (Account → Developer), or with a session cookie or session bearer token.
- **What does not change.** A signed-in session manages its own keys from the list as before, and the existing rule on who restores a key a forced password reset revoked is unchanged. Reading `sys_api_key` is unchanged on every door. System-context writes (the mint, the last-used stamp, the forced reset) are untouched. An operation the object does not expose keeps its `405 OBJECT_API_METHOD_NOT_ALLOWED`.
- **Where it is decided.** One rule, `apiKeyManagementCredentialRefusal`, added to `@objectstack/core`'s entry with `API_KEY_MANAGEMENT_READ_OPERATIONS`, `API_KEY_MANAGEMENT_SESSION_REQUIRED_MESSAGE` and the type `ApiKeyManagementCredentialInput`, asked beside each door's exposure gate. The REST and dispatcher doors read which credential authenticated the request from the shared request resolver (`onAdmission`); the stdio transport's credential is its API key by construction. The `ExecutionContext` shape is unchanged.
- **Embedders calling `HttpDispatcher` data handlers directly.** The rule is positive on the credential: a context with no `admission` keeps today's answer.
44 changes: 44 additions & 0 deletions packages/core/src/security/api-key-management-credential.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import {
apiKeyManagementCredentialRefusal,
API_KEY_MANAGEMENT_READ_OPERATIONS,
} from './api-key-management-credential.js';

/**
* The one rule both data doors ask: a write to `sys_api_key` from a principal
* an API key admitted is refused; everything else keeps today's answer.
*/
describe('apiKeyManagementCredentialRefusal', () => {
const WRITES = ['create', 'update', 'delete', 'bulk', 'import'];

it.each(WRITES)('refuses `%s` on sys_api_key from an API-key-admitted caller, on the ADR-0112 envelope', (operation) => {
const refusal = apiKeyManagementCredentialRefusal({ object: 'sys_api_key', operation, credential: 'api_key' });
expect(refusal).toBeInstanceOf(Error);
expect(refusal).toMatchObject({ code: 'PERMISSION_DENIED', status: 403, statusCode: 403, object: 'sys_api_key' });
});

it('refuses a write verb it has never heard of: only the declared reads pass', () => {
const refusal = apiKeyManagementCredentialRefusal({ object: 'sys_api_key', operation: 'upsert', credential: 'api_key' });
expect(refusal).toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
});

it.each([...API_KEY_MANAGEMENT_READ_OPERATIONS])('leaves the read `%s` alone', (operation) => {
expect(apiKeyManagementCredentialRefusal({ object: 'sys_api_key', operation, credential: 'api_key' })).toBeUndefined();
});

it.each(WRITES)('leaves `%s` alone for a session-admitted caller', (operation) => {
expect(apiKeyManagementCredentialRefusal({ object: 'sys_api_key', operation, credential: 'session' })).toBeUndefined();
});

it('leaves a caller whose door was told no credential alone (positive rule)', () => {
expect(apiKeyManagementCredentialRefusal({ object: 'sys_api_key', operation: 'update', credential: undefined })).toBeUndefined();
});

it('leaves every other object alone for an API-key-admitted caller', () => {
for (const object of ['crm_account', 'sys_user', 'sys_session', undefined, '']) {
expect(apiKeyManagementCredentialRefusal({ object, operation: 'update', credential: 'api_key' })).toBeUndefined();
}
});
});
89 changes: 89 additions & 0 deletions packages/core/src/security/api-key-management-credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Managing API keys is a signed-in-session act, on every data door.
*
* An API key acts as its owner for data, automation and MCP. Managing the
* owner's credentials is not part of that: `POST /api/v1/keys` takes a mint
* only from a principal a signed-in session admitted, and this module is the
* same answer for the generic data doors that reach the `sys_api_key` rows
* themselves (`PATCH /api/v1/data/sys_api_key/:id` and every sibling route
* that writes through the per-object gate). A write to `sys_api_key` from a
* principal an API key admitted is refused `403 PERMISSION_DENIED`, and
* nothing is written; reads are unchanged.
*
* ## Why at the transport and not in an ObjectQL hook
*
* Which credential admitted the caller is decided once, by the shared resolver
* (`resolveAuthzContext`'s `credential`), and handed to the door that called
* it (`resolveRequestExecutionContext`'s `onAdmission`). The
* `ExecutionContext` envelope does not carry it, so an ObjectQL hook cannot
* ask it. Each door asks this function at its per-object gate, the one every
* data route on that door passes: the runtime dispatcher's
* `refuseUnexposedDataAction`, `RestServer.enforceApiAccess` (and per op on
* its cross-object batch), and the stdio MCP transport's write arms
* (`@objectstack/mcp` `stdio-data-bridge.ts`), whose principal an API key
* admits by construction (ADR-0101).
*
* ## The rule
*
* - Positive on the credential: refused only when the door was told an API
* key admitted the caller. A session caller, and a caller whose door was
* told nothing (an embedder handing the dispatcher its own context), keep
* today's answer.
* - Every operation that is not a read is refused, so a write verb added to
* a door later is covered from the day it lands.
* - A system-context write never reaches a door's gate as a caller, and the
* gates skip the system context before asking: the mint, the
* `last_used_at` stamp and the forced reset's revoke are untouched.
*/

import { SystemObjectName } from '@objectstack/spec/system';

/**
* The data-door operations that only read. Every other operation on
* `sys_api_key` (`create`, `update`, `delete`, `bulk`, `import`, and any verb a
* door adds later) is a write for this rule.
*/
export const API_KEY_MANAGEMENT_READ_OPERATIONS: ReadonlySet<string> = new Set([
'get',
'list',
'query',
'find',
'aggregate',
'history',
'export',
]);

/** The located refusal: names the road (sign in, the API Keys page) and nothing about the key presented. */
export const API_KEY_MANAGEMENT_SESSION_REQUIRED_MESSAGE =
'PERMISSION_DENIED: Changing an API key requires a signed-in session. Sign in and manage the key from the '
+ 'API Keys page; a request authenticated with an API key cannot revoke, restore or change API keys.';

/** What a door hands the rule: the object addressed, the door's operation name, and the admitting credential. */
export interface ApiKeyManagementCredentialInput {
object: unknown;
operation: string;
/** The credential the door's `onAdmission` was told, or `undefined` when it was told none. */
credential: 'api_key' | 'session' | undefined;
}

/**
* The refusal for a `sys_api_key` write from a principal an API key admitted,
* or `undefined` when the rule does not apply. Thrown by the door, it renders
* on the envelope the data doors already map (`code`, `status`, `object`),
* the one the `sys_api_key` restore guard's refusal uses.
*/
export function apiKeyManagementCredentialRefusal(
input: ApiKeyManagementCredentialInput,
): (Error & { code: 'PERMISSION_DENIED'; status: 403; statusCode: 403; object: string }) | undefined {
if (input.credential !== 'api_key') return undefined;
if (input.object !== SystemObjectName.API_KEY) return undefined;
if (API_KEY_MANAGEMENT_READ_OPERATIONS.has(input.operation)) return undefined;
return Object.assign(new Error(API_KEY_MANAGEMENT_SESSION_REQUIRED_MESSAGE), {
code: 'PERMISSION_DENIED' as const,
status: 403 as const,
statusCode: 403 as const,
object: SystemObjectName.API_KEY as string,
});
}
9 changes: 9 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,15 @@ export {
type UserAuthGateSource,
} from './auth-gate.js';

// Managing API keys is a signed-in-session act on every data door: the one
// rule the runtime dispatcher's and RestServer's per-object gates ask.
export {
apiKeyManagementCredentialRefusal,
API_KEY_MANAGEMENT_READ_OPERATIONS,
API_KEY_MANAGEMENT_SESSION_REQUIRED_MESSAGE,
type ApiKeyManagementCredentialInput,
} from './api-key-management-credential.js';

// #2567 — the single anonymous-deny decision shared by every HTTP seam.
export {
shouldDenyAnonymous,
Expand Down
74 changes: 74 additions & 0 deletions packages/mcp/src/stdio-data-bridge.exposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,3 +495,77 @@ describe('#15416 the stdio refusal never names an operation it also allows', ()
}
});
});

// ---------------------------------------------------------------------------
// Beside the exposure gate: changing an API key requires a signed-in session
// ---------------------------------------------------------------------------

/**
* This transport's principal is admitted by `OS_MCP_STDIO_API_KEY` alone
* (ADR-0101), so its write arms ask `apiKeyManagementCredentialRefusal`
* (`@objectstack/core`) with the API-key credential, after the exposure gate
* and before the engine. Refusals are asserted by envelope (`code` + `status`)
* with the engine never asked; every served case asserts the engine call.
*/
describe('stdio data bridge: a sys_api_key write is refused 403 PERMISSION_DENIED', () => {
/** The shipped declaration's method set, and an unrestricted one that lets every write verb reach the rule. */
const SHIPPED = { name: 'sys_api_key', enable: { apiMethods: ['get', 'list', 'update'] } };
const UNRESTRICTED = { name: 'sys_api_key' };

const expectCredentialRefusal = async (run: () => Promise<unknown>) => {
let thrown: any;
try {
await run();
} catch (e) {
thrown = e;
}
expect(thrown, 'the write must be refused').toBeInstanceOf(Error);
expect(thrown).toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: 'sys_api_key' });
};

it('refuses update on the shipped declaration, and never asks the engine', async () => {
const { bridge, engine } = makeBridge(SHIPPED);
await expectCredentialRefusal(() => bridge.update('sys_api_key', 'k1', { revoked: false }));
expect(engine.find).not.toHaveBeenCalled();
expect(engine.update).not.toHaveBeenCalled();
});

it('refuses create, update and remove when every verb is exposed, and never asks the engine', async () => {
const { bridge, engine } = makeBridge(UNRESTRICTED);
await expectCredentialRefusal(() => bridge.create('sys_api_key', { name: 'x' }));
await expectCredentialRefusal(() => bridge.update('sys_api_key', 'k1', { revoked: true }));
await expectCredentialRefusal(() => bridge.remove('sys_api_key', 'k1'));
expect(engine.insert).not.toHaveBeenCalled();
expect(engine.find).not.toHaveBeenCalled();
expect(engine.update).not.toHaveBeenCalled();
expect(engine.delete).not.toHaveBeenCalled();
});

it('an operation the object does not expose keeps its 405, ahead of the rule', async () => {
const { bridge, engine } = makeBridge(SHIPPED);
for (const run of [() => bridge.create('sys_api_key', { name: 'x' }), () => bridge.remove('sys_api_key', 'k1')]) {
let thrown: any;
try { await run(); } catch (e) { thrown = e; }
expect(thrown).toMatchObject({ code: 'OBJECT_API_METHOD_NOT_ALLOWED', status: 405 });
}
expect(engine.insert).not.toHaveBeenCalled();
expect(engine.delete).not.toHaveBeenCalled();
});

it('serves reads of sys_api_key', async () => {
const { bridge, engine } = makeBridge(SHIPPED);
const listed: any = await bridge.query('sys_api_key', { limit: 5 });
const one: any = await bridge.get('sys_api_key', 'r1');
expect(listed.records).toHaveLength(1);
expect(one?.id).toBe('r1');
// Both reads reach the engine (the by-id read goes through `find` too).
expect(engine.find).toHaveBeenCalledTimes(2);
});

it('serves a write to another object', async () => {
const { bridge, engine } = makeBridge({ name: 'task' });
await bridge.update('task', 'r1', { title: 'x' });
expect(engine.update).toHaveBeenCalledTimes(1);
expect(engine.update.mock.calls[0][0]).toBe('task');
});
});
26 changes: 26 additions & 0 deletions packages/mcp/src/stdio-data-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'
// and `@objectstack/core` is the lowest package that carries the factory, so
// there is no reason to add a second import path to the same function.
import { recordNotFoundError } from '@objectstack/core';
// Managing API keys is a signed-in-session act: the one rule every data door
// asks beside its exposure gate (`RestServer.enforceApiAccess`, the runtime
// dispatcher's `refuseUnexposedDataAction`), asked here by the write arms.
import { apiKeyManagementCredentialRefusal } from '@objectstack/core';
import type { McpDataBridge, McpObjectSummary } from './mcp-http-tools.js';
import { metaItemsArray, type McpObjectView } from './mcp-server-runtime.js';
import {
Expand Down Expand Up @@ -852,6 +856,25 @@ export function serveStoredMetadataRows(
return rows.map((row) => serveStoredMetadataRow(object, row, opts));
}

/**
* The API-key management rule on this transport: a write to `sys_api_key` is
* refused `403 PERMISSION_DENIED` (`apiKeyManagementCredentialRefusal`,
* `@objectstack/core`, whose docblock carries the rule), thrown as the error
* the tool layer renders, like the exposure refusals beside it.
*
* The credential is `'api_key'` by construction: this transport admits its
* principal from `OS_MCP_STDIO_API_KEY` and from nothing else (ADR-0101,
* `resolveStdioExecutionContext` in `plugin.ts`), and the envelope carries no
* credential to read. That principal is never the system context, so the rule
* reads no elevation flag: it has none to grant. Asked after the exposure
* gate, so an operation the object does not expose keeps its `405`, and before
* the engine is asked anything.
*/
function enforceApiKeyManagementCredential(object: string, action: string): void {
const refusal = apiKeyManagementCredentialRefusal({ object, operation: action, credential: 'api_key' });
if (refusal) throw refusal;
}

/**
* Build the stdio transport's principal-bound data bridge.
*
Expand Down Expand Up @@ -965,6 +988,7 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge
async create(object, data) {
const context = await resolvePrincipal();
await enforceApiExposure(reads, object, GATED_ACTIONS.create, context);
enforceApiKeyManagementCredential(object, GATED_ACTIONS.create);
const written = (await engine.insert(object, data, { context })) as
| Record<string, unknown>
| undefined;
Expand All @@ -988,6 +1012,7 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge
// miss is an observable difference, so gating second would answer "that
// id names no row" for an object the author declared unexposed.
await enforceApiExposure(reads, object, GATED_ACTIONS.update, context);
enforceApiKeyManagementCredential(object, GATED_ACTIONS.update);
const existing = await findById(engine, object, id, context);
// The "this id names no row" refusal, raised BEFORE the write is
// attempted — a write path that answered success for an id that matched
Expand Down Expand Up @@ -1018,6 +1043,7 @@ export function createStdioDataBridge(deps: StdioDataBridgeDeps): McpDataBridge
const context = await resolvePrincipal();
// Gated before the probe, for the reason `update` states.
await enforceApiExposure(reads, object, GATED_ACTIONS.remove, context);
enforceApiKeyManagementCredential(object, GATED_ACTIONS.remove);
const existing = await findById(engine, object, id, context);
// Same shared envelope as `update`, above.
if (!existing) throw recordNotFoundError(object, id);
Expand Down
Loading
Loading