Refuse an unentitled caller in a Forge resolver, and prove it with a test that goes red without the gate
Mihai Perdum
Author
15 min readAugust 27, 2026
Key takeaways
END STATE: a resolver that refuses an unentitled caller, proven by a test suite that scores 1/6 against the ungated version and 6/6 against the gated one.
The happy path passes either way. That is why a positive-only test proves nothing about authorization.
Fail CLOSED: a probe that throws, times out, or returns an unexpected shape must refuse, not allow.
A payload page id that disagrees with the surface the caller invoked from is the attack — refuse the disagreement before you even ask about permissions.
Keep the decision a pure function so it runs under plain node, with no deploy, no tunnel and no second test identity.
Forge resolvers are easy to write and easy to get wrong in one specific way. The resolver receives a payload from the front end, pulls an id out of it, and does the work. If that work is a privileged write — asApp() rather than asUser() — then the app's own permissions are doing the writing, and the id came from whoever called you.
We shipped one of those. The resolver took pageId from the payload, read the page, rewrote its content and bumped its version, all through asApp(), with nothing checking that the caller was allowed to touch that page. The function immediately below it in the same file did gate its caller. That asymmetry is what makes it obvious in hindsight and invisible while you're writing it.
By the end of this you'll have a gate that refuses an unentitled caller, and — more to the point — a test that goes red without the gate. That second half is what separates a fix from a belief.
Note
Prerequisites
Node 18 or later. Verified here on v24.15.0.
A text editor and an empty directory. That's genuinely all — every step below runs locally.
No Forge app, no forge deploy, no tunnel and no second Atlassian account. The decision logic is written as a pure function precisely so you can test the part that matters without any of that.
Familiarity with what a Forge resolver is. If you've written one resolver.define() you're fine.
About thirty minutes.
Why the obvious test is worthless here
Before the steps, the thing that makes authorization bugs survive test suites.
Write the natural test for a sealing resolver and it looks like this: call it as a user who's allowed to seal, assert the seal appears. That test passes. It passed for us, for weeks, while the resolver would have accepted a page id from anybody.
It passes because the happy path is identical whether or not the gate exists. An entitled caller gets through a gate, and an entitled caller gets through no gate. The only test that can tell those two worlds apart is one where the caller is not entitled — and that's the test nobody writes, because it's the awkward one that needs a second identity.
So the trick is to move the decision out of the resolver, where it needs a real tenant and a real second account, and into a pure function, where it needs neither. You lose the ability to test that Confluence agrees with you. You gain the ability to test every refusal path, including the ones that only happen when the permission check itself misbehaves — which, as it turns out, is where the interesting failures live.
1
Write the decision as a pure function
take the resolver's authorization choice out of the resolver, so it can run under plain node with no deploy.
2
Prove the test can fail
run the suite against an ungated version first and watch it go red. A test that has never failed is a test you cannot trust.
3
Refuse a payload id that disagrees with the caller's surface
the cheapest guard, and it kills the attack before any network call.
4
Make the probe tri-state so the gate fails closed
treat "I could not ask" as a refusal, never as permission.
5
Wire the gate into the resolver ahead of every side effect
one evaluation, before the retry loop and before any state write.
6
Run the full suite and confirm 6 of 6
both the negative cases and the positive one, so you know you haven't fixed it into uselessness.
Step 1 — Write the decision as a pure function
Create a directory and put this in gate.mjs. Note what it doesn't import: nothing from @forge/api, nothing from @forge/kvs, nothing at all.
That's deliberately incomplete — Steps 3 and 4 add the two guards it's missing. Start here because it's the shape most people would write, and it's worth seeing it fail.
probe is injected rather than imported. In production it's a function that asks Confluence whether this account may edit this page; in the tests it's three lines. That injection is the whole reason this is testable without a tenant.
You should see function. If you get ERR_MODULE_NOT_FOUND, you're in the wrong directory; if you get a syntax error about export, rename the file to .mjs or add "type": "module" to a package.json.
Step 2 — Prove the test can fail
Here's the part most guides skip. Write the test suite, then run it against a version you know is broken, and confirm it goes red. A test suite that has only ever been run against correct code is an assertion about nothing.
Put this in gate.test.mjs:
javascript
1importassertfrom"node:assert/strict";2import{ decide }from"./gate.mjs";34constOWNER="acc-owner",STRANGER="acc-stranger",PAGE="111",OTHER="999";5constprobe=(acc)=> acc ===OWNER;// only the owner may edit6const ctx ={contextPageId:PAGE, probe };78let pass =0, fail =0;9constt=(name, fn)=>{10try{fn(); pass++;console.log(` ok ${name}`);}11catch(e){ fail++;console.log(` FAIL ${name}\n ${e.message}`);}12};1314// THE NEGATIVE CASE — this is the test that matters.15t("refuses a caller with no edit rights",()=>16 assert.equal(decide({...ctx,payloadPageId:PAGE,accountId:STRANGER}).allow,false));1718t("refuses a payload pageId aimed at another page",()=>19 assert.equal(decide({...ctx,payloadPageId:OTHER,accountId:OWNER}).why,"page-mismatch"));2021t("refuses when the probe cannot answer",()=>22 assert.equal(decide({...ctx,payloadPageId:PAGE,accountId:OWNER,23probe:()=>undefined}).why,"probe-indeterminate"));2425t("refuses when the probe throws",()=>26 assert.equal(decide({...ctx,payloadPageId:PAGE,accountId:OWNER,27probe:()=>{thrownewError("429");}}).why,"probe-threw"));2829t("refuses an anonymous caller",()=>30 assert.equal(decide({...ctx,payloadPageId:PAGE,accountId:null}).allow,false));3132// THE POSITIVE CASE — do not fix it into uselessness.33t("still allows the entitled caller",()=>34 assert.equal(decide({...ctx,payloadPageId:PAGE,accountId:OWNER}).allow,true));3536console.log(`\ngate: ${pass}/${pass + fail} passed`);37process.exit(fail ?1:0);
Now build the thing it should reject. Put this in gate-ungated.mjs — it's the resolver as we shipped it, reduced to its decision:
How you know it worked. You should see five failures, one pass, and a non-zero exit. This is the real output:
text
1 FAIL refuses a caller with no edit rights
2 Expected values to be strictly equal:
34true !== false
56 FAIL refuses a payload pageId aimed at another page
7 Expected values to be strictly equal:
8+ actual - expected
910+ 'no-gate'
11- 'page-mismatch'
1213 FAIL refuses when the probe cannot answer
14 FAIL refuses when the probe throws
15 FAIL refuses an anonymous caller
16 ok still allows the entitled caller
1718gate: 1/6 passed
19exit=1
Look at the last two lines before the score. The positive case passes against the completely ungated version. That's the whole argument of this tutorial sitting in one line of output: if your suite had only contained that test, it would be green right now, against a resolver that writes to any page anybody names.
If instead you got 6/6 here, your sed didn't take — check that gate-ungated.test.mjs imports ./gate-ungated.mjs and not ./gate.mjs.
Step 3 — Refuse a payload id that disagrees with the surface
Now start closing it. The first guard costs nothing and needs no network call.
A Forge resolver invoked from a page surface knows which page it's on, independently of what the caller sent. The front end reads that from the extension context. So when a payload id and a context id disagree, the caller is aiming the resolver at a page they didn't open — which is the attack, stated exactly.
Two things worth being careful about. Only refuse when you have both ids — a resolver legitimately invoked with only one of them shouldn't be caught by this. And this guard is not sufficient on its own, because a caller who omits the context id entirely walks straight past it. It's cheap depth, not the load-bearing check.
You should see page-mismatch. If you see entitled, the guard is sitting below the probe call instead of above it, and a permitted caller is bypassing it.
Step 4 — Make the probe tri-state so the gate fails closed
This is the step that matters most and the one that's easiest to write wrongly.
Your probe asks a remote service a question. Remote services return 429s, time out, get rate limited, return a 200 with a body shape you didn't expect, and occasionally answer a slightly different question than the one you asked. A boolean has no room to express any of that — and if probe() throws, or returns undefined, a naive if (verdict === false) treats it as permission.
Read that again, because it's the actual bug: checking only for an explicit false means every non-answer is a yes.
Three states, not two: yes, no, and I could not ask. The third refuses.
Distinguishing "denied" from "indeterminate" isn't cosmetic. They're the same outcome for the caller and completely different for you — one means the gate is working, the other means your permission check is broken and everyone is being refused. Log them differently, or you'll spend a morning debugging an outage that presents as a working security control.
You should see probe-threw, probe-indeterminate, probe-indeterminate. That third one is the interesting one — a probe returning the string"true" is refused, because a truthy value is not an answer. If any of those prints entitled, the gate fails open and everything after this is decoration.
The probe that always says yes
One more failure mode, and it's the nastiest because everything looks fine.
Your probe calls some permission endpoint and gets back an affirmative. You allow the write. But suppose that endpoint doesn't actually answer the question you think it does — suppose it reports on the ambient user rather than the account you named, or it returns a blanket affirmative for any subject when the app principal lacks the privilege to ask about other people, or it quietly ignores the subject parameter entirely.
In every one of those cases your probe returns true for everybody. The gate is present, the code reads correctly, the tests pass, and it authorizes nothing.
The check for this is short: a probe never observed returning false has not been tested. Before you trust an affirmative for your caller, confirm the same endpoint, on the same page, in the same call, demonstrably says no to somebody. Issue a control probe for a subject you know should be refused — an anonymous or unprivileged principal — alongside the real one. If the control doesn't come back negative, you haven't learned that your caller is entitled; you've learned that this endpoint says yes to things, which is worth nothing.
Two subtleties. Run them in parallel so the control costs one round trip rather than doubling your latency. And treat an inconclusive control as indeterminate rather than as denial — you want the logs to distinguish "this user isn't allowed" from "I can't tell whether anyone is allowed", because the second is an outage and the first is Tuesday.
There's a cost to being this careful, and it's worth naming rather than pretending away: a gate this strict fails closed loudly. If the control probe misbehaves in production, legitimate users stop being able to act, and you'll hear about it within the hour. That's the correct direction to fail, but it means the probe belongs in your monitoring, not just your tests.
Writing the real probe
The pure function takes probe as an argument, so what you inject in production is the only part that touches Atlassian. Two approaches, and which one fits depends on what your resolver already has.
The direct approach asks a content-permission endpoint whether a named account may perform an operation on a piece of content. It works for a subject other than yourself, which is what a gate needs, and it accounts for site permissions, space permissions and content restrictions in one answer. The catch is that naming a subject other than yourself may itself require elevated privilege, which is exactly the situation the control probe above exists to detect.
The indirect approach doesn't ask at all — it makes the caller's own authority do the work. Instead of writing through asApp(), write through asUser() and let Confluence refuse it. This is the most robust option available and it needs no permission endpoint, because there's no way to get it wrong: if the user can't edit the page, the write fails. The reason it isn't always available is that plenty of apps genuinely need app authority for the write itself — to touch a page the user can read but not edit, or to act during a trigger where there's no user at all.
There's a hybrid worth knowing about. Use the caller's authority for a cheap read on the target first, and only then do the privileged write. It doesn't prove edit permission, so it's weaker than it looks, but it does prove the caller can see the page — which already stops the "aim it at any page id in the tenant" version of the attack.
Whichever you pick, bind it to the account you're deciding about. If your probe reports on whatever identity happens to be ambient, and you're making a decision about req.context.accountId, those two coincide today and nothing enforces that they always will. Compare them explicitly and refuse when they diverge.
Troubleshooting
Symptom
Most likely cause
Suite passes 6/6 against the ungated file
The sed copy still imports ./gate.mjs — check the import line
page-mismatch never fires
The guard sits below the probe call instead of above it
Everyone is refused, including you
Probe returning a non-boolean; look for probe-indeterminate in the logs, not denied
Gate allows a caller it shouldn't
Checking only === false, so every non-answer is being read as permission
Denied users report "nothing happens"
The decline is being swallowed into console.warn instead of surfacing in the UI
Latency doubled after adding the gate
Control probe issued sequentially instead of in parallel, or the gate is inside the retry loop
Works in tests, authorizes everything live
The probe never actually returns false — add the control probe
8 rows × 2 columnsHeader row enabled
Step 5 — Wire the gate into the resolver ahead of every side effect
Now the real resolver. Position matters as much as the check.
javascript
1resolver.define("seal-section",async(req)=>{2const decision =decide({3payloadPageId: req.payload?.pageId,4contextPageId: req.context?.extension?.content?.id,5accountId: req.context?.accountId,6probe:(acc, page)=>canEdit(acc, page),7});8if(!decision.allow){9console.warn(`[seal] DENIED page=${req.payload?.pageId} why=${decision.why}`);10return{success:false,reason:"You do not have permission to do that here."};11}12// …only now: read the page, write it, store records…13});
Four details, each of which we got wrong at least once.
Evaluate once, at the top. If the resolver has a retry loop around a 409-prone write, the gate goes outside it. Inside, you re-ask the same question up to three times for no benefit and burn your function budget.
Before any state write, not just before the page write. A gate that sits after kvs.set has already let the caller create a record.
Return the same refusal for every reason. The message above doesn't say whether the page exists, whether it was a permission problem, or whether the probe fell over. Detailed refusals are an enumeration oracle — the internal why goes to the log, not to the response.
Surface the decline in the UI. Ours originally swallowed it into a console.warn, so a denied user watched the spinner stop and nothing happen. That reads as a broken feature rather than a refusal, and it generates support tickets that look like bugs.
How you know it worked: deploy and invoke it as a user without edit rights on the target page, then check the logs.
bash
1forge logs --since 10m |grep'\[seal\] DENIED'
You want one DENIED line with a why that says denied, not probe-indeterminate. Seeing probe-indeterminate here means the gate is refusing everybody, including legitimate users — which is Step 4's failure mode, and it will look like a working security control right up until someone complains.
Where else this hole is hiding
Before the final run, a short audit of your own app, because a gate on one resolver is rarely the fix.
Go through every resolver you expose and ask two questions of each. Does it perform a privileged write — anything through asApp() that mutates content, storage or configuration? And does any identifier controlling what it writes to come from the payload rather than from the invocation context?
Every resolver answering yes to both needs a gate. In our case that turned up a second one we hadn't been looking at, in a completely different module, doing a privileged content mutation on a payload-supplied page id. It was written for a different surface and nobody thought of it as a security-relevant path.
The reason it was reachable is worth understanding, because it's a common Forge shape. Apps often collect their resolvers into a shared registry that flattens every capsule or module into a single function. That's tidy, and it means the surface a resolver was written for has no bearing on who can call it. Anyone authenticated on the site can invoke any key in that registry with any payload. A resolver you think of as "the admin panel's" is not the admin panel's — it's everyone's, and the only thing making it the admin panel's is a gate you wrote.
Two shortcuts for finding them. Grep for your privileged-write helper and read every call site — the ones taking an id from req.payload are your list. And grep for req.payload across the resolver files, then check each hit for whether the value ends up steering a write.
While you're in there, check the sibling functions. Ours had a gated unseal sitting directly below an ungated seal, in the same file, written by the same person on the same day. Asymmetry between two functions that should have matching guards is the single highest-signal thing to look for, and it's visible by eye. If one function checks entitlement and its counterpart doesn't, one of them is wrong, and it usually isn't the one doing the checking.
Step 6 — Run the full suite and confirm 6 of 6
The last step is the one that closes the loop: the same suite that scored 1/6 in Step 2, run against the finished gate.
bash
1node gate.test.mjs;echo"exit=$?"
How you know it worked. Six passes and a zero exit. Real output:
text
1 ok refuses a caller with no edit rights
2 ok refuses a payload pageId aimed at another page
3 ok refuses when the probe cannot answer
4 ok refuses when the probe throws
5 ok refuses an anonymous caller
6 ok still allows the entitled caller
78gate: 6/6 passed
9exit=0
The last line is the one that stops you overcorrecting. Five refusals and no allow means you've built something that denies everyone, which passes every negative test and ships an app nobody can use. Both halves have to be green.
Keep both files. When the gate changes, run the suite against the ungated version again and confirm it still goes red — a suite that stops being able to fail has quietly stopped being a suite.
What the gate costs
Worth being concrete about the price, because "add a permission check" sounds free and isn't.
Each gated resolver call now makes at least one extra HTTP request before it does any work, and two if you're running the control probe. Issued in parallel that's one additional round trip, which on a Forge function is typically tens to low hundreds of milliseconds. The denial path is cheaper than the happy path, because it short-circuits everything downstream, so the cost only lands on legitimate traffic.
That matters more than it sounds if your resolver already sits near its invocation budget. Forge functions have a hard ceiling, and a handler doing several reads, a write, and a retry loop with backoff can be closer to it than you'd guess. Adding an unconditional round trip to something already tight is how a working feature starts intermittently timing out — and a timeout presents as "sometimes nothing happens", which is a miserable thing to debug.
Three ways to keep it cheap. Evaluate the gate once, outside any retry loop — three attempts at the same question cost three times as much and tell you the same thing. Issue the subject and control probes concurrently rather than in sequence. And if your resolver reads the target page anyway as its first real action, fold the entitlement question into that read where the API lets you, instead of paying for a separate call.
What you should not do is cache the answer across invocations to save the round trip. Permissions change, and a cached affirmative is a gate that keeps letting someone in after they've lost access. If you must cache, cache within a single invocation, where the window is milliseconds and the value can't outlive the decision it was made for.
What this doesn't cover
The pure function tests your decision logic. It doesn't test that Confluence agrees with your notion of "entitled", and it can't — that needs a real tenant, a real second account, and a live test. Ours runs in a browser-driven harness against a sandbox site for exactly that reason, and it's the slower half of the work.
What you've got here is the half that catches most of the bugs, runs in under a second, and needs nothing installed. The remote half catches the rest, and it's worth doing after this rather than instead of it — because if your decision logic is wrong, a live test just tells you slowly.
One more caution. If your app exposes resolvers through a shared registry that flattens every module into one function, then every key in that registry is reachable by any authenticated invoker, regardless of which surface it was written for. We found a second resolver with the identical hole that way. Gating the one you know about isn't the job; gating every resolver that does a privileged write on a caller-supplied id is.
[[takeaways]] You now have a resolver whose authorization decision is a pure function, a suite of five refusal tests and one permission test, and — the part that makes it worth anything — proof that the suite scores 1/6 against the ungated version and 6/6 against the gated one. You proved the test can fail before you trusted it passing.
The habit that generalises past Forge: when you write a guard, write the version without it and run your tests against that first. If they stay green, you haven't tested the guard, you've tested that your code still works. Those feel identical in a terminal and they are not the same thing.