Repository navigation
Policy: admit anonymous searches through a query grant that opts in #6575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7a87354
108d380
aaa4321
02a6396
37f290b
50a4a33
d59a234
1da1add
43d921c
ba2c3a8
f16371c
e29864e
78b7a94
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,6 +63,7 @@ import { | |
| } from '../search-type-watermarks.ts'; | ||
| import type { | ||
| CodeRef, | ||
| AnonymousCaller, | ||
| DBAdapter, | ||
| Realm, | ||
| VirtualNetwork, | ||
|
|
@@ -137,7 +138,7 @@ export default function handleSearch(opts: { | |
| let linkShapePolicy = opts.linkShapePolicy ?? LinkShapePolicy.pinned('full'); | ||
| let liveSearchCache = opts.liveSearchCache ?? new LiveSearchCache(); | ||
| return async function (ctxt: Koa.Context) { | ||
| let { realmList, grantCandidates, principal } = | ||
| let { realmList, grantCandidates, principal, anonymousCallers } = | ||
| getMultiRealmAuthorization(ctxt); | ||
| let payload = getSearchRequestPayload(ctxt); | ||
| // Every realm the request names, in the order it names them, which is the | ||
|
|
@@ -222,8 +223,9 @@ export default function handleSearch(opts: { | |
| // only once no realm the caller reads will mount. One the caller | ||
| // reaches only through its policy reads the declaration on that realm's | ||
| // own authority, the way the policy gate reads it. No policy admits a | ||
| // request that authenticated nobody, nor a realm-authority principal, | ||
| // so for either there are none of the latter. | ||
| // named query to a request that authenticated nobody, nor to a | ||
| // realm-authority principal, so for either there are none of the | ||
| // latter. | ||
| let policyRealms = async () => | ||
| principal?.kind === 'user' | ||
| ? await realmsThatMayNamePolicy(grantCandidates) | ||
|
|
@@ -296,18 +298,39 @@ export default function handleSearch(opts: { | |
| // policy is asked about it. | ||
| let readable = new Set(realmList); | ||
| let candidates = new Set(grantCandidates); | ||
| // A request that authenticated nobody is counted by each realm that | ||
| // admitted it, against the address the request came from. | ||
| // It is counted only for a search the realm can run, so a request whose | ||
| // query won't parse is refused before any realm counts it. | ||
| let anonymousRequest = anonymousCallers | ||
| ? await fetchRequestFromContext(ctxt) | ||
| : undefined; | ||
| if (anonymousRequest) { | ||
| try { | ||
| parseSearchEntryQueryFromPayload(payload); | ||
| } catch (e) { | ||
| if (e instanceof SearchRequestError) { | ||
| await respondToInvalidSearch(ctxt, e); | ||
| return; | ||
| } | ||
| throw e; | ||
| } | ||
| } | ||
| let access: RealmAccess = unresolved | ||
| ? { readable: new Set(), scoped: new Map(), failed: unresolved } | ||
| : await withSearchConnectionTenant(ctxt, named, () => | ||
| policyAccess( | ||
| readable, | ||
| consultPolicies | ||
| ? named.filter((realm) => candidates.has(realm)) | ||
| ? [...new Set(named.filter((realm) => candidates.has(realm)))] | ||
| : [], | ||
| invocation, | ||
| principal, | ||
| realmsThatMayNamePolicy, | ||
| mountRealms, | ||
| anonymousCallers && anonymousRequest | ||
| ? { callers: anonymousCallers, request: anonymousRequest } | ||
| : undefined, | ||
| ), | ||
| ); | ||
| // The realms this search does work for: those it reads rows from. Each | ||
|
|
@@ -343,13 +366,21 @@ export default function handleSearch(opts: { | |
| // is a realm mounted when its `realm.json` names no policy: with no policy | ||
| // there is nothing to ask, and it contributes nothing, as a realm whose | ||
| // policy grants nothing does. | ||
| // | ||
| // A request that authenticated nobody is searched in each realm that | ||
| // admitted it (`anonymous`), and each counts the search against the | ||
| // caller's budget there once its grants have scoped it. A realm that turns | ||
| // the caller away, its budget spent or its count unreadable, is failed: its | ||
| // rows are withheld and the result is marked incomplete, while the other | ||
| // realms still answer. | ||
| async function policyAccess( | ||
| readable: Set<string>, | ||
| grantCandidates: string[], | ||
| invocation: SearchInvocation | undefined, | ||
| principal: SearchPrincipal | undefined, | ||
| realmsThatMayNamePolicy: (urls: string[]) => Promise<string[]>, | ||
| mountRealms: MountRealms, | ||
| anonymous?: { callers: Map<string, AnonymousCaller>; request: Request }, | ||
| ): Promise<RealmAccess> { | ||
| let access: RealmAccess = { | ||
| readable, | ||
|
|
@@ -372,15 +403,33 @@ export default function handleSearch(opts: { | |
| access.failed.add(url); | ||
| return; | ||
| } | ||
| let caller = anonymous?.callers.get(url); | ||
| if (anonymous && !caller) { | ||
| return; | ||
| } | ||
| if (caller?.turnedAway) { | ||
| access.failed.add(url); | ||
| return; | ||
| } | ||
| try { | ||
| let scope = await policyQueryScope(realm.operationCore, { | ||
| ...invocation, | ||
| principal, | ||
| transport: 'federated-search', | ||
| }); | ||
| if (scope.kind === 'scoped') { | ||
| access.scoped.set(url, scope.filters); | ||
| if (scope.kind !== 'scoped') { | ||
| return; | ||
| } | ||
| if ( | ||
| anonymous && | ||
| caller && | ||
| (await realm.countAnonymousSearch(anonymous.request, caller)) | ||
| .kind !== 'counted' | ||
|
Comment on lines
+426
to
+427
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a federated payload with a valid Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] Fixed in |
||
| ) { | ||
| access.failed.add(url); | ||
| return; | ||
| } | ||
| access.scoped.set(url, scope.filters); | ||
| } catch (e) { | ||
| // Not a policy that could not be judged but a principal no policy | ||
| // may be asked about, which only broken wiring gets here with. It | ||
|
|
@@ -526,14 +575,7 @@ export default function handleSearch(opts: { | |
| : await parseRequest(); | ||
| } catch (e) { | ||
| if (e instanceof SearchRequestError) { | ||
| // `invalid-query` / `invalid-render` are client request-shape errors | ||
| // → the JSON:API search-error body; anything else (bad method / JSON) | ||
| // → a plain bad request, the same split as the other search handlers. | ||
| if (e.code === 'invalid-query' || e.code === 'invalid-render') { | ||
| await setContextResponse(ctxt, buildSearchErrorResponse(e.message)); | ||
| } else { | ||
| await sendResponseForBadRequest(ctxt, e.message); | ||
| } | ||
| await respondToInvalidSearch(ctxt, e); | ||
| return; | ||
| } | ||
| throw e; | ||
|
|
@@ -1155,3 +1197,18 @@ async function respondWithJobScopedSearchCache( | |
| ); | ||
| emitTelemetry(); | ||
| } | ||
|
|
||
| // The answer to a search whose request is malformed. `invalid-query` and | ||
| // `invalid-render` are client request-shape errors, answered with the JSON:API | ||
| // search-error body; anything else (bad method / JSON) is a plain bad request, | ||
| // the same split as the other search handlers. | ||
| async function respondToInvalidSearch( | ||
| ctxt: Koa.Context, | ||
| e: SearchRequestError, | ||
| ): Promise<void> { | ||
| if (e.code === 'invalid-query' || e.code === 'invalid-render') { | ||
| await setContextResponse(ctxt, buildSearchErrorResponse(e.message)); | ||
| } else { | ||
| await sendResponseForBadRequest(ctxt, e.message); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,18 @@ | ||
| import type Koa from 'koa'; | ||
| import type { DBAdapter, Realm } from '@cardstack/runtime-common'; | ||
| import type { | ||
| AnonymousCaller, | ||
| DBAdapter, | ||
| Realm, | ||
| VirtualNetwork, | ||
| } from '@cardstack/runtime-common'; | ||
| import { | ||
| archivedRealmURLs, | ||
| ensureTrailingSlash, | ||
| fetchRealmPermissions, | ||
| fetchUserPermissions, | ||
| isSessionRevoked, | ||
| logger, | ||
| MAX_REALMS_PER_SEARCH_REQUEST, | ||
| param, | ||
| parseRealmsFromPayload, | ||
| parseSearchRequestPayload, | ||
|
|
@@ -19,12 +26,16 @@ import { | |
| AuthenticationErrorMessages, | ||
| } from '@cardstack/runtime-common/router'; | ||
| import { | ||
| isNamedQueryPayload, | ||
| searchInvocation, | ||
| searchPrincipal, | ||
| type SearchPrincipal, | ||
| } from '@cardstack/runtime-common/card-operations'; | ||
| import type { MatrixClient } from '@cardstack/runtime-common/matrix-client'; | ||
| import RealmPermissionChecker from '@cardstack/runtime-common/realm-permission-checker'; | ||
| import type { RealmRegistryReconciler } from '../lib/realm-registry-reconciler.ts'; | ||
| import { mayNameRealmPolicy } from '../lib/realm-policy-pointer.ts'; | ||
| import { resolveRealmsForFederatedRequest } from '../lib/realm-routing.ts'; | ||
| import { | ||
| retrieveTokenClaim, | ||
| type RealmServerTokenClaim, | ||
|
|
@@ -59,12 +70,19 @@ export type MultiRealmAuthorizationState = { | |
| grantCandidates: string[]; | ||
| // Who the request's token was verified for: the user it names, or a | ||
| // realm-authority principal — a session a realm renders its own cards | ||
| // under. Absent on a request that carried no token, which reached here only | ||
| // because every realm it names is publicly readable. | ||
| // under. For a request that carried no token, an anonymous principal where | ||
| // a realm it names admitted it through its policy (see `anonymousCallers`), | ||
| // and absent where every realm it names is publicly readable. | ||
| principal?: SearchPrincipal; | ||
| // For a request that carried no token, the caller each realm it may search | ||
| // only through its policy admitted (see `Realm.admitAnonymousSearch`). Each | ||
| // counts the search against that caller's budget. These are the request's | ||
| // only grant candidates. | ||
| anonymousCallers?: Map<string, AnonymousCaller>; | ||
| }; | ||
|
|
||
| const MULTI_REALM_AUTH_STATE = 'multiRealmAuthorization'; | ||
| const log = logger('realm-server:multi-realm-authorization'); | ||
| const SEARCH_REQUEST_PAYLOAD_STATE = 'searchRequestPayload'; | ||
|
|
||
| export function multiRealmAuthorization( | ||
|
|
@@ -74,12 +92,16 @@ export function multiRealmAuthorization( | |
| realmSecretSeed, | ||
| realms, | ||
| reconciler, | ||
| realmsRootPath, | ||
| virtualNetwork, | ||
| }: { | ||
| dbAdapter: DBAdapter; | ||
| matrixClient: MatrixClient; | ||
| realmSecretSeed: string; | ||
| realms: Realm[]; | ||
| reconciler: RealmRegistryReconciler; | ||
| realmsRootPath: string; | ||
| virtualNetwork: VirtualNetwork; | ||
| }, | ||
| { | ||
| unreadableRealms = 'refuse', | ||
|
|
@@ -161,6 +183,7 @@ export function multiRealmAuthorization( | |
|
|
||
| let readableRealms = new Set<string>(); | ||
| let principal: SearchPrincipal | undefined; | ||
| let anonymousCallers: Map<string, AnonymousCaller> | undefined; | ||
| let authorization = ctxt.req.headers['authorization']; | ||
| if (!authorization) { | ||
| let publicPermissions = await fetchUserPermissions(dbAdapter, { | ||
|
|
@@ -174,18 +197,38 @@ export function multiRealmAuthorization( | |
|
|
||
| // Authentication comes before authorization, and a request that named | ||
| // nobody is told to say who it is rather than answered for nobody. A | ||
| // policy grants by who is asking, so there is no grant for this request | ||
| // to be judged by either — which is why this refuses where an | ||
| // authenticated caller without permission does not. | ||
| // policy grants by who is asking, so such a request is judged only by | ||
| // the grants that opt in to callers who aren't signed in. On an endpoint | ||
| // that carries the realms a caller can't read, each such realm whose | ||
| // policy opens `query` to these callers, and that doesn't refuse the | ||
| // caller's address, is searched through its policy, and the rest | ||
| // contribute no rows, as they would for a signed-in caller. A named | ||
| // query is granted by its own name, which no grant opens to these | ||
| // callers. A request no realm admits is refused, which is why this | ||
| // refuses where an authenticated caller without permission does not. | ||
| let realmsRequiringAuth = realmList.filter( | ||
| (realmURL) => !readableRealms.has(realmURL), | ||
| ); | ||
| if (realmsRequiringAuth.length > 0) { | ||
| await sendResponseForUnauthorizedRequest( | ||
| ctxt, | ||
| `Authorization required for realms: ${realmsRequiringAuth.join(', ')}`, | ||
| ); | ||
| return; | ||
| let payload = (ctxt.state as Record<string, unknown>)[ | ||
| SEARCH_REQUEST_PAYLOAD_STATE | ||
| ]; | ||
| anonymousCallers = | ||
| unreadableRealms === 'carry' && opensToAnonymousSearch(payload) | ||
| ? await admitAnonymousSearch( | ||
| await notArchived(dbAdapter, realmsRequiringAuth), | ||
| request, | ||
| { reconciler, realmsRootPath, virtualNetwork }, | ||
| ) | ||
| : new Map(); | ||
| if (anonymousCallers.size === 0) { | ||
| await sendResponseForUnauthorizedRequest( | ||
| ctxt, | ||
| `Authorization required for realms: ${realmsRequiringAuth.join(', ')}`, | ||
| ); | ||
| return; | ||
| } | ||
| principal = { kind: 'anonymous' }; | ||
| } | ||
| } else { | ||
| let token: RealmServerTokenClaim & { iat: number; exp: number }; | ||
|
|
@@ -301,20 +344,94 @@ export function multiRealmAuthorization( | |
| if (principal?.kind !== 'realm-authority') { | ||
| let archived = await archivedRealmURLs(dbAdapter, unreadable); | ||
| grantCandidates = unreadable.filter( | ||
| (realmURL) => !archived.has(realmURL), | ||
| (realmURL) => | ||
| !archived.has(realmURL) && | ||
| (!anonymousCallers || anonymousCallers.has(realmURL)), | ||
| ); | ||
| } | ||
|
|
||
| (ctxt.state as Record<string, unknown>)[MULTI_REALM_AUTH_STATE] = { | ||
| realmList: readable, | ||
| grantCandidates, | ||
| ...(principal === undefined ? {} : { principal }), | ||
| ...(anonymousCallers ? { anonymousCallers } : {}), | ||
| } satisfies MultiRealmAuthorizationState; | ||
|
|
||
| await next(); | ||
| }; | ||
| } | ||
|
|
||
| // Whether a search could be opened to a request that authenticated nobody at | ||
| // all, judged before any realm is asked: an ad-hoc search, since a named query | ||
| // is granted by its own name, which no grant opens to such callers, that names | ||
| // a type some grant could be on. | ||
| function opensToAnonymousSearch(payload: unknown): boolean { | ||
| return ( | ||
| !isNamedQueryPayload(payload) && | ||
| (searchInvocation(payload as Parameters<typeof searchInvocation>[0])?.types | ||
| .length ?? 0) > 0 | ||
| ); | ||
| } | ||
|
|
||
| // The realms among `urls` that are not archived. An archived realm is | ||
| // sealed and serves no rows to anyone, so it is never asked to admit anyone. | ||
| async function notArchived( | ||
| dbAdapter: DBAdapter, | ||
| urls: string[], | ||
| ): Promise<string[]> { | ||
| let archived = await archivedRealmURLs(dbAdapter, urls); | ||
| return urls.filter((url) => !archived.has(url)); | ||
| } | ||
|
|
||
| // The realms among `urls` that admit a request that authenticated nobody to a | ||
| // search, each with the caller it admitted. A request naming more realms than | ||
| // a search may fan out to admits none, so the realms a caller who isn't signed | ||
| // in can have the server mount and ask are bounded as a client's search is. A | ||
| // realm whose `realm.json` names no policy is not mounted to ask: with no | ||
| // policy there is nothing that could admit the caller. A realm that won't | ||
| // mount, or can't answer, admits nobody, and the others still answer. | ||
| async function admitAnonymousSearch( | ||
| urls: string[], | ||
| request: Request, | ||
| locate: { | ||
| reconciler: RealmRegistryReconciler; | ||
| realmsRootPath: string; | ||
| virtualNetwork: VirtualNetwork; | ||
| }, | ||
| ): Promise<Map<string, AnonymousCaller>> { | ||
| let admitted = new Map<string, AnonymousCaller>(); | ||
| urls = [...new Set(urls)]; | ||
| if (urls.length > MAX_REALMS_PER_SEARCH_REQUEST) { | ||
| return admitted; | ||
| } | ||
| let named = await Promise.all( | ||
| urls.map((url) => mayNameRealmPolicy(url, locate)), | ||
| ); | ||
| let asked = urls.filter((_url, index) => named[index]); | ||
| let mounted = await resolveRealmsForFederatedRequest( | ||
| locate.reconciler, | ||
| asked, | ||
| ); | ||
|
Comment on lines
+411
to
+414
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A credential-less caller can submit an ad-hoc federated request naming an arbitrarily large set of registered private realms; the only realm-count limit is client-side ( Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] Fixed in
Pinned by "a search naming more realms than a search may fan out to is told to authenticate, and no realm is asked". |
||
| await Promise.all( | ||
| mounted.map(async (realm, index) => { | ||
| let caller: AnonymousCaller | undefined; | ||
| try { | ||
| caller = await realm?.admitAnonymousSearch(request); | ||
| } catch (e) { | ||
| log.warn( | ||
| `${asked[index]} could not say whether it admits a search from a caller who isn't signed in, so it admits none`, | ||
| e, | ||
| ); | ||
| return; | ||
| } | ||
| if (caller) { | ||
| admitted.set(asked[index], caller); | ||
| } | ||
| }), | ||
| ); | ||
| return admitted; | ||
| } | ||
|
|
||
| // Whether a realm lets a user read it, judged as the realm judges a session | ||
| // that carries only `read`. An archived realm is sealed, and lets no one read | ||
| // it. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖] Nit: a realm named twice in
realmsis charged twice for one search.parseRealmsFromPayloaddoesn't remove duplicates.named.filter(candidates.has)keeps them, and so doesrealmsThatMayNamePolicy.mountRealmsreuses the mount, but thisPromise.allstill runspolicyQueryScopeandcountAnonymousSearchonce for each entry. The cost falls only on the caller's own address, so it isn't a security issue. It does break "counted one unit per realm search". Removing duplicates from the candidates beforepolicyAccess(or fromaskedhere) fixes it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖] Fixed in
02a6396f12. The grant candidates handed topolicyAccessare de-duplicated, and so is the admission list in the middleware, so a realm named twice is asked and counted once. Pinned by "a realm named twice is counted once".