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
1 change: 1 addition & 0 deletions packages/realm-server/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
268 changes: 268 additions & 0 deletions packages/realm-server/tests/realm-endpoints/archived-seal-test.ts
Original file line number Diff line number Diff line change
@@ -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<Test>;
let dbAdapter: PgAdapter;

setupPermissionedRealmCached(hooks, {
fixture: 'blank',
permissions: {
owner: ['read', 'write', 'realm-owner'],
member: ['read'],
'*': ['read'],
},
onRealmSetup(args: {
testRealm: Realm;
request: SuperTest<Test>;
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<Test>;
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<Test>;
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',
);
});
},
);
});
2 changes: 1 addition & 1 deletion packages/runtime-common/create-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
});
}
63 changes: 63 additions & 0 deletions packages/runtime-common/realm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
isNode,
logger,
fetchRealmPermissions,
isRealmArchived,
baseRealm,
maybeURL,
insertPermissions,
Expand Down Expand Up @@ -112,6 +113,7 @@ import {
import { transpileJS } from './transpile.ts';
import type { Method, RouteTable } from './router.ts';
import {
ArchivedRealmError,
AuthenticationError,
AuthenticationErrorMessages,
AuthorizationError,
Expand Down Expand Up @@ -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 `"<indexed_at>-<realmInfoHash>:card"` — quoted
Expand Down Expand Up @@ -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
Comment thread
lukemelia marked this conversation as resolved.
// 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({
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime-common/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading