diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index ff647b6b976..f3bee8b95fa 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -1,6 +1,6 @@ { "repository": "cardstack/boxel-catalog", - "revision": "e9266fee70d06ad639e7481473fdeeeb5f88c1cf", + "revision": "2845e727868e364cb5197bd78d5e657131cce18a", "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 9243a780559..6ccb551144c 100644 --- a/packages/host/tests/integration/realm-policy-test.gts +++ b/packages/host/tests/integration/realm-policy-test.gts @@ -363,6 +363,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 c3692ea6d0b..a4ca19ec2e5 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 = { @@ -86,7 +90,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 +318,7 @@ module(basename(import.meta.filename), function (hooks) { source: '.teacherIds | any(. == actor())', canonical: '.teacherIds | any(. == actor())', snapshot: false, + readsActor: true, }, }, { @@ -318,6 +328,7 @@ module(basename(import.meta.filename), function (hooks) { source: '.headTeacher == actor()', canonical: '.headTeacher == actor()', snapshot: true, + readsActor: true, }, }, { @@ -822,4 +833,174 @@ 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) { + const OPEN_CLASSROOM = { + module: `${EDUCATION}classroom`, + name: 'OpenClassroom', + }; + await writeTo( + org, + 'policies/education.json', + policyOf([ + { + 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' }, + ], + }, + { + // 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()))', + }, + ], + }, + ]), + ); + let policy = await compiled(); + assert.deepEqual( + 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, 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[5]', + anonymous: { actingUserKey: 'feedbackWriter' }, + }, + { 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, 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( + 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.deepEqual( + policy?.anonymous, + { 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', '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)); + assert.strictEqual( + (await compiled())?.anonymous, + undefined, + '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/realm-server/tests/realm-policy-tiers-test.ts b/packages/realm-server/tests/realm-policy-tiers-test.ts index 0f85e409f0f..f234c318313 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 c6147cf8f7b..f46340fd1cd 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,12 @@ 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, 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 // whose `{ $ref: 'actor' }` markers a search fills in with the caller. A @@ -141,6 +153,9 @@ 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()`. The realm doesn't evaluate such a + // predicate for a caller who isn't signed in, so it never admits one. + readsActor?: true; } // The type every policy card adopts from. It is defined in the catalog realm, @@ -241,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; @@ -290,6 +307,29 @@ 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 (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 + ? 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) { @@ -831,7 +871,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, @@ -1114,6 +1160,40 @@ 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; + } + // 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 + : 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 = + isWrite(granted.base) && actingUserKey ? { actingUserKey } : {}; + } let where = readPredicate(grant?.where); if (where === 'malformed') { issue( @@ -1126,7 +1206,7 @@ async function compileDocument( if (!where) { grants.push( await withFilter( - { operation, path: grantPath }, + { operation, path: grantPath, ...(anonymous ? { anonymous } : {}) }, granted.base, undefined, resolved, @@ -1140,6 +1220,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 @@ -1184,7 +1271,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 }, @@ -1506,6 +1595,30 @@ 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, 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.where?.readsActor && + (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 @@ -1678,7 +1791,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 { @@ -1727,7 +1842,23 @@ async function compilePredicate( problem: `\`where\` matches only part of a value, so it could let in someone the grant doesn't mean to:\n${calls}\n\nInstead:\n- to check whether a list includes the caller, use \`.list | any(. == actor())\`\n- 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..b3ff1deb2da 100644 --- a/packages/runtime-common/card-operations/types.ts +++ b/packages/runtime-common/card-operations/types.ts @@ -549,12 +549,44 @@ 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` 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' + // 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 +// 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([ 'grant-reaches-ungranted-type', 'render-reaches-ungranted-type', + 'anonymous-grant-reads-actor', ]); export type PolicyIssueSeverity = 'inactive' | 'warning'; diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 3a4eb818887..463b05f0a5b 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -15730,6 +15730,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;