Skip to content

Commit ccf425e

Browse files
committed
security: Enforce multi-tenant isolation on job listings and admin operations
1 parent 9602b7b commit ccf425e

2 files changed

Lines changed: 162 additions & 35 deletions

File tree

backend/simpatico-ats.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,6 +1213,8 @@ route("GET", "/payroll/payslips/:employeeId", handleGetPayslips);
12131213
// Recruitment / ATS
12141214
route("POST", "/recruitment/jobs", handleCreateJob);
12151215
route("GET", "/recruitment/jobs", handleListJobs);
1216+
route("PATCH", "/recruitment/jobs/:id", handleUpdateJob);
1217+
route("DELETE", "/recruitment/jobs/:id", handleDeleteJob);
12161218
route("GET", "/recruitment/public/jobs", handlePublicJobs);
12171219
route("POST", "/recruitment/applications", handleCreateApplication);
12181220
route("POST", "/interviews/schedule", handleScheduleInterviewEmail);
@@ -3405,6 +3407,62 @@ async function handleListJobs(request, env, ctx, _, url) {
34053407
return apiResponse({ jobs: await res.json() });
34063408
}
34073409

3410+
/**
3411+
* PATCH /recruitment/jobs/:id — Update a job (tenant-isolated).
3412+
*/
3413+
async function handleUpdateJob(request, env, ctx, [jobId]) {
3414+
requireRole(ctx, "hr", "admin", "superadmin", "client_admin");
3415+
const body = await safeJson(request);
3416+
3417+
// Only allow safe fields to be updated
3418+
const allowed = ["title", "description", "department", "location", "job_type", "employment_type",
3419+
"skills", "salary_min", "salary_max", "status", "level", "experience_required"];
3420+
const update = {};
3421+
for (const key of allowed) {
3422+
if (body[key] !== undefined) update[key] = body[key];
3423+
}
3424+
3425+
if (Object.keys(update).length === 0) {
3426+
throw new ValidationError("No valid fields to update");
3427+
}
3428+
3429+
// sbFetch enforces tenant_id filter → prevents cross-tenant edits
3430+
const res = await sbFetch(
3431+
env,
3432+
"PATCH",
3433+
`/rest/v1/jobs?id=eq.${jobId}`,
3434+
update,
3435+
false,
3436+
ctx.tenantId,
3437+
);
3438+
const [job] = await res.json();
3439+
3440+
await audit(env, ctx, "job.updated", "jobs", jobId, { fields: Object.keys(update) });
3441+
await invalidateCache(env, `analytics:summary:${ctx.tenantId}`);
3442+
return apiResponse({ job });
3443+
}
3444+
3445+
/**
3446+
* DELETE /recruitment/jobs/:id — Delete a job (tenant-isolated).
3447+
*/
3448+
async function handleDeleteJob(request, env, ctx, [jobId]) {
3449+
requireRole(ctx, "hr", "admin", "superadmin", "client_admin");
3450+
3451+
// sbFetch enforces tenant_id filter → prevents cross-tenant deletes
3452+
const res = await sbFetch(
3453+
env,
3454+
"DELETE",
3455+
`/rest/v1/jobs?id=eq.${jobId}`,
3456+
null,
3457+
false,
3458+
ctx.tenantId,
3459+
);
3460+
3461+
await audit(env, ctx, "job.deleted", "jobs", jobId, {});
3462+
await invalidateCache(env, `analytics:summary:${ctx.tenantId}`);
3463+
return apiResponse({ deleted: true, id: jobId });
3464+
}
3465+
34083466
async function handlePublicJobs(request, env, ctx, _, url) {
34093467
// Unauthenticated endpoint for embeddable Careers Widget
34103468
const company_id = url.searchParams.get("company_id");
@@ -3413,6 +3471,27 @@ async function handlePublicJobs(request, env, ctx, _, url) {
34133471
"company_id parameter is required for public job listings",
34143472
);
34153473

3474+
// ── Check if company is blocked before serving public jobs ──
3475+
try {
3476+
const compRes = await fetch(
3477+
`${env.SUPABASE_URL}/rest/v1/companies?id=eq.${company_id}&select=is_blocked`,
3478+
{
3479+
headers: {
3480+
apikey: env.SUPABASE_SERVICE_KEY,
3481+
Authorization: `Bearer ${env.SUPABASE_SERVICE_KEY}`,
3482+
},
3483+
},
3484+
);
3485+
if (compRes.ok) {
3486+
const companies = await compRes.json();
3487+
if (companies.length > 0 && companies[0].is_blocked) {
3488+
return apiResponse({ jobs: [] }); // Blocked company — return no jobs
3489+
}
3490+
}
3491+
} catch (e) {
3492+
console.warn("[PublicJobs] Company block check failed:", e.message);
3493+
}
3494+
34163495
// Pass company_id as the tenant parameter to sbFetch to auto-filter and isolate tenant records securely
34173496
const res = await sbFetch(
34183497
env,

jobs.html

Lines changed: 83 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -255,23 +255,31 @@ <h3>🗑️ Delete Job</h3>
255255
<div class="toast" id="toast"></div>
256256

257257
<div class="footer">
258-
© 2025 <a href="/">Simpatico HR Consultancy</a> • AI-Powered Recruitment
258+
© 2025 <a href="/">Simpatico HR Consultancy</a> AI-Powered Recruitment
259259
</div>
260260

261261
<script>
262262
// ---------------------------------------------------------------
263-
// CONFIG — NO EXPOSED CREDENTIALS
263+
// CONFIG — MULTI-TENANT SAFE
264264
// ---------------------------------------------------------------
265-
const WORKER_URL = "https://evalis-ai.simpaticohrconsultancy.workers.dev";
265+
const WORKER_URL = window.SIMPATICO_CONFIG?.workerUrl || "https://evalis-ai.simpaticohrconsultancy.workers.dev";
266266

267267
let allJobs = [];
268268
let currentFilter = 'all';
269269
let isAdmin = false;
270270
let HR_TOKEN = null;
271271
let HR_USER = null;
272272

273+
// Get the current tenant/company ID from the logged-in user context
274+
function getCurrentCompanyId() {
275+
try {
276+
const user = HR_USER || JSON.parse(localStorage.getItem('simpatico_user') || '{}');
277+
return user?.company_id || user?.tenant_id || null;
278+
} catch { return null; }
279+
}
280+
273281
// ---------------------------------------------------------------
274-
// API HELPER — All calls go through the worker
282+
// API HELPER All calls go through the worker (tenant-isolated)
275283
// ---------------------------------------------------------------
276284
async function api(method, path, body = null, auth = false) {
277285
const opts = {
@@ -281,11 +289,14 @@ <h3>🗑️ Delete Job</h3>
281289
if (auth && HR_TOKEN) {
282290
opts.headers['Authorization'] = 'Bearer ' + HR_TOKEN;
283291
}
292+
// Always send tenant context header for backend isolation
293+
const cid = getCurrentCompanyId();
294+
if (cid) opts.headers['X-Tenant-ID'] = cid;
284295
if (body) opts.body = JSON.stringify(body);
285296

286297
const res = await fetch(WORKER_URL + path, opts);
287298
const data = await res.json();
288-
if (!res.ok) throw new Error(data.error || 'HTTP ' + res.status);
299+
if (!res.ok) throw new Error(data.error?.message || data.error || 'HTTP ' + res.status);
289300
return data;
290301
}
291302

@@ -297,32 +308,49 @@ <h3>🗑️ Delete Job</h3>
297308
t.textContent = msg;
298309
t.className = `toast ${type} show`;
299310
setTimeout(() => t.classList.remove('show'), 3000);
300-
}// ---------------------------------------------------------------
301-
// LOAD JOBS — Public endpoint GET /jobs
311+
}
312+
// ---------------------------------------------------------------
313+
// LOAD JOBS — Tenant-Isolated via Worker API
302314
// ---------------------------------------------------------------
303315
async function loadJobs() {
304316
const el = document.getElementById("jobsList");
305317
try {
306-
const params = new URLSearchParams();
307318
const search = document.getElementById('searchInput')?.value?.trim();
308-
309-
const SUPABASE_URL = window.SIMPATICO_CONFIG?.supabaseUrl;
310-
const SUPABASE_ANON_KEY = window.SIMPATICO_CONFIG?.supabaseAnonKey;
311-
312-
// Default query for open job postings
313-
let sbUrl = `${SUPABASE_URL}/rest/v1/jobs?status=eq.open&select=*,job_applications(count)&order=created_at.desc`;
314-
315-
if (search) sbUrl += '&title=ilike.*' + encodeURIComponent(search) + '*';
316-
if (currentFilter && currentFilter !== 'all') sbUrl += '&type=eq.' + currentFilter;
317-
318-
const res = await fetch(sbUrl, {
319-
headers: {
320-
apikey: SUPABASE_ANON_KEY,
321-
Authorization: 'Bearer ' + SUPABASE_ANON_KEY
322-
}
323-
});
324-
325-
const jobs = (await res.json()) || [];
319+
const companyId = getCurrentCompanyId();
320+
let jobs = [];
321+
322+
if (isAdmin && HR_TOKEN && companyId) {
323+
// ADMIN MODE: Fetch only this company's jobs via authenticated Worker endpoint
324+
const statusParam = (currentFilter && currentFilter !== 'all') ? currentFilter : 'open';
325+
const data = await api('GET', `/recruitment/jobs?status=${statusParam}`, null, true);
326+
jobs = data.jobs || data || [];
327+
} else if (companyId) {
328+
// LOGGED-IN USER: Fetch own company jobs via public endpoint
329+
const data = await api('GET', `/recruitment/public/jobs?company_id=${companyId}`);
330+
jobs = data.jobs || data || [];
331+
} else {
332+
// PUBLIC VIEW (no company context): Show Simpatico HR's own jobs only
333+
// Use the configured default tenant ID as fallback
334+
const defaultTenant = window.SIMPATICO_CONFIG?.tenantId || 'SIMP_PRO_MAIN';
335+
const data = await api('GET', `/recruitment/public/jobs?company_id=${defaultTenant}`);
336+
jobs = data.jobs || data || [];
337+
}
338+
339+
// Client-side search filter
340+
if (search) {
341+
const q = search.toLowerCase();
342+
jobs = jobs.filter(j =>
343+
(j.title || '').toLowerCase().includes(q) ||
344+
(j.department || '').toLowerCase().includes(q) ||
345+
(j.location || '').toLowerCase().includes(q) ||
346+
(j.description || '').toLowerCase().includes(q)
347+
);
348+
}
349+
350+
// Client-side type filter (only for public/non-admin mode)
351+
if (!isAdmin && currentFilter && currentFilter !== 'all') {
352+
jobs = jobs.filter(j => (j.job_type || j.employment_type || '') === currentFilter);
353+
}
326354

327355
if (!jobs.length) {
328356
el.innerHTML = `<div class="empty">
@@ -573,12 +601,21 @@ <h2>${job.title || 'Untitled'}</h2>
573601
};
574602

575603
try {
576-
await api('POST', '/db', {
577-
action: 'update',
578-
table: 'jobs',
579-
data: updateData,
580-
filters: { id: jobId }
581-
}, true);
604+
// ★ TENANT-ISOLATED: Use authenticated Worker endpoint with job ID
605+
// Backend enforces tenant_id check via sbFetch to prevent cross-tenant edits
606+
const res = await fetch(WORKER_URL + `/recruitment/jobs/${jobId}`, {
607+
method: 'PATCH',
608+
headers: {
609+
'Content-Type': 'application/json',
610+
'Authorization': 'Bearer ' + HR_TOKEN,
611+
'X-Tenant-ID': getCurrentCompanyId() || ''
612+
},
613+
body: JSON.stringify(updateData)
614+
});
615+
if (!res.ok) {
616+
const err = await res.json().catch(() => ({}));
617+
throw new Error(err.error?.message || err.error || 'Update failed');
618+
}
582619

583620
showToast("✅ Job updated!", "success");
584621
closeEditModal();
@@ -605,9 +642,20 @@ <h2>${job.title || 'Untitled'}</h2>
605642
btn.disabled = true; btn.textContent = '⏳...';
606643

607644
try {
608-
await api('POST', '/db', {
609-
action: 'delete', table: 'jobs', filters: { id: jobId }
610-
}, true);
645+
// ★ TENANT-ISOLATED: Use authenticated Worker endpoint
646+
// Backend enforces tenant_id check via sbFetch to prevent cross-tenant deletes
647+
const res = await fetch(WORKER_URL + `/recruitment/jobs/${jobId}`, {
648+
method: 'DELETE',
649+
headers: {
650+
'Content-Type': 'application/json',
651+
'Authorization': 'Bearer ' + HR_TOKEN,
652+
'X-Tenant-ID': getCurrentCompanyId() || ''
653+
}
654+
});
655+
if (!res.ok) {
656+
const err = await res.json().catch(() => ({}));
657+
throw new Error(err.error?.message || err.error || 'Delete failed');
658+
}
611659

612660
showToast("🗑️ Job deleted", "success");
613661
closeDeleteModal();

0 commit comments

Comments
 (0)