-
Notifications
You must be signed in to change notification settings - Fork 6
fix(nextly): authorize the draft a preview token hands out #680
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
30be11d
89fa7bc
2de3186
b26249d
bff1e2c
04b8aab
557239c
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 |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| /** | ||
| * Access gate for minting a preview link. | ||
| * | ||
| * A preview token is a bearer credential: whoever holds it reads the entry it | ||
| * names, with no session of their own, and the runtime consumes it with | ||
| * `draft: true` and `overrideAccess: true`. So minting must authorize the view | ||
| * the token will actually hand out, not a weaker one that happens to be easier | ||
| * to ask for. | ||
| * | ||
| * Two questions, because the token's view needs both and they are decided by | ||
| * different rules: | ||
| * | ||
| * 1. **May this caller read this entry at all** — including one that has never | ||
| * been published, which is the case previews exist for. | ||
| * 2. **May this caller edit this entry** — which is what the draft overlay | ||
| * itself requires before surfacing a working draft. | ||
| * | ||
| * Both are asked through `collectionsHandler`, the instance that actually | ||
| * serves collection reads and writes, so a verdict here is the verdict the real | ||
| * operation would reach. This mirrors `api/versions-access.ts`, which gates | ||
| * version history the same way and for the same reason. | ||
| * | ||
| * @module api/preview-access | ||
| */ | ||
|
|
||
| import type { AuthenticatedScope } from "../auth/authenticated-scope"; | ||
| import { getService } from "../di"; | ||
| import type { UserContext } from "../domains/singles/types"; | ||
| import { errorFromServiceEnvelope } from "../errors/from-service-envelope"; | ||
| import { NextlyError } from "../errors/nextly-error"; | ||
|
|
||
| /** | ||
| * Turn a read outcome into a verdict, keeping "denied" and "could not ask" | ||
| * apart. | ||
| * | ||
| * A 403 or 404 is an answer: this caller does not get this row, and the two are | ||
| * deliberately collapsed because telling them apart would reveal which entry ids | ||
| * exist. | ||
| * | ||
| * Anything else is the read failing rather than refusing, and it keeps the | ||
| * status the service gave it. Flattening every other outcome to 500 would erase | ||
| * what the caller needs to act on — a rate limit's 429 and its retry interval | ||
| * become an opaque server error, and a validation failure loses its field | ||
| * detail. The shared converter rebuilds the service's own error, which is what | ||
| * the Direct API boundary does with the same envelope. | ||
| */ | ||
| function readVerdict(read: { | ||
| success: boolean; | ||
| statusCode: number; | ||
| code?: string; | ||
| message?: string; | ||
| messageKey?: string; | ||
| publicData?: unknown; | ||
| }): boolean { | ||
| if (read.success) return true; | ||
| if (read.statusCode === 403 || read.statusCode === 404) return false; | ||
| throw errorFromServiceEnvelope( | ||
| { ...read, message: read.message ?? "Preview authorization read failed" }, | ||
| { reason: "preview-mint-probe-failed" } | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Confirm the caller may be handed a preview link for this entry. | ||
| * | ||
| * Throws `forbidden` when they may not, with the same answer for a row hidden by | ||
| * a rule, an id that matches nothing, and an entry the caller may read but not | ||
| * edit. Those are different reasons and one answer on purpose: a caller who is | ||
| * refused a link learns only that they are refused. | ||
| * | ||
| * @throws {NextlyError} `forbidden` when no link may be minted. | ||
| */ | ||
| export async function assertEntryPreviewable( | ||
| collection: string, | ||
| entryId: string, | ||
| user: UserContext, | ||
| actor?: AuthenticatedScope | ||
| ): Promise<void> { | ||
| const collections = getService("collectionsHandler"); | ||
|
|
||
| // Enforced and as the caller, which is the same evaluation the bearer's read | ||
| // will face. `status: "all"` is the part a plain by-id read cannot express: a | ||
| // status-enabled collection otherwise filters to published only, so an entry | ||
| // that has never been published reports as missing — exactly the entry an | ||
| // editor most wants to share for review. | ||
| const read = await collections.getEntry({ | ||
| collectionName: collection, | ||
| entryId, | ||
| depth: 0, | ||
| overrideAccess: false, | ||
| user, | ||
|
Comment on lines
+86
to
+91
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.
When a read AGENTS.md reference: AGENTS.md:L201-L204 Useful? React with 👍 / 👎.
Collaborator
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. Confirmed, and this defeats the fix I just made — recording that plainly rather than claiming partial credit.
That means my fix narrowed one gap (raw id vs read id, same context) and left the real one open (minter context vs bearer context). It is strictly better than before and it is not sufficient, and it would be wrong to let the resolved thread above imply otherwise. This is #601's open finding "Keep hook resolution identical when consuming preview links", which merged unfixed. It belongs there, not here, because the fix is not at the mint at all — it is making the bearer's read resolve hooks the way the authorized read did, which means the preview consumption path carries the minter's identity rather than reading as an anonymous trusted caller. Leaving unresolved. Recorded in One consequence worth flagging for whoever takes it: the same reasoning applies to |
||
| authenticatedScope: actor, | ||
| status: "all", | ||
|
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 never-published entry, AGENTS.md reference: packages/nextly/AGENTS.md:L23-L24 Useful? React with 👍 / 👎.
Collaborator
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. Confirmed as a real defect, and deliberately not fixed in this PR — left unresolved so it stays visible. This is #601's finding 4, which merged unfixed. Your framing is sharper than the original: a successful Why it is not fixed here. The founder decided the shape: the preview read enforces the minter's field access, rather than refusing to mint. That is not a change to this gate — it is a change to how the bearer's read is performed, in That last one is not pedantry. #650 shipped a fix whose two tests both asserted through Landing that inside a PR whose subject is the mint gate would mix a route-layer redaction change into an access-gate fix and make both harder to review. It needs its own PR, and its tests belong beside the routing entry points rather than at the mint. Recorded with the four-entry-point gate and the #650 precedent in
Comment on lines
+90
to
+93
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 status-enabled, never-published entry hidden by a custom read rule that returns a query constraint, AGENTS.md reference: AGENTS.md:L201-L204 Useful? React with 👍 / 👎. |
||
| }); | ||
|
|
||
| if (!readVerdict(read)) { | ||
| throw NextlyError.forbidden({ | ||
| logContext: { | ||
| reason: "preview-link-entry-not-visible", | ||
| collection, | ||
| entryId, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| // The token's view is the DRAFT, and the draft overlay surfaces a pending | ||
| // working draft only to a caller trusted to edit the document. Reading the | ||
| // published row proves nothing about that: where a collection allows broad | ||
| // reads but restricts updates per row, a caller can read another author's | ||
| // published entry and would otherwise mint a credential exposing that | ||
| // author's unpublished edits. | ||
| // | ||
| // The subject is the REQUESTED id, deliberately — not one derived from the | ||
| // returned document. `read.data` is presentation data: `afterRead` may remove | ||
| // `id` or rewrite it to another row's, so no value in it can be trusted as the | ||
| // identity of what was fetched, and deriving the gate's subject from it | ||
| // authorized a row the bearer will not receive whenever a hook reshaped that | ||
| // field. | ||
| // | ||
| // The token signs the requested id, so that is the id whose editability is | ||
| // asserted here. Where a `beforeOperation` hook maps ids, the bearer's read | ||
| // resolves in ITS OWN context — `user` undefined, `overrideAccess: true` — | ||
| // which this boundary cannot observe. Closing that needs the consumption path | ||
| // to carry the minter's identity rather than a better guess here. | ||
| // | ||
| // `routeAuthorized: true`: the mint route already ran | ||
| // `requireRouteCollectionAccess(req, "update", collection)`, and this flag | ||
| // skips ONLY that coarse RBAC/code-access gate. The stored owner-only, | ||
| // role-based and custom rules still evaluate against the loaded document with | ||
| // the real user, which is the part that answers this question. | ||
|
|
||
| const mayEdit = await collections.canUpdateEntry({ | ||
| collectionName: collection, | ||
| entryId, | ||
| user, | ||
| routeAuthorized: true, | ||
|
Comment on lines
+132
to
+136
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.
When a stored custom update rule throws or its metadata lookup fails, Useful? React with 👍 / 👎.
Collaborator
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. Confirmed, and not fixable at this boundary — leaving unresolved rather than closing it with a workaround. Verified: That makes it the same defect class as the read side, which I DID fix on this PR — So the fix belongs in Recorded in Worth noting the consequence you named is real and user-visible: an outage in a custom update rule currently reports to the operator as a permission denial, which sends them looking at roles rather than at the rule that threw. |
||
| authenticatedScope: actor, | ||
| }); | ||
|
|
||
| if (!mayEdit) { | ||
| throw NextlyError.forbidden({ | ||
| logContext: { | ||
| reason: "preview-link-draft-not-editable", | ||
| collection, | ||
| entryId, | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
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.
When the entry contains a relationship to a row the caller cannot read, this depth-0 probe authorizes only the root document, but the standard preview resolver defaults to depth 1 and consumes the token through
findByIDwithoverrideAccess: true. Relationship expansion therefore skips the target collection's row-level read rules and can return the related row—including an unpublished one—even though the minting caller would receive only its reference ID; either authorize the same populated graph the bearer receives or prevent trusted relationship expansion for token-backed reads.AGENTS.md reference: packages/nextly/AGENTS.md:L23-L24
Useful? React with 👍 / 👎.
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.
Confirmed, and deliberately not fixed here — this is #601's finding 6, owned by task 186 and the read-path lane's #671.
Your description matches the finding as filed: the probe reads at
depth: 0so relations stay as reference ids, while the preview resolver defaults to depth 1 and consumes the token through a trusted read, so expansion skips the target collection's row-level rules.Why it is not fixed in this PR, and it is not scope avoidance. #650 already merged a bound on relationship expansion. Another lane has #671 open specifically to make that bound complete, with the required-field change measured at 35 construction sites across five files. Shipping a third independently-derived bound from a preview PR is how two rules end up disagreeing about the same behaviour — which is the failure #650 itself demonstrates.
Deferred to task 186 by explicit agreement with that lane, and named in #601's thread rather than closed, so it stays visible if 186 stalls.
One thing from this PR that the owner should have. The four-entry-point gate on any such fix is a hard requirement:
ContentPage,generateMetadata,generateStaticParams(which callsnextly.finddirectly and does NOT go throughresolveContent), and the working-draft overlay's by-id re-read. #650's two tests both asserted throughContentPage, which is exactly why the static path survived it. Mutate the fix and confirm four failures, not one.Recorded in
tasks/left-tasks/192-*.mdalongside the observation that this, the hook-context finding above, and field-level redaction share one root: the preview consumption path discards the minter's identity and reads as an anonymous trusted caller. Each is a different consequence of that single fact, which is worth knowing before anyone fixes them individually.