diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index 64df617818..1ac5ea02dd 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -278,6 +278,7 @@ const ALL_TEST_FILES: string[] = [ './module-cache-invalidation-listener-test', './pg-adapter-subscribe-test', './module-cache-coordination-test', + './realm-endpoints/archived-seal-test', './realm-endpoints/directory-test', './realm-endpoints/indexing-errors-test', './realm-endpoints/info-test', diff --git a/packages/realm-server/tests/realm-endpoints/archived-seal-test.ts b/packages/realm-server/tests/realm-endpoints/archived-seal-test.ts new file mode 100644 index 0000000000..6f8a9db43a --- /dev/null +++ b/packages/realm-server/tests/realm-endpoints/archived-seal-test.ts @@ -0,0 +1,268 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import type { Test, SuperTest, Response } from 'supertest'; +import { basename } from 'path'; +import type { Realm } from '@cardstack/runtime-common'; +import { archiveRealm, unarchiveRealm } from '@cardstack/runtime-common'; +import { + setupPermissionedRealmCached, + testRealmHref, + testRealmURLFor, + createJWT, +} from '../helpers/index.ts'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +import type { PgAdapter } from '@cardstack/postgres'; + +// An archived realm is sealed for everyone (owner included): every content +// request to its boundary returns 403 with an "archived" marker, while the +// operational `_readiness-check` stays reachable and unarchiving lifts the +// seal. +module(`realm-endpoints/${basename(import.meta.filename)}`, function () { + module('archived realm seal', function (hooks) { + let testRealm: Realm; + let request: SuperTest; + let dbAdapter: PgAdapter; + + setupPermissionedRealmCached(hooks, { + fixture: 'blank', + permissions: { + owner: ['read', 'write', 'realm-owner'], + member: ['read'], + '*': ['read'], + }, + onRealmSetup(args: { + testRealm: Realm; + request: SuperTest; + dbAdapter: PgAdapter; + }) { + testRealm = args.testRealm; + request = args.request; + dbAdapter = args.dbAdapter; + }, + }); + + function ownerJWT() { + return `Bearer ${createJWT(testRealm, 'owner', [ + 'read', + 'write', + 'realm-owner', + ])}`; + } + + function memberJWT() { + return `Bearer ${createJWT(testRealm, 'member', ['read'])}`; + } + + function assertArchived403( + assert: Assert, + response: Response, + label: string, + ) { + assert.strictEqual(response.status, 403, `${label}: HTTP 403`); + assert.strictEqual( + response.get('X-Boxel-Realm-Archived'), + 'true', + `${label}: carries the X-Boxel-Realm-Archived marker`, + ); + assert.strictEqual( + (response.body as any)?.errors?.[0]?.code, + 'archived', + `${label}: JSON:API error code is "archived"`, + ); + } + + test('content reads and writes are sealed for everyone while archived; readiness stays open; unarchive lifts the seal', async function (assert) { + // Baseline: active realm serves content. + let active = await request + .get('/_info') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', ownerJWT()); + assert.strictEqual(active.status, 200, 'active realm serves /_info'); + + // Baseline for the header-less readiness probe on an active realm, so we + // can assert below that archiving doesn't change how it's handled. + let activeReadinessNoAccept = await request.get('/_readiness-check'); + + await archiveRealm(dbAdapter, new URL(testRealmHref)); + + // Reads are sealed for the owner, an authenticated non-owner, and the + // anonymous public-read caller alike. + assertArchived403( + assert, + await request + .get('/_info') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', ownerJWT()), + 'owner read', + ); + assertArchived403( + assert, + await request + .get('/_info') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', memberJWT()), + 'non-owner read', + ); + assertArchived403( + assert, + await request.get('/_info').set('Accept', 'application/vnd.api+json'), + 'anonymous (public-read) read', + ); + + // Writes are sealed too — the seal short-circuits before card creation, + // so even the owner's write is refused with the archived marker. + assertArchived403( + assert, + await request + .post('/') + .set('Accept', 'application/vnd.card+json') + .set('Authorization', ownerJWT()) + .send({ + data: { + attributes: { firstName: 'Mango' }, + meta: { adoptsFrom: { module: '../person.gts', name: 'Person' } }, + }, + }), + 'owner write', + ); + + // The operational readiness probe is exempt so health checks don't read + // an archived realm as down. + let readiness = await request + .get('/_readiness-check') + .set('Accept', 'application/vnd.api+json'); + assert.strictEqual( + readiness.status, + 200, + '_readiness-check stays reachable while archived', + ); + + // The exemption is path-based, not header-based: a bare health probe + // that sends no `Accept` header is never sealed — it's handled exactly + // as on an active realm, with no archived marker. (The router itself + // gates `_readiness-check` on the `Accept` header, so a header-less probe + // doesn't reach the handler on either an active or archived realm; the + // point here is that the seal doesn't single it out.) + let readinessNoAccept = await request.get('/_readiness-check'); + assert.strictEqual( + readinessNoAccept.status, + activeReadinessNoAccept.status, + '_readiness-check with no Accept header is handled the same whether archived or active', + ); + assert.strictEqual( + readinessNoAccept.get('X-Boxel-Realm-Archived'), + undefined, + '_readiness-check with no Accept header is not given the archived seal', + ); + + // Unarchiving lifts the seal; the active realm is unaffected. + await unarchiveRealm(dbAdapter, new URL(testRealmHref)); + let restored = await request + .get('/_info') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', ownerJWT()); + assert.strictEqual( + restored.status, + 200, + 'content is served again after unarchive', + ); + }); + }); + + // The seal must not leak a private realm's existence or archived state to + // callers who can't prove access: the archived response is reserved for + // callers who would otherwise reach the content. A caller who fails + // authentication or authorization gets the same 401/403 they would on an + // active private realm. + module( + 'archived seal does not disclose to unauthorized callers', + function (hooks) { + let testRealm: Realm; + let request: SuperTest; + let dbAdapter: PgAdapter; + + setupPermissionedRealmCached(hooks, { + fixture: 'blank', + // A private realm: no `*` permission. + realmURL: testRealmURLFor('private-archived/'), + permissions: { + owner: ['read', 'write', 'realm-owner'], + }, + onRealmSetup(args: { + testRealm: Realm; + request: SuperTest; + dbAdapter: PgAdapter; + }) { + testRealm = args.testRealm; + request = args.request; + dbAdapter = args.dbAdapter; + }, + }); + + // Address content under the realm's own path prefix (the realm is mounted + // at `/private-archived/`, not the server root). + function path(suffix: string) { + return `${new URL(testRealm.url).pathname.replace(/\/$/, '')}${suffix}`; + } + + test('a private archived realm returns the normal 401/403 to callers who cannot prove access, and the archived marker only to authorized callers', async function (assert) { + await archiveRealm(dbAdapter, new URL(testRealm.url)); + + // Unauthenticated: the normal missing-auth 401, with no hint that the + // realm exists or is archived. + let anonymous = await request + .get(path('/_info')) + .set('Accept', 'application/vnd.api+json'); + assert.strictEqual( + anonymous.status, + 401, + 'unauthenticated caller gets 401', + ); + assert.notStrictEqual( + anonymous.get('X-Boxel-Realm-Archived'), + 'true', + 'unauthenticated caller is not told the realm is archived', + ); + + // Authenticated but holding no permission on this realm: the normal 403 + // authorization failure, still with no archived disclosure. + let stranger = await request + .get(path('/_info')) + .set('Accept', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, 'stranger', [])}`, + ); + assert.strictEqual( + stranger.status, + 403, + 'unauthorized caller gets 403', + ); + assert.notStrictEqual( + stranger.get('X-Boxel-Realm-Archived'), + 'true', + 'unauthorized caller is not told the realm is archived', + ); + + // The owner could otherwise reach the content, so they see the seal. + let owner = await request + .get(path('/_info')) + .set('Accept', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, 'owner', [ + 'read', + 'write', + 'realm-owner', + ])}`, + ); + assert.strictEqual(owner.status, 403, 'authorized owner gets 403'); + assert.strictEqual( + owner.get('X-Boxel-Realm-Archived'), + 'true', + 'authorized owner sees the archived marker', + ); + }); + }, + ); +}); diff --git a/packages/runtime-common/create-response.ts b/packages/runtime-common/create-response.ts index 14d123f70d..421376cef9 100644 --- a/packages/runtime-common/create-response.ts +++ b/packages/runtime-common/create-response.ts @@ -21,7 +21,7 @@ export function createResponse({ }), vary: 'Accept', 'Access-Control-Expose-Headers': - 'X-Boxel-Realm-Url,X-Boxel-Realm-Public-Readable,X-Boxel-Canonical-Path,Authorization,Cache-Control,ETag', + 'X-Boxel-Realm-Url,X-Boxel-Realm-Public-Readable,X-Boxel-Realm-Archived,X-Boxel-Canonical-Path,Authorization,Cache-Control,ETag', }, }); } diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 2dc096b990..ed9d6e640d 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -60,6 +60,7 @@ import { isNode, logger, fetchRealmPermissions, + isRealmArchived, baseRealm, maybeURL, insertPermissions, @@ -112,6 +113,7 @@ import { import { transpileJS } from './transpile.ts'; import type { Method, RouteTable } from './router.ts'; import { + ArchivedRealmError, AuthenticationError, AuthenticationErrorMessages, AuthorizationError, @@ -251,6 +253,13 @@ const CACHE_MISS_VALUE = 'miss'; // a second transpile before the winner finishes. const MODULE_TRANSPILE_CACHE_TABLE = 'module_transpile_cache'; const COALESCE_NOTIFY_WAIT_MS = 180_000; +// `localPath`s (no leading slash) exempt from the archived-realm seal: the +// realm's public operational endpoints, which must keep working while a realm +// is archived. `_readiness-check` is the health probe; `_session` is the +// authentication endpoint. Matched on path rather than `Accept`/`Content-Type` +// so the exemption holds for header-less probes too. Keep in sync with +// `#publicEndpoints`. +const ARCHIVED_SEAL_EXEMPT_PATHS = new Set(['_readiness-check', '_session']); const MODULE_ETAG_VARIANT = 'module'; const SOURCE_ETAG_VARIANT = 'source'; // Card+JSON ETag is `"-:card"` — quoted @@ -2767,6 +2776,32 @@ export class Realm { try { if (!isLocal) { await this.checkPermission(request, requestContext, requiredPermission); + // An archived realm is sealed for everyone, owner included: once a + // caller is authorized, every external content request is + // short-circuited with 403 (archived). The seal runs AFTER + // checkPermission so an unauthenticated or unauthorized caller to a + // private realm gets the normal 401/403 and never learns the realm + // exists or is archived — only callers who could otherwise reach the + // content see the sealed response. A public realm's readers are + // authorized by checkPermission, so they do see the seal (the realm's + // existence is already public). The seal is method-agnostic, so reads + // and writes are blocked by this one check. The realm's public + // operational endpoints stay reachable while archived: the + // `_readiness-check` health probe (so health checks don't read an + // archived realm as down) and `_session` (so authentication still + // works). They're matched on `localPath`, independent of request + // headers, so a bare health probe that sends no `Accept` header is + // still exempt. The archive-management endpoints live on the realm + // SERVER router and never reach this boundary, so they stay reachable. + // Read fresh (no memoization) for the same reason createRequestContext + // does: a peer replica's archive/unarchive must take effect here + // without a restart. + if ( + !ARCHIVED_SEAL_EXEMPT_PATHS.has(localPath) && + (await isRealmArchived(this.#dbAdapter, new URL(this.url))) + ) { + throw new ArchivedRealmError(`Realm ${this.url} is archived`); + } } if (!this.#realmIndexQueryEngine) { return systemError({ @@ -2793,6 +2828,34 @@ export class Realm { }); } + if (e instanceof ArchivedRealmError) { + // 403 (not 404) carrying an "archived" marker — both a dedicated + // header and a JSON:API error with a stable `code` — so the client can + // distinguish a sealed realm from a generic forbidden response and + // render the right message. + return createResponse({ + body: JSON.stringify({ + errors: [ + { + status: '403', + code: 'archived', + title: 'Realm Archived', + detail: e.message, + }, + ], + }), + init: { + status: 403, + headers: { + 'content-type': SupportedMimeType.JSONAPI, + 'X-Boxel-Realm-Archived': 'true', + 'X-Boxel-Realm-Url': requestContext.realm.url, + }, + }, + requestContext, + }); + } + if (e instanceof AuthorizationError) { return new Response(`${e.message}`, { status: 403, diff --git a/packages/runtime-common/router.ts b/packages/runtime-common/router.ts index 07c0e18c7b..4ba7800f69 100644 --- a/packages/runtime-common/router.ts +++ b/packages/runtime-common/router.ts @@ -4,6 +4,10 @@ import { RealmPaths, logger } from './index.ts'; export class AuthenticationError extends Error {} export class AuthorizationError extends Error {} +// Thrown at the realm request boundary when a request targets an archived +// (sealed) realm. Surfaced as a 403 carrying an "archived" marker so the +// client can render the sealed state rather than a generic forbidden error. +export class ArchivedRealmError extends Error {} // A `const` object (rather than a TS `enum`) so the declaration is // erasable and runs under Node's native `--experimental-strip-types`. export const AuthenticationErrorMessages = {