diff --git a/packages/realm-server/handlers/handle-delegate-session.ts b/packages/realm-server/handlers/handle-delegate-session.ts new file mode 100644 index 0000000000..47a0f4cd4a --- /dev/null +++ b/packages/realm-server/handlers/handle-delegate-session.ts @@ -0,0 +1,187 @@ +import type Koa from 'koa'; +import { randomUUID } from 'crypto'; +import { + ensureTrailingSlash, + fetchRealmPermissions, + logger, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import RealmPermissionChecker from '@cardstack/runtime-common/realm-permission-checker'; +import type { CreateRoutesArgs } from '../routes.ts'; +import { createJWT } from '../jwt.ts'; +import { + fetchRequestFromContext, + sendResponseForBadRequest, + sendResponseForError, + sendResponseForUnauthorizedRequest, + setContextResponse, +} from '../middleware/index.ts'; +import { + DELEGATION_SIGNATURE_HEADER, + DELEGATION_TIMESTAMP_HEADER, + verifyDelegationRequest, +} from '../utils/delegation.ts'; + +// Token lifetime per the v1 security design (CS-11551): 30 minutes. Long +// enough to span a tool call, short enough to bound how stale a revoked +// realm permission can be when read through a delegated session. +const DELEGATED_TOKEN_TTL = '30m'; + +const log = logger('realm:delegate-session'); + +function headerValue( + headers: Koa.Context['req']['headers'], + name: string, +): string | undefined { + let value = headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +// Mints a realm session JWT scoped to a named user's read access on a single +// realm (CS-11552). Shared-secret authenticated (HMAC over the request body + +// timestamp, see utils/delegation.ts). The minted token carries only ['read'] +// and is flagged `delegated` so the realm accepts it read-only regardless of +// the user's broader permissions; it can never read anything the user +// couldn't, and can never write. +export default function handleDelegateSession({ + dbAdapter, + matrixClient, + realmSecretSeed, + serverURL, + aiBotDelegationSecret, +}: CreateRoutesArgs): (ctxt: Koa.Context, next: Koa.Next) => Promise { + return async function (ctxt: Koa.Context, _next: Koa.Next) { + if (!aiBotDelegationSecret) { + // No shared secret configured: the delegation endpoint is disabled. + await sendResponseForError( + ctxt, + 503, + 'Service Unavailable', + 'Delegation endpoint is not configured', + ); + return; + } + + let request = await fetchRequestFromContext(ctxt); + let rawBody = await request.text(); + + let auth = verifyDelegationRequest({ + secret: aiBotDelegationSecret, + timestamp: headerValue(ctxt.req.headers, DELEGATION_TIMESTAMP_HEADER), + signature: headerValue(ctxt.req.headers, DELEGATION_SIGNATURE_HEADER), + rawBody, + now: Date.now(), + }); + if (!auth.ok) { + log.warn(`delegation request rejected: ${auth.reason}`); + await sendResponseForUnauthorizedRequest(ctxt, auth.reason); + return; + } + + let json: Record; + try { + json = JSON.parse(rawBody); + } catch { + await sendResponseForBadRequest(ctxt, 'Request body is not valid JSON'); + return; + } + + let onBehalfOf = json?.onBehalfOf; + if (typeof onBehalfOf !== 'string' || onBehalfOf.length === 0) { + await sendResponseForBadRequest( + ctxt, + 'Request body must include a non-empty "onBehalfOf" string', + ); + return; + } + let realm = json?.realm; + if (typeof realm !== 'string' || realm.length === 0) { + await sendResponseForBadRequest( + ctxt, + 'Request body must include a non-empty "realm" string', + ); + return; + } + + let realmURL: URL; + try { + realmURL = new URL(realm); + } catch { + await sendResponseForBadRequest( + ctxt, + `"realm" is not a valid URL: ${realm}`, + ); + return; + } + // Normalise to the canonical realm-root form so the permission lookup hits + // the same realm_user_permissions row a write through other endpoints + // produced (they key by the trailing-slash href; see + // handle-upsert-realm-user-permission). + realmURL.search = ''; + realmURL.hash = ''; + let normalizedRealmHref = ensureTrailingSlash(realmURL.href); + + // Forensic audit record (security design CS-11551): every delegation + // request is logged with a correlation id, requester, and outcome. + let auditId = randomUUID(); + + // Mirror the realm's own authorizer (RealmPermissionChecker): the user can + // read if an exact permission row, the public `*` grant, or the `users` + // grant (any user with a Matrix profile) gives them read. Using the raw + // realm_user_permissions rows alone would 403 a user who can really read + // via a `users` grant, diverging from what the realm would accept. + let realmPermissions = await fetchRealmPermissions( + dbAdapter, + new URL(normalizedRealmHref), + ); + let permissionChecker = new RealmPermissionChecker( + realmPermissions, + matrixClient, + ); + if (!(await permissionChecker.can(onBehalfOf, 'read'))) { + log.warn( + `[delegate-session ${auditId}] denied: user ${onBehalfOf} has no read access to ${normalizedRealmHref}`, + ); + await sendResponseForError( + ctxt, + 403, + 'Forbidden', + `User ${onBehalfOf} has no read access to ${normalizedRealmHref}`, + ); + return; + } + + let token = createJWT( + { + user: onBehalfOf, + realm: normalizedRealmHref, + permissions: ['read'], + sessionRoom: undefined, + realmServerURL: serverURL, + delegated: true, + }, + DELEGATED_TOKEN_TTL, + realmSecretSeed, + ); + + log.info( + `[delegate-session ${auditId}] granted: read-only session for ${onBehalfOf} on ${normalizedRealmHref}`, + ); + + await setContextResponse( + ctxt, + new Response( + JSON.stringify( + { + token, + realm: normalizedRealmHref, + permissions: ['read'], + }, + null, + 2, + ), + { headers: { 'content-type': SupportedMimeType.JSON } }, + ), + ); + }; +} diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index b8db76c556..78d8ddd97b 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -112,6 +112,12 @@ if (!REALM_SERVER_MATRIX_USERNAME) { const MATRIX_REGISTRATION_SHARED_SECRET = process.env.MATRIX_REGISTRATION_SHARED_SECRET; +// Shared secret authenticating ai-bot's delegation requests (CS-11552). +// Optional: when unset, the /_delegate-session endpoint responds 503 and mints +// nothing, so the pull-model feature stays inert until the secret is +// provisioned (and rotated, CS-11567) alongside ai-bot. +const AI_BOT_DELEGATION_SECRET = process.env.AI_BOT_DELEGATION_SECRET; + // Synapse admin credentials. Optional: only consumed by operator-action // endpoints that need to admin-impersonate a target user to read or write // their account_data on their behalf (synapse admin tokens can read but @@ -576,6 +582,7 @@ const smokeTestHostApp = async () => { realmServerSecretSeed: REALM_SERVER_SECRET_SEED, realmSecretSeed: REALM_SECRET_SEED, grafanaSecret: GRAFANA_SECRET, + aiBotDelegationSecret: AI_BOT_DELEGATION_SECRET, dbAdapter, queue, searchCache, diff --git a/packages/realm-server/routes.ts b/packages/realm-server/routes.ts index e319ee8f19..7156de38c8 100644 --- a/packages/realm-server/routes.ts +++ b/packages/realm-server/routes.ts @@ -38,6 +38,7 @@ import handleOpenRouterPassthrough from './handlers/handle-openrouter-passthroug import handlePostDeployment from './handlers/handle-post-deployment.ts'; import { handleCheckBoxelDomainAvailabilityRequest } from './handlers/handle-check-boxel-domain-availability.ts'; import handleRealmAuth from './handlers/handle-realm-auth.ts'; +import handleDelegateSession from './handlers/handle-delegate-session.ts'; import handleGetBoxelClaimedDomainRequest from './handlers/handle-get-boxel-claimed-domain.ts'; import handleClaimBoxelDomainRequest from './handlers/handle-claim-boxel-domain.ts'; import handleDeleteBoxelClaimedDomainRequest from './handlers/handle-delete-boxel-claimed-domain.ts'; @@ -84,6 +85,11 @@ export type CreateRoutesArgs = { realmServerSecretSeed: string; grafanaSecret: string; realmSecretSeed: string; + // Shared secret authenticating ai-bot's delegation requests (CS-11552). + // Optional: when unset, the /_delegate-session endpoint responds 503 rather + // than minting tokens, so the feature stays inert until a secret is + // provisioned. + aiBotDelegationSecret?: string; virtualNetwork: VirtualNetwork; queue: QueuePublisher; realms: Realm[]; @@ -289,6 +295,9 @@ export function createRoutes(args: CreateRoutesArgs) { jwtMiddleware(args.realmSecretSeed), handleRealmAuth(args), ); + // Shared-secret authenticated (HMAC over body + timestamp); auth is handled + // inside the handler because the signature covers the request body. + router.post('/_delegate-session', handleDelegateSession(args)); router.get( '/_check-boxel-domain-availability', jwtMiddleware(args.realmSecretSeed), diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index f2dcb358ba..ce11641512 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -931,6 +931,7 @@ export class RealmServer { private realmServerSecretSeed: string; private realmSecretSeed: string; private grafanaSecret: string; + private aiBotDelegationSecret: string | undefined; private realmsRootPath: string; private dbAdapter: DBAdapter; @@ -967,6 +968,7 @@ export class RealmServer { realmServerSecretSeed, realmSecretSeed, grafanaSecret, + aiBotDelegationSecret, realmsRootPath, dbAdapter, queue, @@ -989,6 +991,7 @@ export class RealmServer { realmServerSecretSeed: string; realmSecretSeed: string; grafanaSecret: string; + aiBotDelegationSecret?: string; realmsRootPath: string; dbAdapter: DBAdapter; queue: QueuePublisher; @@ -1029,6 +1032,7 @@ export class RealmServer { this.matrixClient = matrixClient; this.realmSecretSeed = realmSecretSeed; + this.aiBotDelegationSecret = aiBotDelegationSecret; this.realmServerSecretSeed = realmServerSecretSeed; this.grafanaSecret = grafanaSecret; this.realmsRootPath = realmsRootPath; @@ -1137,6 +1141,7 @@ export class RealmServer { realmServerSecretSeed: this.realmServerSecretSeed, realmSecretSeed: this.realmSecretSeed, grafanaSecret: this.grafanaSecret, + aiBotDelegationSecret: this.aiBotDelegationSecret, virtualNetwork: this.virtualNetwork, serveHostApp, serveIndex, diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index 27bdfca4d2..fd995c6abe 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -217,6 +217,7 @@ export const realmServerTestMatrix: MatrixConfig = { export const realmServerSecretSeed = "mum's the word"; export const realmSecretSeed = `shhh! it's a secret`; export const grafanaSecret = `shhh! it's a secret`; +export const aiBotDelegationSecret = `delegation shared secret for tests`; function getMatrixRegistrationSecret(): string { let secret = @@ -1330,6 +1331,7 @@ export async function runTestRealmServer({ queue: publisher, getIndexHTML, grafanaSecret, + aiBotDelegationSecret, serverURL: new URL(realmURL.origin), assetsURL: new URL(`http://example.com/notional-assets-host/`), domainsForPublishedRealms, @@ -1479,6 +1481,7 @@ export async function runTestRealmServerWithRealms({ queue: publisher, getIndexHTML, grafanaSecret, + aiBotDelegationSecret, serverURL, assetsURL: new URL(`http://example.com/notional-assets-host/`), domainsForPublishedRealms, @@ -2677,6 +2680,7 @@ async function buildBaseRealmTemplate( realmServerSecretSeed, realmSecretSeed, grafanaSecret, + aiBotDelegationSecret, matrixRegistrationSecret, realmsRootPath: dirSync().name, dbAdapter, diff --git a/packages/realm-server/tests/server-endpoints/delegate-session-test.ts b/packages/realm-server/tests/server-endpoints/delegate-session-test.ts new file mode 100644 index 0000000000..040f1d2162 --- /dev/null +++ b/packages/realm-server/tests/server-endpoints/delegate-session-test.ts @@ -0,0 +1,325 @@ +import { module, test } from 'qunit'; +import { basename } from 'path'; +import type { Test, SuperTest } from 'supertest'; +import sinon from 'sinon'; +import jwt from 'jsonwebtoken'; +import { SupportedMimeType, type TokenClaims } from '@cardstack/runtime-common'; +import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; +import type { PgAdapter } from '@cardstack/postgres'; +import { + aiBotDelegationSecret, + realmSecretSeed, + setupPermissionedRealmCached, + testRealmHref, + testRealmURL, +} from '../helpers/index.ts'; +import { + DELEGATION_SIGNATURE_HEADER, + DELEGATION_TIMESTAMP_HEADER, + delegationSignature, +} from '../../utils/delegation.ts'; + +const onBehalfOf = '@jane:localhost'; +// A user with no permission rows on the test realm. +const stranger = '@stranger:localhost'; + +function signedPost( + request: SuperTest, + rawBody: string, + opts: { + timestamp?: number; + secret?: string; + signature?: string; + omitTimestamp?: boolean; + omitSignature?: boolean; + } = {}, +) { + let timestamp = String(opts.timestamp ?? Date.now()); + let signature = + opts.signature ?? + delegationSignature( + opts.secret ?? aiBotDelegationSecret, + timestamp, + rawBody, + ); + let req = request + .post('/_delegate-session') + .set('Content-Type', 'application/json'); + if (!opts.omitTimestamp) { + req = req.set(DELEGATION_TIMESTAMP_HEADER, timestamp); + } + if (!opts.omitSignature) { + req = req.set(DELEGATION_SIGNATURE_HEADER, signature); + } + return req.send(rawBody); +} + +module(`server-endpoints/${basename(__filename)}`, function () { + module('POST /_delegate-session', function (hooks) { + let request: SuperTest; + + setupPermissionedRealmCached(hooks, { + fixture: 'realistic', + // Deliberately no '*' read grant: reads on this realm require a valid + // JWT, so the delegated token's realm-side acceptance is actually + // exercised. `onBehalfOf` is realm-owner — broader than read — which is + // exactly the case the exact-permissions-match invariant would reject + // without the delegated-token handling in realm.ts. + permissions: { + [onBehalfOf]: ['read', 'write', 'realm-owner'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + realmURL: testRealmURL, + onRealmSetup: (args: { + request: SuperTest; + dbAdapter: PgAdapter; + }) => { + request = args.request; + }, + }); + + test('mints a read-only delegated token scoped to the user and realm', async function (assert) { + let response = await signedPost( + request, + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + ); + + assert.strictEqual(response.status, 200, 'HTTP 200'); + assert.strictEqual( + response.body.realm, + testRealmHref, + 'response echoes the normalized realm URL', + ); + assert.deepEqual( + response.body.permissions, + ['read'], + 'response reports read-only permissions', + ); + + let claims = jwt.verify( + response.body.token, + realmSecretSeed, + ) as TokenClaims & { + iat: number; + exp: number; + }; + assert.strictEqual(claims.user, onBehalfOf, 'token is bound to the user'); + assert.strictEqual( + claims.realm, + testRealmHref, + 'token is scoped to the realm', + ); + assert.deepEqual(claims.permissions, ['read'], 'token carries only read'); + assert.true(claims.delegated, 'token is flagged delegated'); + assert.strictEqual( + claims.exp - claims.iat, + 30 * 60, + 'token lives for 30 minutes', + ); + }); + + test('minted token authorizes a realm read even though the user is realm-owner', async function (assert) { + let mint = await signedPost( + request, + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + ); + assert.strictEqual(mint.status, 200, 'token minted'); + + let read = await request + .get('/friend.gts') + .set('Accept', SupportedMimeType.CardSource) + .set('Authorization', `Bearer ${mint.body.token}`); + assert.strictEqual( + read.status, + 200, + 'delegated token can read realm source', + ); + }); + + test('minted token cannot write to the realm', async function (assert) { + let mint = await signedPost( + request, + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + ); + assert.strictEqual(mint.status, 200, 'token minted'); + + let write = await request + .post('/') + .set('Accept', SupportedMimeType.CardJson) + .set('Content-Type', SupportedMimeType.CardJson) + .set('Authorization', `Bearer ${mint.body.token}`) + .send( + JSON.stringify({ + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }), + ); + assert.strictEqual( + write.status, + 403, + 'delegated token is rejected for write (read-only)', + ); + }); + + test('a delegated token scoped to another realm is rejected (single-realm scope)', async function (assert) { + // A well-formed delegated token (validly signed with the realm-server + // seed) but minted for a different realm must not be accepted here, even + // though the bound user has read on this realm. + let foreignToken = jwt.sign( + { + user: onBehalfOf, + realm: 'http://some-other-realm.example/', + permissions: ['read'], + realmServerURL: testRealmURL.href, + delegated: true, + }, + realmSecretSeed, + { expiresIn: '30m' }, + ); + + let read = await request + .get('/friend.gts') + .set('Accept', SupportedMimeType.CardSource) + .set('Authorization', `Bearer ${foreignToken}`); + assert.strictEqual( + read.status, + 401, + 'token minted for another realm is rejected', + ); + }); + + test('denies a user with no read access to the realm', async function (assert) { + let response = await signedPost( + request, + JSON.stringify({ onBehalfOf: stranger, realm: testRealmHref }), + ); + assert.strictEqual(response.status, 403, 'HTTP 403'); + }); + + test('rejects a request with no signature or timestamp', async function (assert) { + let response = await signedPost( + request, + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + { omitSignature: true, omitTimestamp: true }, + ); + assert.strictEqual(response.status, 401, 'HTTP 401'); + }); + + test('rejects a request with an invalid signature', async function (assert) { + let response = await signedPost( + request, + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + { signature: 'deadbeef'.repeat(8) }, + ); + assert.strictEqual(response.status, 401, 'HTTP 401'); + }); + + test('rejects a request signed with the wrong secret', async function (assert) { + let response = await signedPost( + request, + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + { secret: 'not-the-shared-secret' }, + ); + assert.strictEqual(response.status, 401, 'HTTP 401'); + }); + + test('rejects a stale timestamp outside the ±60s window', async function (assert) { + let response = await signedPost( + request, + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + { timestamp: Date.now() - 61_000 }, + ); + assert.strictEqual(response.status, 401, 'HTTP 401'); + }); + + test('rejects a timestamp too far in the future', async function (assert) { + let response = await signedPost( + request, + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + { timestamp: Date.now() + 61_000 }, + ); + assert.strictEqual(response.status, 401, 'HTTP 401'); + }); + + test('rejects a body that is not valid JSON (signature still required)', async function (assert) { + let response = await signedPost(request, 'this is not json'); + assert.strictEqual(response.status, 400, 'HTTP 400'); + }); + + test('rejects a body missing onBehalfOf', async function (assert) { + let response = await signedPost( + request, + JSON.stringify({ realm: testRealmHref }), + ); + assert.strictEqual(response.status, 400, 'HTTP 400'); + }); + + test('rejects a body missing realm', async function (assert) { + let response = await signedPost(request, JSON.stringify({ onBehalfOf })); + assert.strictEqual(response.status, 400, 'HTTP 400'); + }); + }); + + // A realm whose read access comes from the `users` grant (any Matrix user + // with a profile) rather than an exact per-user row. The endpoint must mint + // for such a user, matching what the realm authorizer would accept. + module('POST /_delegate-session — users grant', function (hooks) { + let request: SuperTest; + const karl = '@karl:localhost'; + + setupPermissionedRealmCached(hooks, { + fixture: 'realistic', + permissions: { + users: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + realmURL: testRealmURL, + onRealmSetup: (args: { + request: SuperTest; + dbAdapter: PgAdapter; + }) => { + request = args.request; + }, + }); + + hooks.afterEach(function () { + sinon.restore(); + }); + + test('mints for a user who can read via a `users` grant (no exact row)', async function (assert) { + sinon + .stub(MatrixClient.prototype, 'getProfile') + .resolves({ displayname: 'Karl' }); + + let response = await signedPost( + request, + JSON.stringify({ onBehalfOf: karl, realm: testRealmHref }), + ); + assert.strictEqual(response.status, 200, 'HTTP 200'); + let claims = jwt.verify( + response.body.token, + realmSecretSeed, + ) as TokenClaims; + assert.strictEqual(claims.user, karl, 'token is bound to the user'); + assert.deepEqual(claims.permissions, ['read'], 'token carries only read'); + }); + + test('denies a `users`-grant realm when the user has no Matrix profile', async function (assert) { + sinon.stub(MatrixClient.prototype, 'getProfile').resolves(undefined); + + let response = await signedPost( + request, + JSON.stringify({ onBehalfOf: karl, realm: testRealmHref }), + ); + assert.strictEqual(response.status, 403, 'HTTP 403'); + }); + }); +}); diff --git a/packages/realm-server/utils/delegation.ts b/packages/realm-server/utils/delegation.ts new file mode 100644 index 0000000000..dff76d13c8 --- /dev/null +++ b/packages/realm-server/utils/delegation.ts @@ -0,0 +1,77 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +// Shared-secret authentication for the realm-server /_delegate-session +// endpoint (security design CS-11551). ai-bot and the realm server hold a +// shared secret; ai-bot signs each delegation request with it. The secret +// itself never crosses the wire — only an HMAC over the request — so TLS plus +// the timestamp window below give meaningful replay protection, and secret +// rotation (CS-11567) remains the defense against the secret leaking from +// configuration. + +export const DELEGATION_TIMESTAMP_HEADER = 'x-boxel-delegation-timestamp'; +export const DELEGATION_SIGNATURE_HEADER = 'x-boxel-delegation-signature'; + +// ±60s window on the request timestamp. Cheap and stateless — it bounds the +// replay window for a captured request without a server-side nonce store. +export const DELEGATION_TIMESTAMP_WINDOW_MS = 60_000; + +// The canonical string both sides sign: `${timestamp}.${rawBody}`. `timestamp` +// is epoch milliseconds in base-10; `rawBody` is the exact request body bytes. +// HMAC-SHA256 with the shared secret, hex digest. Binding the timestamp into +// the signed payload is what makes the ±60s window enforceable — a captured +// request cannot have its timestamp rewritten without the secret. +export function delegationSignature( + secret: string, + timestamp: string, + rawBody: string, +): string { + return createHmac('sha256', secret) + .update(`${timestamp}.${rawBody}`) + .digest('hex'); +} + +export type DelegationAuthResult = { ok: true } | { ok: false; reason: string }; + +export function verifyDelegationRequest({ + secret, + timestamp, + signature, + rawBody, + now, +}: { + secret: string; + timestamp: string | undefined; + signature: string | undefined; + rawBody: string; + now: number; +}): DelegationAuthResult { + if (!timestamp || !signature) { + return { + ok: false, + reason: 'missing delegation timestamp or signature header', + }; + } + let ts = Number(timestamp); + if (!Number.isFinite(ts)) { + return { ok: false, reason: 'malformed delegation timestamp' }; + } + if (Math.abs(now - ts) > DELEGATION_TIMESTAMP_WINDOW_MS) { + return { + ok: false, + reason: 'delegation timestamp is outside the allowed window', + }; + } + let expected = delegationSignature(secret, timestamp, rawBody); + let expectedBuf = Buffer.from(expected, 'utf8'); + let providedBuf = Buffer.from(signature, 'utf8'); + // Constant-time compare. timingSafeEqual throws on a length mismatch, so + // gate on length first — both are hex SHA-256 digests (64 chars) when + // well-formed, and the length itself is not secret. + if ( + expectedBuf.length !== providedBuf.length || + !timingSafeEqual(expectedBuf, providedBuf) + ) { + return { ok: false, reason: 'invalid delegation signature' }; + } + return { ok: true }; +} diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 781114275a..027b327cdc 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -579,6 +579,12 @@ export interface TokenClaims { sessionRoom: string | undefined; // TODO: remove when we create users on demand in ensureSessionRoom permissions: RealmPermissions['user']; realmServerURL: string; + // Set on tokens minted by the realm-server's /_delegate-session endpoint + // (CS-11552): a read-only session ai-bot uses to read a realm on behalf of + // a user. Unlike a normal session token, a delegated token carries only + // ['read'] even when the bound user has broader permissions, so request + // authorization treats it specially (read-only, no exact-permissions match). + delegated?: boolean; } export interface AdapterWriteResult { @@ -3697,6 +3703,48 @@ export class Realm { ); let user = token.user; + + // Delegated read-only session (minted by the realm-server's + // /_delegate-session endpoint for ai-bot — CS-11552). It is bound to a + // single user and deliberately scoped to ['read'] even when that user + // has broader permissions, so neither the exact-permissions-match + // invariant used for normal sessions below nor the assume-user + // indirection applies. Enforce instead the two guarantees the delegation + // design promises: the session is read-only, and it grants no more than + // the bound user can already read. + if (token.delegated) { + // Single-realm scope. Delegated tokens are signed with the realm-server + // seed shared across every realm on this server and this branch skips + // the normal exact-permissions match, so without this check a token + // minted for realm A could be replayed against realm B whenever the + // bound user also has read on B. Bind the token to the realm it names. + if ( + ensureTrailingSlash(token.realm) !== ensureTrailingSlash(this.url) + ) { + this.#log.warn( + `auth failed for ${request.method} ${request.url} (accept: ${request.headers.get('accept')}), delegated session for user ${user} is scoped to realm ${token.realm}, not ${this.url}`, + ); + throw new AuthenticationError( + AuthenticationErrorMessages.TokenInvalid, + ); + } + if (requiredPermission !== 'read') { + this.#log.warn( + `auth failed for ${request.method} ${request.url} (accept: ${request.headers.get('accept')}), delegated session for user ${user} attempted ${requiredPermission}; delegated sessions are read-only`, + ); + throw new AuthorizationError('Delegated sessions are read-only'); + } + if (!(await realmPermissionChecker.can(user, 'read'))) { + this.#log.warn( + `auth failed for ${request.method} ${request.url} (accept: ${request.headers.get('accept')}), delegated session for user ${user} but user lacks read permission`, + ); + throw new AuthenticationError( + AuthenticationErrorMessages.PermissionMismatch, + ); + } + return; + } + let assumedUser = request.headers.get('X-Boxel-Assume-User'); let didAssumeUser = false; if (