Skip to content
Draft
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
187 changes: 187 additions & 0 deletions packages/realm-server/handlers/handle-delegate-session.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, unknown>;
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 } },
),
);
};
}
7 changes: 7 additions & 0 deletions packages/realm-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions packages/realm-server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions packages/realm-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -967,6 +968,7 @@ export class RealmServer {
realmServerSecretSeed,
realmSecretSeed,
grafanaSecret,
aiBotDelegationSecret,
realmsRootPath,
dbAdapter,
queue,
Expand All @@ -989,6 +991,7 @@ export class RealmServer {
realmServerSecretSeed: string;
realmSecretSeed: string;
grafanaSecret: string;
aiBotDelegationSecret?: string;
realmsRootPath: string;
dbAdapter: DBAdapter;
queue: QueuePublisher;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1137,6 +1141,7 @@ export class RealmServer {
realmServerSecretSeed: this.realmServerSecretSeed,
realmSecretSeed: this.realmSecretSeed,
grafanaSecret: this.grafanaSecret,
aiBotDelegationSecret: this.aiBotDelegationSecret,
virtualNetwork: this.virtualNetwork,
serveHostApp,
serveIndex,
Expand Down
4 changes: 4 additions & 0 deletions packages/realm-server/tests/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1479,6 +1481,7 @@ export async function runTestRealmServerWithRealms({
queue: publisher,
getIndexHTML,
grafanaSecret,
aiBotDelegationSecret,
serverURL,
assetsURL: new URL(`http://example.com/notional-assets-host/`),
domainsForPublishedRealms,
Expand Down Expand Up @@ -2677,6 +2680,7 @@ async function buildBaseRealmTemplate(
realmServerSecretSeed,
realmSecretSeed,
grafanaSecret,
aiBotDelegationSecret,
matrixRegistrationSecret,
realmsRootPath: dirSync().name,
dbAdapter,
Expand Down
Loading
Loading