diff --git a/packages/catalog/test-subset.json b/packages/catalog/test-subset.json index 9731fe5f4f2..d17a43b63a4 100644 --- a/packages/catalog/test-subset.json +++ b/packages/catalog/test-subset.json @@ -1,6 +1,6 @@ { "repository": "cardstack/boxel-catalog", - "revision": "776baef8ccd1bc4c370711797374a79905c4e965", + "revision": "708a03324f6c0d3b8dceda9a200f4244e07bef5a", "files": [ { "path": "realm-policy/realm-policy.gts", @@ -21,6 +21,7 @@ "realmServer": [ "catalog-test-subset-test", "realm-policy-activation-test", + "realm-policy-anonymous-explain-test", "realm-policy-anonymous-named-operation-test", "realm-policy-anonymous-read-test", "realm-policy-anonymous-search-test", diff --git a/packages/host/tests/helpers/index.gts b/packages/host/tests/helpers/index.gts index c140dabdef9..c4e2c6a4084 100644 --- a/packages/host/tests/helpers/index.gts +++ b/packages/host/tests/helpers/index.gts @@ -2635,6 +2635,13 @@ export function realmConfigCardJSON( includePrerenderedDefaultRealmIndex?: boolean; // The URL of the card whose policy governs the realm. policy?: string; + // The realm's own settings, which a card operation reads with + // `realmConfig("key")`. + config?: Record; + // How the realm limits and blocks callers its policy admits without a + // session. + anonymousRateLimit?: { requests: number; windowSeconds: number }; + anonymousBlocklist?: string[]; } = {}, ): string { let attrs: Record = {}; @@ -2654,6 +2661,15 @@ export function realmConfigCardJSON( if (config.policy !== undefined) { attrs.policy = config.policy; } + if (config.config !== undefined) { + attrs.config = config.config; + } + if (config.anonymousRateLimit !== undefined) { + attrs.anonymousRateLimit = config.anonymousRateLimit; + } + if (config.anonymousBlocklist !== undefined) { + attrs.anonymousBlocklist = config.anonymousBlocklist; + } return JSON.stringify({ data: { type: 'card', diff --git a/packages/host/tests/integration/realm-policy-test.gts b/packages/host/tests/integration/realm-policy-test.gts index 865f0d11d79..8f73b493ec4 100644 --- a/packages/host/tests/integration/realm-policy-test.gts +++ b/packages/host/tests/integration/realm-policy-test.gts @@ -752,6 +752,77 @@ module('Integration | realm policy', function (hooks) { ); } + test('an explanation for someone who is not signed in shows the limit, the blocklist, and who each grant opened to them writes as', async function (assert) { + await setupIntegrationTestRealm({ + mockMatrixUtils, + permissions: { + '*': ['read'], + '@testuser:localhost': ['read', 'write', 'realm-owner'], + }, + contents: { + 'realm.json': realmConfigCardJSON({ + policy: `${testRealmURL}policies/classrooms`, + config: { submitter: '@testuser:localhost' }, + anonymousRateLimit: { requests: 7, windowSeconds: 30 }, + anonymousBlocklist: ['not an address'], + }), + 'classroom.gts': classroomModule, + 'classrooms/room-204.json': classroom([TEACHER]), + 'policies/classrooms.json': policyDocument([ + { + targetType: { module: '../classroom', name: 'Classroom' }, + grants: [ + { operation: 'update', anonymous: true, actingUser: 'submitter' }, + { operation: 'update', anonymous: true, actingUser: 'missing' }, + // Reads the caller, so it is warned about. + { + operation: 'update', + anonymous: true, + actingUser: 'submitter', + where: teachesPredicate, + }, + ], + }, + ]), + }, + }); + await getService('realm').login(testRealmURL); + getService('operations'); + let policy = await loadPolicy('policies/classrooms'); + await renderCard(loader, policy, 'isolated'); + + await ask('', `${testRealmURL}classrooms/room-204`, 'update'); + assert.dom('[data-test-explanation-actor]').hasText('not signed in'); + assert + .dom('[data-test-explanation-reason]') + .hasText( + "This realm's blocklist has an entry that isn't an address or a range, so it turns away everyone who isn't signed in before the policy is checked.", + ); + assert + .dom('[data-test-explanation-anonymous-limit]') + .hasText('7 requests per 30 seconds from one address, set by this realm'); + assert + .dom('[data-test-explanation-anonymous-blocklist]') + .includesText('"not an address"', 'names the entry to fix'); + assert.deepEqual( + [ + ...document.querySelectorAll('[data-test-explanation-grant-anonymous]'), + ].map((el) => el.textContent?.trim()), + [ + 'Open to people who aren\'t signed in; their writes are made as @testuser:localhost (this realm\'s "submitter" setting).', + 'Open to people who aren\'t signed in, but this realm\'s settings have no "missing", so this grant admits none of them.', + 'Open to people who aren\'t signed in; their writes are made as @testuser:localhost (this realm\'s "submitter" setting).', + ], + ); + assert.deepEqual( + [...document.querySelectorAll('[data-test-explanation-grant-issue]')].map( + (el) => el.getAttribute('data-test-explanation-grant-issue'), + ), + ['anonymous-grant-reads-actor'], + 'the grant whose condition reads the caller carries its warning', + ); + }); + test('the policy explains what it decides for one caller, one card and one operation', async function (assert) { await renderClassroomPolicy(); diff --git a/packages/realm-server/tests/realm-policy-anonymous-explain-test.ts b/packages/realm-server/tests/realm-policy-anonymous-explain-test.ts new file mode 100644 index 00000000000..7671ec8e8c7 --- /dev/null +++ b/packages/realm-server/tests/realm-policy-anonymous-explain-test.ts @@ -0,0 +1,386 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import supertest from 'supertest'; +import type { Test, SuperTest } from 'supertest'; +import { basename, join } from 'path'; +import { dirSync } from 'tmp'; +import { rri, SupportedMimeType } from '@cardstack/runtime-common'; +import type { + QueuePublisher, + QueueRunner, + Realm, +} from '@cardstack/runtime-common'; +import type { PolicyExplanation } from '@cardstack/runtime-common/card-operations'; +import type { PgAdapter } from '@cardstack/postgres'; +import { resetCatalogRealms } from '../handlers/handle-fetch-catalog-realms.ts'; +import type { RealmHttpServer as Server } from '../server.ts'; +import { + closeServer, + createJWT, + createVirtualNetwork, + matrixURL, + realmConfigCardJSON, + runTestRealmServerWithRealms, + setupDB, + setupTestDatabaseTemplate, +} from './helpers/index.ts'; +import { setupCatalogTestSubset } from './helpers/catalog-test-subset.ts'; + +// A notice board nobody may read or write without signing in, governed by a +// policy card in an Org realm. Its policy opens edits of a notice to callers +// who aren't signed in through four grants, each made as a different key of +// the board's `realm.json` `config`: one naming a user who may write the +// board, one naming a user who may only read it, one naming nobody, and one +// the config doesn't hold. `claim` reads the caller, so the grant opening it +// to such callers is warned about. A second board sets no limit of its own. +const BOARD = 'http://127.0.0.1:4444/board/'; +const PLAIN = 'http://127.0.0.1:4444/plain/'; +const ORG = 'http://127.0.0.1:4444/org/'; +const BOARD_POLICY = `${ORG}policies/board`; +const PLAIN_POLICY = `${ORG}policies/plain`; +const EDITOR = '@editor:localhost'; +const SUBMITTER = '@submitter:localhost'; +const READER = '@reader:localhost'; +const ORG_ADMIN = '@org-admin:localhost'; + +const REALM_POLICY = { + module: rri('@cardstack/catalog/realm-policy/realm-policy'), + name: 'RealmPolicy', +}; + +const BOARD_MODULE = ` + import { contains, field, CardDef } from "@cardstack/base/card-api"; + import StringField from "@cardstack/base/string"; + import { operation, actor } from "@cardstack/base/operations"; + export class Notice extends CardDef { + @field message = contains(StringField); + @field claimedBy = contains(StringField); + + @operation static claim = { + base: 'transform', + set: { claimedBy: actor() }, + }; + } +`; + +const BOARD_CONFIG = { + submitter: SUBMITTER, + reader: READER, + bogus: 'not a user', +}; + +const BOARD_LIMIT = { requests: 5, windowSeconds: 30 }; + +const NOTICE = `${BOARD}notices/welcome`; +const PLAIN_NOTICE = `${PLAIN}notices/welcome`; + +function notice(message: string) { + return JSON.stringify({ + data: { + type: 'card', + attributes: { message }, + meta: { adoptsFrom: { module: '../board', name: 'Notice' } }, + }, + }); +} + +function policyCard(realm: string) { + return JSON.stringify({ + data: { + type: 'card', + attributes: { + rules: [ + { + targetType: { module: `${realm}board`, name: 'Notice' }, + grants: [ + { operation: 'update', anonymous: true, actingUser: 'submitter' }, + { operation: 'update', anonymous: true, actingUser: 'reader' }, + { operation: 'update', anonymous: true, actingUser: 'bogus' }, + { operation: 'update', anonymous: true, actingUser: 'missing' }, + { operation: 'read', anonymous: true }, + { operation: 'claim', anonymous: true, actingUser: 'submitter' }, + // Signed-in grants that put a warned grant at index 10, whose + // path `rules[0].grants[1]` is a string prefix of. + { operation: 'delete' }, + { operation: 'delete' }, + { operation: 'delete' }, + { operation: 'delete' }, + { operation: 'claim', anonymous: true, actingUser: 'reader' }, + ], + }, + ], + }, + meta: { adoptsFrom: REALM_POLICY }, + }, + }); +} + +module(basename(import.meta.filename), function (hooks) { + let board: Realm; + let plain: Realm; + let org: Realm; + let request: SuperTest; + let server: Server; + + setupCatalogTestSubset(hooks); + + async function start({ + dbAdapter, + publisher, + runner, + }: { + dbAdapter: PgAdapter; + publisher: QueuePublisher; + runner: QueueRunner; + }) { + let result = await runTestRealmServerWithRealms({ + virtualNetwork: createVirtualNetwork(), + realmsRootPath: join(dirSync().name, 'realm_server_1'), + clientAddress: { trustedProxyHops: 1, infraAddresses: [] }, + realms: [ + { + realmURL: new URL(BOARD), + fileSystem: { + 'realm.json': realmConfigCardJSON({ + name: 'Board', + policy: BOARD_POLICY, + config: BOARD_CONFIG, + anonymousRateLimit: BOARD_LIMIT, + }), + 'board.gts': BOARD_MODULE, + 'notices/welcome.json': notice('Welcome'), + }, + permissions: { + [EDITOR]: ['read', 'write', 'realm-owner'], + [SUBMITTER]: ['read', 'write'], + [READER]: ['read'], + [ORG_ADMIN]: ['read'], + }, + }, + { + realmURL: new URL(PLAIN), + fileSystem: { + 'realm.json': realmConfigCardJSON({ + name: 'Plain', + policy: PLAIN_POLICY, + config: BOARD_CONFIG, + }), + 'board.gts': BOARD_MODULE, + 'notices/welcome.json': notice('Welcome'), + }, + permissions: { + [EDITOR]: ['read', 'write', 'realm-owner'], + [SUBMITTER]: ['read', 'write'], + [ORG_ADMIN]: ['read'], + }, + }, + { + realmURL: new URL(ORG), + fileSystem: { + 'realm.json': realmConfigCardJSON({ name: 'Org' }), + 'policies/board.json': policyCard(BOARD), + 'policies/plain.json': policyCard(PLAIN), + }, + permissions: { [ORG_ADMIN]: ['read', 'write', 'realm-owner'] }, + }, + ], + dbAdapter, + publisher, + runner, + matrixURL, + }); + server = result.testRealmHttpServer; + request = supertest(server); + board = result.realms.find((realm) => realm.url === BOARD)!; + plain = result.realms.find((realm) => realm.url === PLAIN)!; + org = result.realms.find((realm) => realm.url === ORG)!; + } + + async function stop() { + for (let realm of [board, plain, org]) { + realm.__testOnlyClearCaches(); + realm.unsubscribe(); + } + await closeServer(server); + resetCatalogRealms(); + } + + let templateDatabase = setupTestDatabaseTemplate(hooks, { + key: import.meta.filename, + build: async (args) => { + await start(args); + return stop; + }, + }); + + setupDB(hooks, { + templateDatabase, + beforeEach: async (dbAdapter, publisher, runner) => { + await start({ dbAdapter, publisher, runner }); + }, + afterEach: stop, + }); + + async function explain( + policy: string, + actor: string, + target: string, + operation: string, + ): Promise { + let response = await request + .post(`${new URL(ORG).pathname}_operations`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', SupportedMimeType.BoxelOperations) + .set('Content-Type', SupportedMimeType.BoxelOperations) + .set( + 'Authorization', + `Bearer ${createJWT(org, ORG_ADMIN, ['read', 'write', 'realm-owner'])}`, + ) + .send( + JSON.stringify({ + 'boxel:operations': [ + { + op: 'invoke', + 'boxel:name': 'explain', + href: policy, + data: { actor, target, operation }, + }, + ], + }), + ); + if (response.status !== 200) { + throw new Error(`explain answered ${response.status}: ${response.text}`); + } + return response.body['atomic:results'][0] as PolicyExplanation; + } + + function grantsOf(explanation: PolicyExplanation) { + return explanation.rules.flatMap((rule) => rule.grants); + } + + test('each grant opened to callers who are not signed in says who its writes are made as, or why no one', async function (assert) { + let explanation = await explain(BOARD_POLICY, '', NOTICE, 'update'); + assert.strictEqual(explanation.decision, 'allowed', 'the submitter writes'); + assert.deepEqual( + grantsOf(explanation).map(({ path, anonymous }) => ({ path, anonymous })), + [ + { + path: 'rules[0].grants[0]', + anonymous: { actingUserKey: 'submitter', actingUser: SUBMITTER }, + }, + { + path: 'rules[0].grants[1]', + anonymous: { actingUserKey: 'reader', actingUserFailure: 'no-write' }, + }, + { + path: 'rules[0].grants[2]', + anonymous: { + actingUserKey: 'bogus', + actingUserFailure: 'not-a-matrix-id', + }, + }, + { + path: 'rules[0].grants[3]', + anonymous: { + actingUserKey: 'missing', + actingUserFailure: 'key-missing', + }, + }, + ], + ); + let read = await explain(BOARD_POLICY, '', NOTICE, 'read'); + assert.deepEqual( + grantsOf(read).map(({ anonymous }) => anonymous), + [{}], + 'a read opted in names no acting user', + ); + }); + + test('an explanation for a caller who is not signed in says how the realm limits and blocks them', async function (assert) { + let own = await explain(BOARD_POLICY, '', NOTICE, 'read'); + assert.deepEqual(own.anonymous, { + limit: BOARD_LIMIT, + limitFrom: 'realm', + invalidBlocklistEntries: [], + }); + let inherited = await explain(PLAIN_POLICY, '', PLAIN_NOTICE, 'read'); + assert.strictEqual( + inherited.anonymous?.limitFrom, + 'platform', + 'a realm that sets no limit has the platform default', + ); + assert.true( + (inherited.anonymous?.limit.requests ?? 0) > 0, + 'and says what that default is', + ); + let signedIn = await explain(BOARD_POLICY, READER, NOTICE, 'update'); + assert.strictEqual( + signedIn.anonymous, + undefined, + 'a question about a signed-in caller says nothing about it', + ); + }); + + test('an invalid blocklist entry is reported, since it closes the realm to every such caller', async function (assert) { + await board.write( + 'realm.json', + realmConfigCardJSON({ + name: 'Board', + policy: BOARD_POLICY, + config: BOARD_CONFIG, + anonymousRateLimit: BOARD_LIMIT, + anonymousBlocklist: ['198.51.100.0/24', 'not an address'], + }), + ); + await board.indexing(); + let explanation = await explain(BOARD_POLICY, '', NOTICE, 'read'); + assert.deepEqual(explanation.anonymous?.invalidBlocklistEntries, [ + 'not an address', + ]); + assert.strictEqual( + explanation.decision, + 'denied', + 'the grant that would admit such a caller does not, since the realm turns them away first', + ); + assert.strictEqual(explanation.reason, 'blocklist-invalid'); + assert.deepEqual(explanation.refusal, { + status: 401, + code: 'actor-required', + }); + assert.strictEqual(explanation.admittedBy, undefined); + let signedIn = await explain(BOARD_POLICY, READER, NOTICE, 'read'); + assert.strictEqual( + signedIn.reason, + 'acl', + 'a signed-in caller is answered by the realm as before', + ); + }); + + test('a grant listed carries what compiling the policy recorded against it', async function (assert) { + let explanation = await explain(BOARD_POLICY, READER, NOTICE, 'claim'); + let claim = grantsOf(explanation).find( + ({ path }) => path === 'rules[0].grants[5]', + ); + assert.ok(claim, 'the claim grant is listed'); + assert.deepEqual( + claim?.issues?.map(({ code, path, severity }) => ({ + code, + path, + severity, + })), + [ + { + code: 'anonymous-grant-reads-actor', + path: 'rules[0].grants[5].operation', + severity: 'warning', + }, + ], + ); + assert.notOk( + grantsOf(await explain(BOARD_POLICY, READER, NOTICE, 'update')).some( + ({ issues }) => issues, + ), + "a grant with nothing recorded against it carries no issues, not even rules[0].grants[10]'s", + ); + }); +}); diff --git a/packages/runtime-common/card-operations/explain.ts b/packages/runtime-common/card-operations/explain.ts index 1adc6edadaf..c3821e571a6 100644 --- a/packages/runtime-common/card-operations/explain.ts +++ b/packages/runtime-common/card-operations/explain.ts @@ -33,7 +33,8 @@ import { type MatchedGrant, } from './gate.ts'; import { resolveNamedQuery, searchInvocation } from './named-query.ts'; -import type { CompiledRealmPolicy } from './policy.ts'; +import { NO_ACTING_USER, type ActingUserResolution } from './acting-users.ts'; +import type { CompiledOperationGrant, CompiledRealmPolicy } from './policy.ts'; import { policyQueryScope, type PolicyQueryScope } from './policy-query.ts'; import type { PolicyRoute } from './telemetry.ts'; import { @@ -43,6 +44,7 @@ import { isOperationFailure, isWrite, refusalForNonReader, + type ExplainedGrant, type ExplainedGrantOutcome, type ExplainedIndexLag, type ExplainedRule, @@ -278,13 +280,21 @@ export async function explainOperation( let actor = scopeCallerFor(question.actor); let acl = await realm.aclFor(actor); let draft = 'draft' in governing ? governing.draft : undefined; - let answered = (explanation: PolicyExplanation): PolicyExplanation => - draft ? { ...explanation, draft: { issues: draft.issues } } : explanation; + let deciding = await pinnedForExplain(governing.core); + let detailed = await detailer(deciding, actor); + let answered = async ( + explanation: PolicyExplanation, + ): Promise => { + let withDetail = await detailed(explanation); + return draft + ? { ...withDetail, draft: { issues: draft.issues } } + : withDetail; + }; if (question.search) { return { - explanation: answered( + explanation: await answered( await explainSearch( - governing.core, + deciding, realm, question.search, question, @@ -303,12 +313,14 @@ export async function explainOperation( for (let url of cards) { try { explanations.push( - await explain( - governing.core, - { kind: 'instance', url }, - question, - actor, - acl, + await detailed( + await explain( + deciding, + { kind: 'instance', url }, + question, + actor, + acl, + ), ), ); } catch (e: unknown) { @@ -330,13 +342,131 @@ export async function explainOperation( }; } let explanation = await explain( - governing.core, + deciding, target as OperationTarget & { kind: 'instance' }, question, actor, acl, ); - return { explanation: answered(explanation) }; + return { explanation: await answered(explanation) }; +} + +// The core an explain decides with: the governing core, holding one compile +// of its policy and one answer per acting-user key for the whole explain. A +// listing runs the gate once per card, and the policy can recompile or a +// user's permissions change between them, so without this one card could be +// judged against a different policy from the next, or a grant reported with an +// acting user the gate didn't see. +async function pinnedForExplain(core: OperationCore): Promise { + let access = core.policy; + if (!access) { + return core; + } + let compiled = await access.compiledPolicy(); + let resolutions = new Map>(); + return { + ...core, + policy: { + ...access, + compiledPolicy: async () => compiled, + actingUser: (key: string) => { + let resolution = resolutions.get(key); + if (!resolution) { + resolution = access.actingUser?.(key) ?? NO_ACTING_USER(); + resolutions.set(key, resolution); + } + return resolution; + }, + }, + }; +} + +// What an explanation reports beyond the decision itself, read from the +// policy and the realm that decided it: for a question about a caller who +// isn't signed in, how the realm limits and blocks such callers; for each +// grant listed that opts in to them, the user its writes are made as, or why +// its key names no one; and for each grant listed, what compiling the policy +// recorded against it. It reads the same compile and acting users the gate +// decided with (see `pinnedForExplain`). +async function detailer( + core: OperationCore, + actor: ScopeCaller, +): Promise<(explanation: PolicyExplanation) => Promise> { + let compiled = await core.policy?.compiledPolicy(); + let grants = new Map(); + for (let rule of compiled?.rules ?? []) { + for (let grant of rule.grants) { + grants.set(grant.path, grant); + } + } + let issues = compiled?.issues ?? []; + let access = + actor.kind === 'user' ? undefined : await core.policy?.anonymousAccess?.(); + let resolve = (key: string) => + core.policy?.actingUser?.(key) ?? NO_ACTING_USER(); + let detail = async (explained: ExplainedGrant): Promise => { + let grant = grants.get(explained.path); + // Issues are recorded at a grant's path or at a part of it, and + // `rules[1]` is a prefix of `rules[10]`, so only the path itself or one + // followed by `.` is this grant's. + let own = issues.filter( + ({ path }) => + path === explained.path || path.startsWith(`${explained.path}.`), + ); + let anonymous: ExplainedGrant['anonymous']; + if (grant?.anonymous) { + let key = grant.anonymous.actingUserKey; + if (key) { + let resolution = await resolve(key); + anonymous = + 'user' in resolution + ? { actingUserKey: key, actingUser: resolution.user } + : { actingUserKey: key, actingUserFailure: resolution.failure }; + } else { + anonymous = {}; + } + } + return { + ...explained, + ...(anonymous ? { anonymous } : {}), + ...(own.length > 0 ? { issues: own } : {}), + }; + }; + // A blocklist entry that is neither an address nor a range closes the realm + // to every caller who isn't signed in, before the policy is consulted. So + // where the answer turned on the policy, it is refused as such a caller's + // request would be. A realm whose ACL answers the request, or whose policy + // opens nothing to them, refuses them before reading the blocklist, and its + // answer stands. + let blocklistCloses = + access !== undefined && access.invalidBlocklistEntries.length > 0; + return async (explanation) => ({ + ...(blocklistCloses && + explanation.reason !== 'acl' && + explanation.reason !== 'actor-required' + ? (({ admittedBy: _admittedBy, ...rest }) => ({ + ...rest, + decision: 'denied' as const, + reason: 'blocklist-invalid' as const, + refusal: { status: 401, code: 'actor-required' as const }, + }))(explanation) + : explanation), + rules: await Promise.all( + explanation.rules.map(async (rule) => ({ + ...rule, + grants: await Promise.all(rule.grants.map(detail)), + })), + ), + ...(access + ? { + anonymous: { + limit: { ...access.limit }, + limitFrom: access.limitFrom, + invalidBlocklistEntries: [...access.invalidBlocklistEntries], + }, + } + : {}), + }); } // The core the target's realm would decide with were its policy card to hold diff --git a/packages/runtime-common/card-operations/gate.ts b/packages/runtime-common/card-operations/gate.ts index 0a1bb70f911..29b0effa29c 100644 --- a/packages/runtime-common/card-operations/gate.ts +++ b/packages/runtime-common/card-operations/gate.ts @@ -14,6 +14,7 @@ import { chainType } from './adoption-chain.ts'; import { localPathFor, pathsFor } from './dispatch.ts'; import type { OperationCore, OperationScope } from './dispatch.ts'; import type { ActingUserResolution } from './acting-users.ts'; +import type { AnonymousAccessSettings } from '../anonymous-access.ts'; import type { AdmissionSubject, BxlMutationModule } from './executors.ts'; import type { GateTrace, @@ -189,6 +190,9 @@ export interface OperationPolicyAccess { // `ActingUsers`). A realm without it admits no write by a caller who isn't // signed in. actingUser?(key: string): Promise; + // How the realm limits and blocks callers who aren't signed in, from its + // `realm.json` and the platform default. An explain reports it. + anonymousAccess?(): Promise; } // The target as the gate judges it. It holds what the realm resolved, and diff --git a/packages/runtime-common/card-operations/types.ts b/packages/runtime-common/card-operations/types.ts index e88d230bc4f..0c157fda7c5 100644 --- a/packages/runtime-common/card-operations/types.ts +++ b/packages/runtime-common/card-operations/types.ts @@ -1032,6 +1032,21 @@ export interface PolicyExplanation { // Present for a question asked about a search: what the target realm's // policy composes into it. search?: ExplainedSearch; + // Present for a question asked about a caller who isn't signed in: how the + // target realm limits and blocks such callers, from its `realm.json`. + anonymous?: ExplainedAnonymousAccess; +} + +// How a realm treats callers who aren't signed in, as an explain reports it. +export interface ExplainedAnonymousAccess { + // How many requests one address may make in a window, and whether that is + // the realm's own setting (`anonymousRateLimit`) or the platform's default. + limit: { requests: number; windowSeconds: number }; + limitFrom: 'realm' | 'platform'; + // `anonymousBlocklist` entries that are neither an address nor a range, as + // written. While there is any, the realm admits no caller who isn't signed + // in at all. + invalidBlocklistEntries: string[]; } // ============================================================================ @@ -1141,6 +1156,10 @@ export type PolicyExplanationReason = // operation, as the target's type declares it, reads `actor()`, which such // a caller has none of, so the grant doesn't apply to them. | 'reads-actor' + // The caller isn't signed in, and the target realm's `anonymousBlocklist` + // has an entry that is neither an address nor a range, which closes the + // realm to every such caller whatever the policy grants. + | 'blocklist-invalid' // The realm names a policy it cannot load. | 'policy-unloadable'; @@ -1172,6 +1191,19 @@ export interface ExplainedGrant { // is what it composes into the search. One whose predicate has none admits // no search (`policy-not-filterable`). Absent on the direct lane. filterable?: boolean; + // Present where the grant opts in to callers who aren't signed in. For one + // on a write, the `realm.json` `config` key its writes are made under, and + // either the user that key names or why it names no one who may write the + // realm, in which case the grant admits no such caller. + anonymous?: { + actingUserKey?: string; + actingUser?: string; + actingUserFailure?: 'key-missing' | 'not-a-matrix-id' | 'no-write'; + }; + // What compiling the policy recorded against this grant: the warnings it + // still applies under. A grant an issue left out of the policy isn't here + // to carry it, and appears only in the policy's own issues. + issues?: PolicyIssue[]; } export type ExplainedGrantOutcome = diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 1765adb4b29..87601601c16 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -7040,6 +7040,7 @@ export class Realm { this.#policyCompileEnvironment(), ), actingUser: (key) => this.#anonymous.resolveActingUser(key), + anonymousAccess: () => this.getAnonymousAccess(), }, targetRealm: (href) => this.#targetRealm(href), // Compiled as this realm's own policy cache compiles the card its