Skip to content

Commit 41f5f05

Browse files
authored
Enforce user-scoped isolation for private data and webhook triggers (#606)
* Enforce hosted current-user data isolation * Enforce user-scoped isolation across private data paths * Fix hosted resume previews and null-owner uniqueness
1 parent 1161c6a commit 41f5f05

43 files changed

Lines changed: 1729 additions & 540 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

orchestrator/src/client/api/settings-profile.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ export async function deleteDesignResumePicture(input?: {
122122
});
123123
}
124124

125+
export async function getDesignResumeAssetContentBlob(
126+
assetUrl: string,
127+
): Promise<Blob> {
128+
return fetchBlobApi(normalizeApiPath(assetUrl), { cache: "no-store" });
129+
}
130+
125131
export async function exportDesignResume(): Promise<DesignResumeExportResponse> {
126132
return fetchApi<DesignResumeExportResponse>("/design-resume/export");
127133
}

orchestrator/src/client/components/design-resume/DesignResumeInlineSections.test.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
1-
import { fireEvent, render, screen } from "@testing-library/react";
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
22
import { describe, expect, it, vi } from "vitest";
3-
import { BasicsCustomFieldsSection } from "./DesignResumeInlineSections";
3+
4+
const mocks = vi.hoisted(() => ({
5+
getDesignResumeAssetContentBlob: vi.fn(),
6+
}));
7+
8+
vi.mock("@client/api/settings-profile", () => ({
9+
getDesignResumeAssetContentBlob: mocks.getDesignResumeAssetContentBlob,
10+
}));
11+
12+
import {
13+
BasicsCustomFieldsSection,
14+
PictureSection,
15+
} from "./DesignResumeInlineSections";
416

517
describe("BasicsCustomFieldsSection", () => {
618
it("lets the custom fields section title be renamed", () => {
@@ -44,4 +56,43 @@ describe("BasicsCustomFieldsSection", () => {
4456
{ id: "field-1", title: "Availability", icon: "", text: "", link: "" },
4557
]);
4658
});
59+
60+
it("loads JobOps asset picture previews through the authenticated blob API", async () => {
61+
const createObjectUrl = vi
62+
.spyOn(URL, "createObjectURL")
63+
.mockReturnValue("blob:preview");
64+
const revokeObjectUrl = vi
65+
.spyOn(URL, "revokeObjectURL")
66+
.mockImplementation(() => {});
67+
mocks.getDesignResumeAssetContentBlob.mockResolvedValue(
68+
new Blob(["image"], { type: "image/png" }),
69+
);
70+
71+
const { unmount } = render(
72+
<PictureSection
73+
picture={{ url: "/api/design-resume/assets/asset-1/content" }}
74+
pictureUploading={false}
75+
pictureEnabled={true}
76+
onUploadPicture={vi.fn()}
77+
onDeletePicture={vi.fn()}
78+
onUpdatePicture={vi.fn()}
79+
/>,
80+
);
81+
82+
await waitFor(() => {
83+
expect(
84+
screen.getByAltText("Resume Studio profile").getAttribute("src"),
85+
).toBe("blob:preview");
86+
});
87+
88+
expect(mocks.getDesignResumeAssetContentBlob).toHaveBeenCalledWith(
89+
"/api/design-resume/assets/asset-1/content",
90+
);
91+
92+
unmount();
93+
expect(revokeObjectUrl).toHaveBeenCalledWith("blob:preview");
94+
95+
createObjectUrl.mockRestore();
96+
revokeObjectUrl.mockRestore();
97+
});
4798
});

orchestrator/src/client/components/design-resume/DesignResumeInlineSections.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { getDesignResumeAssetContentBlob } from "@client/api/settings-profile";
12
import type { DesignResumeJson } from "@shared/types";
23
import { FileImage, ImagePlus, Plus, Trash2 } from "lucide-react";
4+
import { useEffect, useState } from "react";
35
import { Alert, AlertDescription } from "@/components/ui/alert";
46
import { Button } from "@/components/ui/button";
57
import { Input } from "@/components/ui/input";
@@ -92,6 +94,41 @@ export function PictureSection({
9294
onUpdatePicture,
9395
}: PictureSectionProps) {
9496
const editDisabled = !pictureEnabled;
97+
const pictureUrl = toText(picture.url);
98+
const [previewUrl, setPreviewUrl] = useState(pictureUrl);
99+
100+
useEffect(() => {
101+
if (!pictureUrl) {
102+
setPreviewUrl("");
103+
return;
104+
}
105+
106+
if (!pictureUrl.startsWith("/api/design-resume/assets/")) {
107+
setPreviewUrl(pictureUrl);
108+
return;
109+
}
110+
111+
let objectUrl: string | null = null;
112+
let cancelled = false;
113+
114+
getDesignResumeAssetContentBlob(pictureUrl)
115+
.then((blob) => {
116+
objectUrl = URL.createObjectURL(blob);
117+
if (cancelled) {
118+
URL.revokeObjectURL(objectUrl);
119+
return;
120+
}
121+
setPreviewUrl(objectUrl);
122+
})
123+
.catch(() => {
124+
if (!cancelled) setPreviewUrl(pictureUrl);
125+
});
126+
127+
return () => {
128+
cancelled = true;
129+
if (objectUrl) URL.revokeObjectURL(objectUrl);
130+
};
131+
}, [pictureUrl]);
95132

96133
return (
97134
<div className="grid gap-3">
@@ -109,7 +146,7 @@ export function PictureSection({
109146
className={`${insetPanelClassName} flex items-center gap-3 border-dashed p-3`}
110147
>
111148
<img
112-
src={toText(picture.url)}
149+
src={previewUrl}
113150
alt="Resume Studio profile"
114151
className="h-16 w-16 rounded-lg border border-border/60 object-cover"
115152
/>

orchestrator/src/server/api/routes/auth.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,46 @@ describe.sequential("Auth routes", () => {
213213
expect(body.error.message).toContain("Username already exists");
214214
});
215215

216+
it("scopes backend analytics identity by hosted user", async () => {
217+
async function signup(username: string): Promise<string> {
218+
const res = await fetch(`${baseUrl}/api/auth/signup`, {
219+
method: "POST",
220+
headers: { "Content-Type": "application/json" },
221+
body: JSON.stringify({
222+
username,
223+
password: `${username}-secret`,
224+
}),
225+
});
226+
expect(res.status).toBe(201);
227+
const body = await res.json();
228+
expect(body.ok).toBe(true);
229+
return body.data.token as string;
230+
}
231+
232+
async function analyticsDistinctId(token: string): Promise<string> {
233+
const res = await fetch(`${baseUrl}/api/auth/me`, {
234+
headers: { Authorization: `Bearer ${token}` },
235+
});
236+
expect(res.status).toBe(200);
237+
const body = await res.json();
238+
expect(body.ok).toBe(true);
239+
return body.data.analyticsDistinctId as string;
240+
}
241+
242+
const aliceToken = await signup("analytics-alice");
243+
const bobToken = await signup("analytics-bob");
244+
245+
const aliceFirst = await analyticsDistinctId(aliceToken);
246+
const aliceSecond = await analyticsDistinctId(aliceToken);
247+
const bob = await analyticsDistinctId(bobToken);
248+
249+
expect(aliceFirst).toMatch(
250+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
251+
);
252+
expect(aliceSecond).toBe(aliceFirst);
253+
expect(bob).not.toBe(aliceFirst);
254+
});
255+
216256
it("returns 400 for invalid signup input", async () => {
217257
const res = await fetch(`${baseUrl}/api/auth/signup`, {
218258
method: "POST",

orchestrator/src/server/api/routes/design-resume.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { badRequest, conflict, notFound, toAppError } from "@infra/errors";
22
import { asyncRoute, fail, ok } from "@infra/http";
33
import { logger } from "@infra/logger";
4+
import { getJobOpsAppConfig } from "@server/config/app-mode";
45
import { getRequestId } from "@server/infra/request-context";
56
import { enqueueAutoPdfRegenerationForReadyJobs } from "@server/services/auto-pdf-regeneration";
67
import {
@@ -421,7 +422,7 @@ designResumeRouter.get(
421422
}
422423

423424
const { asset, content } = await readDesignResumeAssetContent(assetId, {
424-
bypassTenantScope: true,
425+
bypassTenantScope: getJobOpsAppConfig().appMode !== "hosted",
425426
});
426427
res.setHeader("Content-Type", asset.mimeType);
427428
res.setHeader("Cache-Control", "private, max-age=60");

orchestrator/src/server/api/routes/post-application-providers.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { badRequest, serviceUnavailable, upstreamError } from "@infra/errors";
33
import { asyncRoute, fail, ok } from "@infra/http";
44
import { logger } from "@infra/logger";
55
import { executePostApplicationProviderAction } from "@server/services/post-application/providers";
6-
import { getActiveTenantId } from "@server/tenancy/context";
6+
import { getPrivateDataScope } from "@server/tenancy/private-scope";
77
import {
88
POST_APPLICATION_PROVIDER_ACTIONS,
99
POST_APPLICATION_PROVIDERS,
@@ -47,6 +47,7 @@ const oauthStateStore = new Map<
4747
{
4848
accountKey: string;
4949
tenantId: string;
50+
userId: string | null;
5051
redirectUri: string;
5152
createdAt: number;
5253
}
@@ -102,6 +103,7 @@ function setOauthState(
102103
entry: {
103104
accountKey: string;
104105
tenantId: string;
106+
userId: string | null;
105107
redirectUri: string;
106108
createdAt: number;
107109
},
@@ -236,10 +238,12 @@ postApplicationProvidersRouter.get(
236238
const accountKey = parsed.accountKey ?? "default";
237239
const oauth = resolveGmailOauthConfig(req);
238240
const state = randomUUID();
241+
const scope = getPrivateDataScope();
239242

240243
setOauthState(state, {
241244
accountKey,
242-
tenantId: getActiveTenantId(),
245+
tenantId: scope.tenantId,
246+
userId: scope.enforceUserIsolation ? scope.userId : null,
243247
redirectUri: oauth.redirectUri,
244248
createdAt: Date.now(),
245249
});
@@ -289,10 +293,15 @@ postApplicationProvidersRouter.post(
289293
fail(res, badRequest("OAuth state/account mismatch."));
290294
return;
291295
}
292-
if (oauthState.tenantId !== getActiveTenantId()) {
296+
const scope = getPrivateDataScope();
297+
if (oauthState.tenantId !== scope.tenantId) {
293298
fail(res, badRequest("OAuth state/workspace mismatch."));
294299
return;
295300
}
301+
if (scope.enforceUserIsolation && oauthState.userId !== scope.userId) {
302+
fail(res, badRequest("OAuth state/user mismatch."));
303+
return;
304+
}
296305

297306
const oauth = resolveGmailOauthConfig(req);
298307
const tokenPayload = await exchangeGmailAuthorizationCode({

0 commit comments

Comments
 (0)