Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions apps/relay/src/access.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,47 @@
import { parseHostRoutingKey } from "@superset/shared/host-routing";
import { LRUCache } from "lru-cache";
import { createApiClient } from "./api-client";
import type { AuthContext } from "./auth";

const ALLOWED_TTL_MS = 15 * 60 * 1000;
const DENIED_TTL_MS = 30 * 1000;

// Cache by (userId, hostId), not (token, hostId). Tokens rotate on every JWT
// refresh while the underlying user→host authorization is stable, so a
// token-keyed cache effectively expires with each refresh and burns
// host.checkAccess calls on the API for no reason.
const allowedCache = new LRUCache<string, true>({
max: 50_000,
ttl: 5 * 60 * 1000,
ttl: ALLOWED_TTL_MS,
});
const deniedCache = new LRUCache<string, true>({
max: 10_000,
ttl: DENIED_TTL_MS,
});

export async function checkHostAccess(
auth: AuthContext,
token: string,
hostId: string,
): Promise<boolean> {
const key = `${token}:${hostId}`;
// Short-circuit "not in org" locally: the API does this same check from
// the JWT before hitting the DB, so the round trip is wasted.
const parsed = parseHostRoutingKey(hostId);
if (!parsed) return false;
if (!auth.organizationIds.includes(parsed.organizationId)) return false;

const key = `${auth.sub}:${hostId}`;
if (allowedCache.has(key)) return true;
if (deniedCache.has(key)) return false;
Comment on lines +27 to +35
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Short-circuit fires before the deniedCache check, but order is reversed from what you might expect

The org short-circuit (!auth.organizationIds.includes(parsed.organizationId)) is evaluated before the deniedCache lookup. Consider the transition: user is in the org + gets API-denied → deniedCache["userId:hostId"] is populated → user is later removed from the org → their new JWT no longer contains the org → subsequent requests hit the short-circuit and return false before ever reaching deniedCache. The stale deniedCache entry becomes permanently unreachable for that user+host pair and will sit there until TTL expires. This is harmless (the short-circuit is cheap and correct), but the dead deniedCache slot wastes memory in the 10k-entry LRU during those 30 seconds. Low impact, but worth being aware of if the org-removal→re-add cycle is frequent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/relay/src/access.ts
Line: 27-35

Comment:
**Short-circuit fires before the `deniedCache` check, but order is reversed from what you might expect**

The org short-circuit (`!auth.organizationIds.includes(parsed.organizationId)`) is evaluated before the `deniedCache` lookup. Consider the transition: user is in the org + gets API-denied → `deniedCache["userId:hostId"]` is populated → user is later removed from the org → their new JWT no longer contains the org → subsequent requests hit the short-circuit and return `false` before ever reaching `deniedCache`. The stale `deniedCache` entry becomes permanently unreachable for that user+host pair and will sit there until TTL expires. This is harmless (the short-circuit is cheap and correct), but the dead `deniedCache` slot wastes memory in the 10k-entry LRU during those 30 seconds. Low impact, but worth being aware of if the org-removal→re-add cycle is frequent.

How can I resolve this? If you propose a fix, please make it concise.


try {
const client = createApiClient(token);
const result = await client.host.checkAccess.query({ hostId });
const ok = result.allowed && result.paidPlan;
if (ok) {
allowedCache.set(key, true);
} else {
deniedCache.set(key, true);
}
return ok;
} catch {
Expand Down
4 changes: 2 additions & 2 deletions apps/relay/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ const authMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
return c.json({ error: "Host not connected" }, 503);
}

const hasAccess = await checkHostAccess(token, hostId);
const hasAccess = await checkHostAccess(auth, token, hostId);
if (!hasAccess) return c.json({ error: "Forbidden" }, 403);

c.set("auth", auth);
Expand Down Expand Up @@ -140,7 +140,7 @@ app.get(
return;
}

const hasAccess = await checkHostAccess(token, hostId);
const hasAccess = await checkHostAccess(auth, token, hostId);
if (!hasAccess) {
ws.close(1008, "Forbidden");
return;
Expand Down
Loading