From 69c0d3792223b480b47d40fd6e70a9716e62b83a Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 5 Oct 2026 12:31:00 -0400 Subject: [PATCH 1/5] WIP compile anonymous grants --- .../runtime-common/card-operations/policy.ts | 99 ++++++++++++++++++- .../runtime-common/card-operations/types.ts | 27 ++++- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/packages/runtime-common/card-operations/policy.ts b/packages/runtime-common/card-operations/policy.ts index df5b101077a..0cd91feb246 100644 --- a/packages/runtime-common/card-operations/policy.ts +++ b/packages/runtime-common/card-operations/policy.ts @@ -27,7 +27,9 @@ import { type ReadableType, } from './policy-reach.ts'; import { + ANONYMOUS_ELIGIBLE_OPERATIONS, isDefinitionFreeBaseOperation, + isWrite, linkStrategyOf, readLinkStrategyOf, policyIssueSeverity, @@ -78,6 +80,10 @@ export interface CompiledRealmPolicy { // Set on a draft compiled for an explain (see `compileDraftPolicy`), which // no realm holds in force. draft?: true; + // The operations some live grant opens to callers who aren't signed in. + // Absent when no grant does, which is what lets a realm answer such a caller + // without judging anything: it reads this, and nothing else of the policy. + anonymous?: { operations: string[] }; } export interface CompiledPolicyRule { @@ -96,6 +102,11 @@ export interface CompiledOperationGrant { path: string; // Absent for a grant with no condition. where?: CompiledPolicyPredicate; + // Set on a grant that also admits callers who aren't signed in. A write + // grant names the key, in the governed realm's `realm.json` settings, whose + // value is the user its writes are made as. The realm resolves it on every + // invocation, so the policy names a setting and never a user. + anonymous?: { actingUserKey?: string }; // For a grant on a query, the search filter the grant admits: the cards of // the rule's type that its predicate holds for, as a wire filter template // whose `{ $ref: 'actor' }` markers a search fills in with the caller. A @@ -141,6 +152,10 @@ export interface CompiledPolicyPredicate { // reads the stored source alone is judged against the stored source, and // pays no index read. snapshot: boolean; + // Set when the predicate calls `actor()`. A caller who isn't signed in has + // no actor, so such a predicate never holds for one, and is not evaluated + // for one either. + readsActor?: true; } // The type every policy card adopts from. It is defined in the catalog realm, @@ -816,7 +831,13 @@ async function compileDocument( let issue = (code: PolicyIssueCode, path: string, message: string) => issues.push({ code, path, message, severity: policyIssueSeverity(code) }); let compiled = (): Omit => ({ - compiled: { card, version: undefined, rules, issues }, + compiled: { + card, + version: undefined, + rules, + issues, + ...anonymousSummary(rules), + }, definitions, inputs, subtypes, @@ -1099,6 +1120,37 @@ async function compileDocument( ); continue; } + let anonymous: CompiledOperationGrant['anonymous']; + if (grant?.anonymous === true) { + if ( + granted.base !== operation || + !ANONYMOUS_ELIGIBLE_OPERATIONS.includes(granted.base) + ) { + issue( + 'anonymous-not-base-operation', + `${grantPath}.anonymous`, + `\`${operation}\` can't be opened to callers who aren't signed in: only ${ANONYMOUS_ELIGIBLE_OPERATIONS.map( + (name) => `\`${name}\``, + ).join( + ', ', + )} can, under their own names, and a custom operation or a named query can't`, + ); + continue; + } + let actingUserKey = + typeof grant.actingUser === 'string' && grant.actingUser.trim() + ? grant.actingUser.trim() + : undefined; + if (isWrite(granted.base) && !actingUserKey) { + issue( + 'anonymous-write-without-acting-user', + `${grantPath}.actingUser`, + `\`${operation}\` writes, so a grant opening it to callers who aren't signed in has to name the setting in the realm's \`realm.json\` whose value is the user those writes are made as, in \`actingUser\``, + ); + continue; + } + anonymous = actingUserKey ? { actingUserKey } : {}; + } let where = readPredicate(grant?.where); if (where === 'malformed') { issue( @@ -1111,7 +1163,7 @@ async function compileDocument( if (!where) { grants.push( await withFilter( - { operation, path: grantPath }, + { operation, path: grantPath, ...(anonymous ? { anonymous } : {}) }, granted.base, undefined, resolved, @@ -1169,7 +1221,9 @@ async function compileDocument( source: where.source, canonical: outcome.canonical, snapshot, + ...(outcome.readsActor ? { readsActor: true as const } : {}), }, + ...(anonymous ? { anonymous } : {}), }, granted.base, { body: outcome.body, snapshot }, @@ -1491,6 +1545,25 @@ function safeURL( return attempt(() => env.toURL(identifier).href); } +// The operations the policy's live grants open to callers who aren't signed +// in, recorded on the compiled policy when there are any. A query grant that +// has no search filter admits no search, so it opens nothing. +function anonymousSummary( + rules: CompiledPolicyRule[], +): Pick { + let operations = new Set(); + for (let rule of rules) { + for (let grant of rule.grants) { + if (grant.anonymous && (grant.operation !== 'query' || grant.filter)) { + operations.add(grant.operation); + } + } + } + return operations.size > 0 + ? { anonymous: { operations: [...operations].sort() } } + : {}; +} + // What `fn` answers, or undefined when it throws. Compiling records a problem // with the policy as an issue rather than throwing it, and a value that cannot // be resolved is a problem with the policy, so the step that needed it counts @@ -1663,7 +1736,9 @@ type PredicateProblem = { async function compilePredicate( source: string, -): Promise<{ canonical: string; body: unknown } | PredicateProblem> { +): Promise< + { canonical: string; body: unknown; readsActor: boolean } | PredicateProblem +> { let bxl = await loadBxl(); let program; try { @@ -1712,7 +1787,23 @@ async function compilePredicate( problem: `\`where\` matches only part of a value, so it could let in someone the grant doesn't mean to: ${calls}. To check whether a list includes the caller, use \`.list | any(. == actor())\`. To compare text, use \`==\`, or \`startswith\` or \`endswith\` with a fixed prefix or suffix`, }; } - return { canonical: program.canonicalSource, body: program.body }; + return { + canonical: program.canonicalSource, + body: program.body, + readsActor: callsActor(bxl, program.body), + }; +} + +// Whether a predicate calls `actor()` anywhere in it. +function callsActor(bxl: BxlPolicyParser, body: unknown): boolean { + let found = false; + bxl.visitBxlAst(body, (node) => { + let { type, name } = node as { type?: unknown; name?: unknown }; + if (type === 'call' && name === 'actor') { + found = true; + } + }); + return found; } // BXL, and the shape of what this module asks of it. diff --git a/packages/runtime-common/card-operations/types.ts b/packages/runtime-common/card-operations/types.ts index 6231cb7d43b..83124269588 100644 --- a/packages/runtime-common/card-operations/types.ts +++ b/packages/runtime-common/card-operations/types.ts @@ -549,7 +549,32 @@ export type PolicyIssueCode = // draws the card's links whatever strategy the grant's document is served // under, so this is recorded independently of // `grant-reaches-ungranted-type`. The grant is kept, for the same reason. - | 'render-reaches-ungranted-type'; + | 'render-reaches-ungranted-type' + // A grant that opts in to admitting callers who aren't signed in on an + // operation that isn't one of `ANONYMOUS_ELIGIBLE_OPERATIONS` as the type + // carries it: a custom operation, a named query, or anything else declared + // under its own name. The grant is left out. + | 'anonymous-not-base-operation' + // A grant that opts in to admitting callers who aren't signed in on a write, + // without naming the `realm.json` setting that says which user the write is + // made as. The grant is left out. + | 'anonymous-write-without-acting-user'; + +// The operations a grant may open to callers who aren't signed in: the +// grantable base operations, invoked under their own names. A custom operation +// or a named query is a contract its author wrote for signed-in callers, and +// stays one. +export const ANONYMOUS_ELIGIBLE_OPERATIONS: readonly BaseOperationName[] = [ + 'read', + 'readSource', + 'query', + 'create', + 'update', + 'delete', + 'transform', + 'appendContainsMany', + 'appendLine', +]; // The codes that leave the part of the policy they are recorded against live. export const KEEPS_ITS_PART: ReadonlySet = new Set([ From 755e88f79c3841e7f1ae9412a5d854bc822441c7 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 5 Oct 2026 12:34:09 -0400 Subject: [PATCH 2/5] Compile a grant's anonymous opt-in --- .../tests/realm-policy-compile-test.ts | 94 ++++++++++++++++++- .../tests/realm-policy-tiers-test.ts | 7 +- .../runtime-common/card-operations/policy.ts | 20 ++++ packages/runtime-common/realm.ts | 8 ++ 4 files changed, 127 insertions(+), 2 deletions(-) diff --git a/packages/realm-server/tests/realm-policy-compile-test.ts b/packages/realm-server/tests/realm-policy-compile-test.ts index 9bc948b84bc..7fccf6926e4 100644 --- a/packages/realm-server/tests/realm-policy-compile-test.ts +++ b/packages/realm-server/tests/realm-policy-compile-test.ts @@ -86,7 +86,12 @@ function classroomModule(rosterField: string) { `; } -type Grant = { operation: string; where?: unknown }; +type Grant = { + operation: string; + where?: unknown; + anonymous?: boolean; + actingUser?: string; +}; type Rule = { targetType: { module: string; name: string }; grants: Grant[] }; const CLASSROOM = { module: `${EDUCATION}classroom`, name: 'Classroom' }; @@ -309,6 +314,7 @@ module(basename(import.meta.filename), function (hooks) { source: '.teacherIds | any(. == actor())', canonical: '.teacherIds | any(. == actor())', snapshot: false, + readsActor: true, }, }, { @@ -318,6 +324,7 @@ module(basename(import.meta.filename), function (hooks) { source: '.headTeacher == actor()', canonical: '.headTeacher == actor()', snapshot: true, + readsActor: true, }, }, { @@ -813,4 +820,89 @@ module(basename(import.meta.filename), function (hooks) { ); assert.notOk(policy?.uncompilable, 'the policy as a whole compiled'); }); + + test('a grant opens a base operation to callers who are not signed in, and a write names who it is made as', async function (assert) { + await writeTo( + org, + 'policies/education.json', + policyCard([ + { operation: 'read', anonymous: true }, + { + operation: 'read', + anonymous: true, + where: '.teacherIds | any(. == actor())', + }, + // A custom operation, built on a base one. + { operation: 'appendActivity', anonymous: true }, + // A write that names nobody to make it as. + { operation: 'transform', anonymous: true }, + { + operation: 'transform', + anonymous: true, + actingUser: 'feedbackWriter', + }, + { operation: 'query', anonymous: true, where: '.status == "open"' }, + // Opened to signed-in callers only. + { operation: 'update' }, + ]), + ); + let policy = await compiled(); + assert.deepEqual( + policy?.issues.map(({ code, path }) => ({ code, path })), + [ + { + code: 'anonymous-not-base-operation', + path: 'rules[0].grants[2].anonymous', + }, + { + code: 'anonymous-write-without-acting-user', + path: 'rules[0].grants[3].actingUser', + }, + ], + 'a custom operation and a write with no acting user are refused, and nothing else', + ); + let grants = policy?.rules[0]?.grants ?? []; + assert.deepEqual( + grants.map(({ path, anonymous }) => ({ path, anonymous })), + [ + { path: 'rules[0].grants[0]', anonymous: {} }, + { path: 'rules[0].grants[1]', anonymous: {} }, + { + path: 'rules[0].grants[4]', + anonymous: { actingUserKey: 'feedbackWriter' }, + }, + { path: 'rules[0].grants[5]', anonymous: {} }, + { path: 'rules[0].grants[6]', anonymous: undefined }, + ], + 'the refused grants are left out, and only the grants that opt in carry the opt-in', + ); + assert.true( + grants[1]?.where?.readsActor, + 'a predicate that reads the caller says so', + ); + assert.notOk( + grants[3]?.where?.readsActor, + 'and one that reads only the card does not', + ); + assert.ok(grants[3]?.filter, 'the anonymous query grant has a filter'); + assert.deepEqual( + policy?.anonymous, + { operations: ['query', 'read', 'transform'] }, + 'the policy records which operations it opens to callers who are not signed in', + ); + assert.deepEqual( + [...(await education.getAnonymousAdmission())].sort(), + ['query', 'read', 'transform'], + 'and the realm reads them from the compiled policy', + ); + + await writeTo(org, 'policies/education.json', policyCard(GRANTS)); + let signedInOnly = await compiled(); + assert.strictEqual( + signedInOnly?.anonymous, + undefined, + 'a policy that opens nothing to callers who are not signed in records nothing', + ); + assert.strictEqual((await education.getAnonymousAdmission()).size, 0); + }); }); diff --git a/packages/realm-server/tests/realm-policy-tiers-test.ts b/packages/realm-server/tests/realm-policy-tiers-test.ts index ec377138cac..569781e0ce9 100644 --- a/packages/realm-server/tests/realm-policy-tiers-test.ts +++ b/packages/realm-server/tests/realm-policy-tiers-test.ts @@ -368,7 +368,12 @@ module(basename(import.meta.filename), function () { assert.deepEqual(issues, [], `${source}: no issue`); assert.deepEqual( grant?.where, - { source, canonical: grant?.where?.canonical, snapshot: true }, + { + source, + canonical: grant?.where?.canonical as string, + snapshot: true, + ...(source.includes('actor()') ? { readsActor: true as const } : {}), + }, `${source}: judged against the snapshot`, ); } diff --git a/packages/runtime-common/card-operations/policy.ts b/packages/runtime-common/card-operations/policy.ts index 0cd91feb246..68b51f4c907 100644 --- a/packages/runtime-common/card-operations/policy.ts +++ b/packages/runtime-common/card-operations/policy.ts @@ -256,6 +256,8 @@ export interface RealmPolicyCacheEnvironment extends PolicyCompileEnvironment { // commit moves the index of the realm holding the card, which reaches the // cache as any move does, and the refresh that follows compiles what the card // holds now. +const NO_OPERATIONS: ReadonlySet = new Set(); + export class RealmPolicyCache { #env: RealmPolicyCacheEnvironment; #current: Compilation | undefined; @@ -305,6 +307,24 @@ export class RealmPolicyCache { return await this.#refresh(card); } + // The operations the realm's policy opens to callers who aren't signed in, + // or none. Read from the compiled policy and nothing else, so answering such + // a caller loads no target and evaluates no predicate. A policy that can't + // be compiled opens nothing, which leaves that caller with the answer every + // other caller who isn't signed in gets, rather than a failure that would + // tell them the realm names a policy. + async anonymousAdmission(): Promise> { + let compiled: CompiledRealmPolicy | undefined; + try { + compiled = await this.get(); + } catch { + return NO_OPERATIONS; + } + return compiled?.uncompilable || !compiled?.anonymous + ? NO_OPERATIONS + : new Set(compiled.anonymous.operations); + } + // The index of the realm at `realmURL` has moved. indexMoved(realmURL: string): void { for (let refresh of this.#inFlight) { diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 8832dcb0318..862816d105c 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -15685,6 +15685,14 @@ export class Realm { return await this.#policyCache.get(); } + // The operations the realm's policy opens to callers who aren't signed in, + // as `RealmPolicyCache.anonymousAdmission()` reads them: empty for a realm + // that names no policy, and for one whose policy opens none or won't + // compile. + async getAnonymousAdmission(): Promise> { + return await this.#policyCache.anonymousAdmission(); + } + __testOnlyPolicyCacheStats(): { compiles: number; revalidations: number; From 973b4cb95eee419aefc5daaada18257c10442f28 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 5 Oct 2026 12:40:42 -0400 Subject: [PATCH 3/5] Pin the catalog test subset to the grant's anonymous opt-in Co-Authored-By: Claude Opus 5.5 --- packages/catalog/test-subset.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index 1ac84efcb03..45a2e004c7b 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -1,6 +1,6 @@ { "repository": "cardstack/boxel-catalog", - "revision": "b014f5a669dfd5a46626f5c7d6156e34070d45e5", + "revision": "14c2ead9c0759fb834623762d426a61c57b9cd6b", "files": [ { "path": "realm-policy/realm-policy.gts", From f87ad865fa82df6067570a7ff839cefdc6049de5 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 5 Oct 2026 12:58:15 -0400 Subject: [PATCH 4/5] Tighten the anonymous opt-in's compile rules A grant whose `where` reads `actor()` opens nothing to anonymous callers and is warned about; only a write records an acting-user key, used exactly as written; a fault reading the policy for an anonymous caller is logged. Tests cover a named query, a query grant with no filter, an uncompilable policy, and the grant view; the subset pin follows the catalog PR head. Co-Authored-By: Claude Opus 5.5 --- packages/catalog/test-subset.json | 2 +- .../tests/integration/realm-policy-test.gts | 31 ++++ .../tests/catalog-test-subset-test.ts | 11 +- .../tests/realm-policy-compile-test.ts | 149 ++++++++++++++---- .../runtime-common/card-operations/policy.ts | 40 +++-- .../runtime-common/card-operations/types.ts | 15 +- 6 files changed, 201 insertions(+), 47 deletions(-) diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index 45a2e004c7b..36245c494c5 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -1,6 +1,6 @@ { "repository": "cardstack/boxel-catalog", - "revision": "14c2ead9c0759fb834623762d426a61c57b9cd6b", + "revision": "79bae3357dbc23d53e89c7e1700ca57daecd7f2c", "files": [ { "path": "realm-policy/realm-policy.gts", diff --git a/packages/host/tests/integration/realm-policy-test.gts b/packages/host/tests/integration/realm-policy-test.gts index 10fabd69814..cf38de4ba6e 100644 --- a/packages/host/tests/integration/realm-policy-test.gts +++ b/packages/host/tests/integration/realm-policy-test.gts @@ -360,6 +360,37 @@ module('Integration | realm policy', function (hooks) { assert.dom('[data-test-policy-predicate-snapshot]').exists({ count: 1 }); }); + test('a grant opened to callers who are not signed in says so, and a write names the setting it acts as', async function (assert) { + await setupPolicyRealm({ + 'policies/public.json': policyDocument([ + { + targetType: { module: '../classroom', name: 'Classroom' }, + grants: [ + { operation: 'read', anonymous: true }, + { + operation: 'update', + anonymous: true, + actingUser: 'feedbackWriter', + }, + { operation: 'delete' }, + ], + }, + ]), + }); + let policy = await loadPolicy('policies/public'); + await renderCard(loader, policy, 'embedded'); + + assert.dom('[data-test-operation-grant]').exists({ count: 3 }); + assert + .dom('[data-test-operation-grant-anonymous]') + .exists({ count: 2 }, 'only the grants that opt in are marked') + .hasText('anyone'); + assert + .dom('[data-test-operation-grant-acting-user]') + .exists({ count: 1 }, 'only the write names who it acts as') + .hasText('config.feedbackWriter'); + }); + test('a policy with no rules says it grants nothing', async function (assert) { await setupPolicyRealm({ 'policies/empty.json': policyDocument([]) }); let policy = await loadPolicy('policies/empty'); diff --git a/packages/realm-server/tests/catalog-test-subset-test.ts b/packages/realm-server/tests/catalog-test-subset-test.ts index e3b1e849e0d..8cce14fa247 100644 --- a/packages/realm-server/tests/catalog-test-subset-test.ts +++ b/packages/realm-server/tests/catalog-test-subset-test.ts @@ -70,15 +70,22 @@ module(basename(import.meta.filename), function (hooks) { )}`, ); let rules = (doc?.type === 'doc' ? doc.doc.data.attributes?.rules : []) as { - grants: { operation: string; where: unknown }[]; + grants: { + operation: string; + where: unknown; + anonymous: unknown; + actingUser: unknown; + }[]; }[]; assert.deepEqual( rules[0]?.grants, [ - { operation: 'read', where: null }, + { operation: 'read', where: null, anonymous: false, actingUser: null }, { operation: 'appendActivity', where: { bxl: 'actor() in .teacherIds', snapshot: true }, + anonymous: false, + actingUser: null, }, ], "the catalog definition's fields, including its serializer-backed predicate, round-trip", diff --git a/packages/realm-server/tests/realm-policy-compile-test.ts b/packages/realm-server/tests/realm-policy-compile-test.ts index 7fccf6926e4..b5d3bd189a4 100644 --- a/packages/realm-server/tests/realm-policy-compile-test.ts +++ b/packages/realm-server/tests/realm-policy-compile-test.ts @@ -76,6 +76,10 @@ function classroomModule(rosterField: string) { set: { status: 'locked' }, nonGrantable: true, }; + @operation static listOpen = { + base: 'query', + query: { filter: { type: () => Classroom } }, + }; } export class OpenClassroom extends Classroom { @operation static lock = { @@ -822,87 +826,172 @@ module(basename(import.meta.filename), function (hooks) { }); test('a grant opens a base operation to callers who are not signed in, and a write names who it is made as', async function (assert) { + const OPEN_CLASSROOM = { + module: `${EDUCATION}classroom`, + name: 'OpenClassroom', + }; await writeTo( org, 'policies/education.json', - policyCard([ - { operation: 'read', anonymous: true }, + policyOf([ { - operation: 'read', - anonymous: true, - where: '.teacherIds | any(. == actor())', + targetType: CLASSROOM, + grants: [ + { operation: 'read', anonymous: true }, + // A custom operation, built on a base one. + { operation: 'appendActivity', anonymous: true }, + // A named query. + { operation: 'listOpen', anonymous: true }, + // A write that names nobody to make it as, and one whose key is + // blank. + { operation: 'transform', anonymous: true }, + { operation: 'update', anonymous: true, actingUser: ' ' }, + { + operation: 'transform', + anonymous: true, + actingUser: 'feedbackWriter', + }, + // A read names no acting user, whatever it is given. + { operation: 'readSource', anonymous: true, actingUser: 'reader' }, + { operation: 'query', anonymous: true, where: '.status == "open"' }, + // Opened to signed-in callers only. + { operation: 'delete' }, + ], }, - // A custom operation, built on a base one. - { operation: 'appendActivity', anonymous: true }, - // A write that names nobody to make it as. - { operation: 'transform', anonymous: true }, { - operation: 'transform', - anonymous: true, - actingUser: 'feedbackWriter', + // The only anonymous grant of its operation reads the caller. + targetType: OPEN_CLASSROOM, + grants: [ + { + operation: 'update', + anonymous: true, + actingUser: 'feedbackWriter', + where: '.status == "public" or (.teacherIds | any(. == actor()))', + }, + ], }, - { operation: 'query', anonymous: true, where: '.status == "open"' }, - // Opened to signed-in callers only. - { operation: 'update' }, ]), ); let policy = await compiled(); assert.deepEqual( - policy?.issues.map(({ code, path }) => ({ code, path })), + policy?.issues.map(({ code, path, severity }) => ({ + code, + path, + severity, + })), [ + { + code: 'anonymous-not-base-operation', + path: 'rules[0].grants[1].anonymous', + severity: 'inactive', + }, { code: 'anonymous-not-base-operation', path: 'rules[0].grants[2].anonymous', + severity: 'inactive', }, { code: 'anonymous-write-without-acting-user', path: 'rules[0].grants[3].actingUser', + severity: 'inactive', + }, + { + code: 'anonymous-write-without-acting-user', + path: 'rules[0].grants[4].actingUser', + severity: 'inactive', + }, + { + code: 'anonymous-grant-reads-actor', + path: 'rules[1].grants[0].where', + severity: 'warning', }, ], - 'a custom operation and a write with no acting user are refused, and nothing else', + 'a custom operation, a named query and a write with no acting user are refused; a grant that reads the caller is warned about and kept', ); let grants = policy?.rules[0]?.grants ?? []; assert.deepEqual( grants.map(({ path, anonymous }) => ({ path, anonymous })), [ { path: 'rules[0].grants[0]', anonymous: {} }, - { path: 'rules[0].grants[1]', anonymous: {} }, { - path: 'rules[0].grants[4]', + path: 'rules[0].grants[5]', anonymous: { actingUserKey: 'feedbackWriter' }, }, - { path: 'rules[0].grants[5]', anonymous: {} }, - { path: 'rules[0].grants[6]', anonymous: undefined }, + { path: 'rules[0].grants[6]', anonymous: {} }, + { path: 'rules[0].grants[7]', anonymous: {} }, + { path: 'rules[0].grants[8]', anonymous: undefined }, ], - 'the refused grants are left out, and only the grants that opt in carry the opt-in', + 'the refused grants are left out, only the grants that opt in carry the opt-in, and only a write names who it is made as', ); + assert.ok(grants[3]?.filter, 'the anonymous query grant has a filter'); + let readsCaller = policy?.rules[1]?.grants[0]; assert.true( - grants[1]?.where?.readsActor, + readsCaller?.where?.readsActor, 'a predicate that reads the caller says so', ); assert.notOk( grants[3]?.where?.readsActor, 'and one that reads only the card does not', ); - assert.ok(grants[3]?.filter, 'the anonymous query grant has a filter'); assert.deepEqual( policy?.anonymous, - { operations: ['query', 'read', 'transform'] }, - 'the policy records which operations it opens to callers who are not signed in', + { operations: ['query', 'read', 'readSource', 'transform'] }, + 'the operations opened to callers who are not signed in, leaving out the one whose only grant reads the caller', ); assert.deepEqual( [...(await education.getAnonymousAdmission())].sort(), - ['query', 'read', 'transform'], + ['query', 'read', 'readSource', 'transform'], 'and the realm reads them from the compiled policy', ); + }); + test('a query grant with no search filter opens nothing to callers who are not signed in', async function (assert) { + await writeTo( + org, + 'policies/education.json', + policyCard([ + // Reads a realm setting, which a search does not resolve. + { + operation: 'query', + anonymous: true, + where: '.status == realmConfig("approver")', + }, + ]), + ); + let policy = await compiled(); + assert.deepEqual( + policy?.issues.map(({ code }) => code), + ['policy-not-filterable'], + ); + assert.strictEqual(policy?.anonymous, undefined); + assert.strictEqual((await education.getAnonymousAdmission()).size, 0); + }); + + test('a policy that opens nothing, or that does not compile, opens nothing to callers who are not signed in', async function (assert) { await writeTo(org, 'policies/education.json', policyCard(GRANTS)); - let signedInOnly = await compiled(); assert.strictEqual( - signedInOnly?.anonymous, + (await compiled())?.anonymous, undefined, - 'a policy that opens nothing to callers who are not signed in records nothing', + 'a policy that opens nothing records nothing', ); assert.strictEqual((await education.getAnonymousAdmission()).size, 0); + + await writeTo( + org, + 'policies/education.json', + policyCard([{ operation: 'read', anonymous: true }], { + module: rri('@cardstack/base/card-api'), + name: 'CardDef', + }), + ); + assert.true( + (await compiled())?.uncompilable, + 'the policy does not compile', + ); + assert.strictEqual( + (await education.getAnonymousAdmission()).size, + 0, + 'and opens nothing', + ); }); }); diff --git a/packages/runtime-common/card-operations/policy.ts b/packages/runtime-common/card-operations/policy.ts index 68b51f4c907..328970cf320 100644 --- a/packages/runtime-common/card-operations/policy.ts +++ b/packages/runtime-common/card-operations/policy.ts @@ -104,8 +104,9 @@ export interface CompiledOperationGrant { where?: CompiledPolicyPredicate; // Set on a grant that also admits callers who aren't signed in. A write // grant names the key, in the governed realm's `realm.json` settings, whose - // value is the user its writes are made as. The realm resolves it on every - // invocation, so the policy names a setting and never a user. + // value is the user its writes are made as, and no other grant carries one. + // The realm resolves it on every invocation, so the policy names a setting + // and never a user. anonymous?: { actingUserKey?: string }; // For a grant on a query, the search filter the grant admits: the cards of // the rule's type that its predicate holds for, as a wire filter template @@ -152,9 +153,8 @@ export interface CompiledPolicyPredicate { // reads the stored source alone is judged against the stored source, and // pays no index read. snapshot: boolean; - // Set when the predicate calls `actor()`. A caller who isn't signed in has - // no actor, so such a predicate never holds for one, and is not evaluated - // for one either. + // Set when the predicate calls `actor()`. The realm doesn't evaluate such a + // predicate for a caller who isn't signed in, so it never admits one. readsActor?: true; } @@ -317,7 +317,12 @@ export class RealmPolicyCache { let compiled: CompiledRealmPolicy | undefined; try { compiled = await this.get(); - } catch { + } catch (e: unknown) { + log.warn( + `could not read the realm's policy to answer a caller who isn't signed in, so it opens nothing to them: ${ + e instanceof Error ? e.message : String(e) + }`, + ); return NO_OPERATIONS; } return compiled?.uncompilable || !compiled?.anonymous @@ -1157,9 +1162,11 @@ async function compileDocument( ); continue; } + // Used exactly as written, so the key the policy shows is the key the + // realm looks up. One that is blank names nothing. let actingUserKey = typeof grant.actingUser === 'string' && grant.actingUser.trim() - ? grant.actingUser.trim() + ? grant.actingUser : undefined; if (isWrite(granted.base) && !actingUserKey) { issue( @@ -1169,7 +1176,8 @@ async function compileDocument( ); continue; } - anonymous = actingUserKey ? { actingUserKey } : {}; + anonymous = + isWrite(granted.base) && actingUserKey ? { actingUserKey } : {}; } let where = readPredicate(grant?.where); if (where === 'malformed') { @@ -1197,6 +1205,13 @@ async function compileDocument( issue(outcome.code, `${grantPath}.where`, outcome.problem); continue; } + if (anonymous && outcome.readsActor) { + issue( + 'anonymous-grant-reads-actor', + `${grantPath}.where`, + `this grant opens \`${operation}\` to callers who aren't signed in, but its \`where\` uses \`actor()\`, which isn't evaluated for a caller with no actor, so it never admits one. It still applies to signed-in callers. To open it to anyone, give the anonymous grant a \`where\` that reads only the card`, + ); + } // Which tier the predicate reads is settled here, from the rule's type, // and ahead of the search filter: a predicate that reads the index // without saying so is inactive in both lanes, not a query grant that @@ -1567,14 +1582,19 @@ function safeURL( // The operations the policy's live grants open to callers who aren't signed // in, recorded on the compiled policy when there are any. A query grant that -// has no search filter admits no search, so it opens nothing. +// has no search filter admits no search, and a grant whose `where` reads the +// caller admits nobody without one, so neither opens anything. function anonymousSummary( rules: CompiledPolicyRule[], ): Pick { let operations = new Set(); for (let rule of rules) { for (let grant of rule.grants) { - if (grant.anonymous && (grant.operation !== 'query' || grant.filter)) { + if ( + grant.anonymous && + !grant.where?.readsActor && + (grant.operation !== 'query' || grant.filter) + ) { operations.add(grant.operation); } } diff --git a/packages/runtime-common/card-operations/types.ts b/packages/runtime-common/card-operations/types.ts index 83124269588..b3ff1deb2da 100644 --- a/packages/runtime-common/card-operations/types.ts +++ b/packages/runtime-common/card-operations/types.ts @@ -551,14 +551,20 @@ export type PolicyIssueCode = // `grant-reaches-ungranted-type`. The grant is kept, for the same reason. | 'render-reaches-ungranted-type' // A grant that opts in to admitting callers who aren't signed in on an - // operation that isn't one of `ANONYMOUS_ELIGIBLE_OPERATIONS` as the type - // carries it: a custom operation, a named query, or anything else declared - // under its own name. The grant is left out. + // operation that isn't one of `ANONYMOUS_ELIGIBLE_OPERATIONS` invoked under + // its own name: a custom operation or a named query. A type's own + // declaration under a base operation's name is that operation, and is + // eligible. The grant is left out. | 'anonymous-not-base-operation' // A grant that opts in to admitting callers who aren't signed in on a write, // without naming the `realm.json` setting that says which user the write is // made as. The grant is left out. - | 'anonymous-write-without-acting-user'; + | 'anonymous-write-without-acting-user' + // A grant that opts in to admitting callers who aren't signed in, whose + // `where` reads `actor()`. The realm doesn't evaluate such a `where` for a + // caller with no actor, so the grant never admits one, whatever else its + // `where` says. It is kept for signed-in callers. + | 'anonymous-grant-reads-actor'; // The operations a grant may open to callers who aren't signed in: the // grantable base operations, invoked under their own names. A custom operation @@ -580,6 +586,7 @@ export const ANONYMOUS_ELIGIBLE_OPERATIONS: readonly BaseOperationName[] = [ export const KEEPS_ITS_PART: ReadonlySet = new Set([ 'grant-reaches-ungranted-type', 'render-reaches-ungranted-type', + 'anonymous-grant-reads-actor', ]); export type PolicyIssueSeverity = 'inactive' | 'warning'; From 004683ba7cdee1299e3ba885e1fa9c31af1257cd Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 5 Oct 2026 17:54:40 -0400 Subject: [PATCH 5/5] Pin the catalog test subset to the paired branch with catalog main merged Co-Authored-By: Claude Opus 5.5 --- packages/catalog/test-subset.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index 36245c494c5..26bc5a6584e 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -1,6 +1,6 @@ { "repository": "cardstack/boxel-catalog", - "revision": "79bae3357dbc23d53e89c7e1700ca57daecd7f2c", + "revision": "2845e727868e364cb5197bd78d5e657131cce18a", "files": [ { "path": "realm-policy/realm-policy.gts",