From 755493bb84f7732f680583d7f1a91f130e7fa0bf Mon Sep 17 00:00:00 2001 From: Ikerlaforga <19539979+Isonimus@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:40:37 +0200 Subject: [PATCH 1/2] feat(server): scope admin RBAC to organizations + per-org 2FA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-scope the admin layer to the organization tenant (migration 013): an owner is now a superuser WITHIN its own org, never across the box. - authz: canViewProject/canManageProject gate owner access on the project belonging to the user's org (new projectInOrg helper). - admin routes: every owner-scoped query filters by organization_id — project list/create, user + invite listings, membership grants, and the per-org last-active-owner guard. Cross-org project/user ids return 404 (not 403) so a tenant can't even confirm another's resources exist. - project-access middleware: the session data-view path is org-scoped too (404 on cross-org X-Project-Id). - 2FA policy is now per-org (organizations.require_2fa); isTwoFactorRequired / setTwoFactorRequired take an orgId, threaded through login, /me, invite accept, enforce-2fa, and the settings route. - invites carry the inviter's org so an accepted account joins that company. Adds an org-isolation integration suite (two orgs, cross-tenant 404s, scoped user/invite listings, invite org-inheritance, per-org last-owner guard) and makes the existing admin/RBAC test helpers org-aware via a shared test-support/org helper. 135 server tests pass. --- packages/server/src/admin/authz.ts | 33 +++-- packages/server/src/admin/enforce-2fa.ts | 2 +- packages/server/src/admin/invite.ts | 15 +- packages/server/src/admin/settings.ts | 19 ++- .../server/src/middleware/project-access.ts | 8 +- .../src/routes/admin-2fa.integration.test.ts | 14 +- .../routes/admin-account.integration.test.ts | 9 +- .../src/routes/admin-rbac.integration.test.ts | 15 +- .../src/routes/admin.integration.test.ts | 7 +- packages/server/src/routes/admin.ts | 73 ++++++---- .../routes/org-isolation.integration.test.ts | 136 ++++++++++++++++++ .../routes/project-access.integration.test.ts | 15 +- packages/server/src/test-support/org.ts | 15 ++ 13 files changed, 291 insertions(+), 70 deletions(-) create mode 100644 packages/server/src/routes/org-isolation.integration.test.ts create mode 100644 packages/server/src/test-support/org.ts diff --git a/packages/server/src/admin/authz.ts b/packages/server/src/admin/authz.ts index 91726d0..afc156e 100644 --- a/packages/server/src/admin/authz.ts +++ b/packages/server/src/admin/authz.ts @@ -2,19 +2,33 @@ import type { Request, Response, NextFunction } from 'express'; import { db } from '../db/client.js'; import type { AdminUser } from './session.js'; -// Authorization helpers for the two-level admin RBAC (see migration 009): -// - owners are superusers: implicit access to every project, and the only role that -// can create/delete projects and (PR2) manage admin accounts. -// - members reach only the projects granted in project_members; their per-project -// role decides manage ('admin') vs read-only ('viewer'). +// Authorization helpers for the two-level admin RBAC (migration 009), now scoped to the +// organization tenant layer (migration 013): +// - owners are superusers WITHIN THEIR OWN ORG: implicit access to every project in +// their org, and the only role that can create/delete projects and manage admins — +// but never anything in another org. +// - members reach only the projects granted in project_members (always same-org); their +// per-project role decides manage ('admin') vs read-only ('viewer'). export function isOwner(user: AdminUser): boolean { return user.role === 'owner'; } -// Can the user READ this project's data? Owner, or any membership row. +// Does this project belong to the user's org? The gate for owner access — an owner is a +// superuser only inside their own tenant. +async function projectInOrg(projectId: string, organizationId: string): Promise { + const rows = await db<{ id: string }[]>` + SELECT id FROM projects + WHERE id = ${projectId} AND organization_id = ${organizationId} + LIMIT 1 + `; + return rows.length > 0; +} + +// Can the user READ this project's data? An owner of the project's org, or any membership +// row (memberships are same-org by construction). export async function canViewProject(user: AdminUser, projectId: string): Promise { - if (isOwner(user)) return true; + if (isOwner(user)) return projectInOrg(projectId, user.organizationId); const rows = await db<{ id: string }[]>` SELECT id FROM project_members WHERE user_id = ${user.id} AND project_id = ${projectId} @@ -23,9 +37,10 @@ export async function canViewProject(user: AdminUser, projectId: string): Promis return rows.length > 0; } -// Can the user MANAGE this project (rotate keys etc.)? Owner, or a project 'admin'. +// Can the user MANAGE this project (rotate keys etc.)? An owner of the project's org, or a +// project 'admin'. export async function canManageProject(user: AdminUser, projectId: string): Promise { - if (isOwner(user)) return true; + if (isOwner(user)) return projectInOrg(projectId, user.organizationId); const rows = await db<{ id: string }[]>` SELECT id FROM project_members WHERE user_id = ${user.id} AND project_id = ${projectId} AND role = 'admin' diff --git a/packages/server/src/admin/enforce-2fa.ts b/packages/server/src/admin/enforce-2fa.ts index 944ad4e..827e739 100644 --- a/packages/server/src/admin/enforce-2fa.ts +++ b/packages/server/src/admin/enforce-2fa.ts @@ -18,7 +18,7 @@ export async function enforce2faEnrollment(req: Request, res: Response, next: Ne next(); return; } - if (await isTwoFactorRequired()) { + if (await isTwoFactorRequired(user.organizationId)) { res.status(403).json({ error: 'two_factor_enrollment_required' }); return; } diff --git a/packages/server/src/admin/invite.ts b/packages/server/src/admin/invite.ts index 9c543be..267eacf 100644 --- a/packages/server/src/admin/invite.ts +++ b/packages/server/src/admin/invite.ts @@ -17,17 +17,20 @@ export interface Invite { expires_at: string; } -// Create an invite; returns the raw token (shown once) plus the stored row. +// Create an invite scoped to the inviter's organization; returns the raw token (shown +// once) plus the stored row. The org travels with the invite so the accepted account +// lands in the inviting company. export async function createInvite( email: string, role: AdminRole, invitedBy: string, + organizationId: string, ): Promise<{ token: string; invite: Invite }> { const token = randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + INVITE_TTL_DAYS * 24 * 60 * 60 * 1000); const [invite] = await db` - INSERT INTO admin_invites (email, token_hash, role, invited_by, expires_at) - VALUES (${email}, ${hashToken(token)}, ${role}, ${invitedBy}, ${expiresAt.toISOString()}) + INSERT INTO admin_invites (email, token_hash, role, invited_by, organization_id, expires_at) + VALUES (${email}, ${hashToken(token)}, ${role}, ${invitedBy}, ${organizationId}, ${expiresAt.toISOString()}) RETURNING id, email, role, expires_at `; return { token, invite: invite! }; @@ -36,9 +39,9 @@ export async function createInvite( // Resolve a raw token to a pending, unexpired invite, or null. export async function findValidInvite( token: string, -): Promise<{ id: string; email: string; role: AdminRole } | null> { - const rows = await db<{ id: string; email: string; role: AdminRole }[]>` - SELECT id, email, role +): Promise<{ id: string; email: string; role: AdminRole; organization_id: string } | null> { + const rows = await db<{ id: string; email: string; role: AdminRole; organization_id: string }[]>` + SELECT id, email, role, organization_id FROM admin_invites WHERE token_hash = ${hashToken(token)} AND accepted_at IS NULL diff --git a/packages/server/src/admin/settings.ts b/packages/server/src/admin/settings.ts index 96f8fca..85bc366 100644 --- a/packages/server/src/admin/settings.ts +++ b/packages/server/src/admin/settings.ts @@ -1,14 +1,19 @@ import { db } from '../db/client.js'; -// Install-wide admin settings (single row, see migration 011). Read fresh each call — -// it's one indexed single-row lookup on low-traffic admin paths, so caching isn't -// worth the invalidation risk when the toggle flips. +// Per-organization 2FA policy (migration 013). Each tenant decides whether its admins +// must enrol in 2FA, so one company tightening the requirement never affects another on +// the same box. Read fresh each call — a single indexed lookup on low-traffic admin +// paths, so caching isn't worth the invalidation risk when the toggle flips. +// (Supersedes the legacy install-wide admin_settings.require_2fa, kept only as the +// backfill seed for the Default org.) -export async function isTwoFactorRequired(): Promise { - const rows = await db<{ require_2fa: boolean }[]>`SELECT require_2fa FROM admin_settings WHERE id = true LIMIT 1`; +export async function isTwoFactorRequired(organizationId: string): Promise { + const rows = await db<{ require_2fa: boolean }[]>` + SELECT require_2fa FROM organizations WHERE id = ${organizationId} LIMIT 1 + `; return rows[0]?.require_2fa ?? false; } -export async function setTwoFactorRequired(value: boolean): Promise { - await db`UPDATE admin_settings SET require_2fa = ${value} WHERE id = true`; +export async function setTwoFactorRequired(organizationId: string, value: boolean): Promise { + await db`UPDATE organizations SET require_2fa = ${value} WHERE id = ${organizationId}`; } diff --git a/packages/server/src/middleware/project-access.ts b/packages/server/src/middleware/project-access.ts index 863a3d8..e4dfc7d 100644 --- a/packages/server/src/middleware/project-access.ts +++ b/packages/server/src/middleware/project-access.ts @@ -72,7 +72,7 @@ export async function requireProjectRead( // If the install requires 2FA, a not-yet-enrolled admin can't view data either — // they must enroll first (mirrors enforce2faEnrollment on the /admin routes). - if (!user.totpEnabled && (await isTwoFactorRequired())) { + if (!user.totpEnabled && (await isTwoFactorRequired(user.organizationId))) { res.status(403).json({ error: 'two_factor_enrollment_required' }); return; } @@ -89,15 +89,17 @@ export async function requireProjectRead( return; } + // Org-scoped existence check: a project in another tenant reads as not-found (404), + // so a session can never even confirm a cross-tenant project exists. const project = await db<{ id: string }[]>` - SELECT id FROM projects WHERE id = ${projectId} LIMIT 1 + SELECT id FROM projects WHERE id = ${projectId} AND organization_id = ${user.organizationId} LIMIT 1 `; if (!project[0]) { res.status(404).json({ error: 'Project not found' }); return; } - // RBAC: owners see every project; members only those granted in project_members. + // RBAC: owners see every project in their org; members only those granted in project_members. if (!(await canViewProject(user, projectId))) { res.status(403).json({ error: 'You do not have access to this project' }); return; diff --git a/packages/server/src/routes/admin-2fa.integration.test.ts b/packages/server/src/routes/admin-2fa.integration.test.ts index 6803846..0f552c8 100644 --- a/packages/server/src/routes/admin-2fa.integration.test.ts +++ b/packages/server/src/routes/admin-2fa.integration.test.ts @@ -11,7 +11,7 @@ const { migrate } = await import('../db/migrate.js'); const { db } = await import('../db/client.js'); const { redis } = await import('../db/redis.js'); const { hashPassword } = await import('../admin/password.js'); -const { setTwoFactorRequired } = await import('../admin/settings.js'); +const { createTestOrg, deleteTestOrg } = await import('../test-support/org.js'); // Integration coverage for TOTP 2FA (migration 011): enrollment, login challenge, // recovery codes, disable, and the org-wide require-2FA enrollment funnel. Gated on @@ -22,6 +22,7 @@ const OWNER_EMAIL = 'twofa-owner-it@example.com'; const MEMBER_EMAIL = 'twofa-member-it@example.com'; const PASSWORD = 'test-password-123'; const EMAILS = [OWNER_EMAIL, MEMBER_EMAIL]; +const ORG = 'TwoFaIT Org'; const app = createApp(); @@ -36,15 +37,18 @@ beforeAll(async () => { await migrate(); await redis.flushdb(); await db`DELETE FROM admin_users WHERE email = ANY(${EMAILS})`; - await db`INSERT INTO admin_users (email, password_hash, role, is_active) VALUES (${OWNER_EMAIL}, ${await hashPassword(PASSWORD)}, 'owner', true)`; - await db`INSERT INTO admin_users (email, password_hash, role, is_active) VALUES (${MEMBER_EMAIL}, ${await hashPassword(PASSWORD)}, 'member', true)`; + await deleteTestOrg(ORG); + const org = await createTestOrg(ORG); + await db`INSERT INTO admin_users (email, password_hash, role, is_active, organization_id) VALUES (${OWNER_EMAIL}, ${await hashPassword(PASSWORD)}, 'owner', true, ${org})`; + await db`INSERT INTO admin_users (email, password_hash, role, is_active, organization_id) VALUES (${MEMBER_EMAIL}, ${await hashPassword(PASSWORD)}, 'member', true, ${org})`; }); afterAll(async () => { if (!hasDb) return; - // Reset the global toggle so other suites' (un-enrolled) admins aren't funneled. - await setTwoFactorRequired(false); + // require_2fa is per-org now, so deleting this suite's org (after its admins) is enough + // — no other suite's admins can be funneled by it. await db`DELETE FROM admin_users WHERE email = ANY(${EMAILS})`; + await deleteTestOrg(ORG); await redis.quit(); await db.end(); }); diff --git a/packages/server/src/routes/admin-account.integration.test.ts b/packages/server/src/routes/admin-account.integration.test.ts index cb88584..bc2191a 100644 --- a/packages/server/src/routes/admin-account.integration.test.ts +++ b/packages/server/src/routes/admin-account.integration.test.ts @@ -5,6 +5,7 @@ import { migrate } from '../db/migrate.js'; import { db } from '../db/client.js'; import { redis } from '../db/redis.js'; import { hashPassword } from '../admin/password.js'; +import { createTestOrg, deleteTestOrg } from '../test-support/org.js'; // Integration coverage for account management (migration 010): invite + accept, // owner-only gating, self-lockout guard, per-project membership grants, self password @@ -16,6 +17,7 @@ const MEMBER_EMAIL = 'acct-member-it@example.com'; const SHORTPW_EMAIL = 'acct-shortpw-it@example.com'; const PASSWORD = 'test-password-123'; const NEW_PASSWORD = 'new-password-456'; +const ORG = 'AcctIT Org'; const app = createApp(); let ownerId: string; @@ -35,9 +37,11 @@ beforeAll(async () => { await db`DELETE FROM admin_users WHERE email = ANY(${EMAILS})`; await db`DELETE FROM admin_invites WHERE email = ANY(${EMAILS})`; await db`DELETE FROM projects WHERE name LIKE 'AcctIT %'`; + await deleteTestOrg(ORG); + const org = await createTestOrg(ORG); const [owner] = await db<{ id: string }[]>` - INSERT INTO admin_users (email, password_hash, role, is_active) - VALUES (${OWNER_EMAIL}, ${await hashPassword(PASSWORD)}, 'owner', true) RETURNING id + INSERT INTO admin_users (email, password_hash, role, is_active, organization_id) + VALUES (${OWNER_EMAIL}, ${await hashPassword(PASSWORD)}, 'owner', true, ${org}) RETURNING id `; ownerId = owner!.id; }); @@ -47,6 +51,7 @@ afterAll(async () => { await db`DELETE FROM admin_users WHERE email = ANY(${EMAILS})`; await db`DELETE FROM admin_invites WHERE email = ANY(${EMAILS})`; await db`DELETE FROM projects WHERE name LIKE 'AcctIT %'`; + await deleteTestOrg(ORG); await redis.quit(); await db.end(); }); diff --git a/packages/server/src/routes/admin-rbac.integration.test.ts b/packages/server/src/routes/admin-rbac.integration.test.ts index 6637047..03d969c 100644 --- a/packages/server/src/routes/admin-rbac.integration.test.ts +++ b/packages/server/src/routes/admin-rbac.integration.test.ts @@ -6,6 +6,7 @@ import { db } from '../db/client.js'; import { redis } from '../db/redis.js'; import { hashApiKey } from '../middleware/api-key.js'; import { hashPassword } from '../admin/password.js'; +import { createTestOrg, deleteTestOrg } from '../test-support/org.js'; // Integration coverage for the two-level admin RBAC (migration 009): owners are // superusers; members reach only the projects granted in project_members, and their @@ -18,6 +19,7 @@ const PASSWORD = 'test-password-123'; const KEY_A = 'rbac-it-key-a'; const KEY_B = 'rbac-it-key-b'; const KEY_C = 'rbac-it-key-c'; +const ORG = 'RbacIT Org'; const app = createApp(); let idA: string; // member: viewer @@ -37,19 +39,21 @@ beforeAll(async () => { await redis.flushdb(); await db`DELETE FROM admin_users WHERE email IN (${OWNER_EMAIL}, ${MEMBER_EMAIL})`; await db`DELETE FROM projects WHERE name LIKE 'RbacIT %'`; + await deleteTestOrg(ORG); + const org = await createTestOrg(ORG); await db` - INSERT INTO admin_users (email, password_hash, role) - VALUES (${OWNER_EMAIL}, ${await hashPassword(PASSWORD)}, 'owner') + INSERT INTO admin_users (email, password_hash, role, organization_id) + VALUES (${OWNER_EMAIL}, ${await hashPassword(PASSWORD)}, 'owner', ${org}) `; const [member] = await db<{ id: string }[]>` - INSERT INTO admin_users (email, password_hash, role) - VALUES (${MEMBER_EMAIL}, ${await hashPassword(PASSWORD)}, 'member') RETURNING id + INSERT INTO admin_users (email, password_hash, role, organization_id) + VALUES (${MEMBER_EMAIL}, ${await hashPassword(PASSWORD)}, 'member', ${org}) RETURNING id `; const mk = async (key: string, name: string): Promise => { const [p] = await db<{ id: string }[]>` - INSERT INTO projects (api_key_hash, name) VALUES (${hashApiKey(key)}, ${name}) RETURNING id + INSERT INTO projects (api_key_hash, name, organization_id) VALUES (${hashApiKey(key)}, ${name}, ${org}) RETURNING id `; return p!.id; }; @@ -66,6 +70,7 @@ afterAll(async () => { if (!hasDb) return; await db`DELETE FROM admin_users WHERE email IN (${OWNER_EMAIL}, ${MEMBER_EMAIL})`; await db`DELETE FROM projects WHERE name LIKE 'RbacIT %'`; + await deleteTestOrg(ORG); await redis.quit(); await db.end(); }); diff --git a/packages/server/src/routes/admin.integration.test.ts b/packages/server/src/routes/admin.integration.test.ts index 32b2921..17daf33 100644 --- a/packages/server/src/routes/admin.integration.test.ts +++ b/packages/server/src/routes/admin.integration.test.ts @@ -6,6 +6,7 @@ import { db } from '../db/client.js'; import { redis } from '../db/redis.js'; import { hashPassword } from '../admin/password.js'; import { hashApiKey } from '../middleware/api-key.js'; +import { createTestOrg, deleteTestOrg } from '../test-support/org.js'; // Integration coverage for the admin management API: login/session, and project key // create/rotate/revoke including the data-API (/v1) authorization side effects and @@ -13,6 +14,7 @@ import { hashApiKey } from '../middleware/api-key.js'; const hasDb = Boolean(process.env['DATABASE_URL']); const EMAIL = 'admin-int@example.com'; const PASSWORD = 'test-password-123'; +const ORG = 'AdminIT Org'; const app = createApp(); @@ -37,14 +39,17 @@ beforeAll(async () => { await migrate(); await redis.flushdb(); await db`DELETE FROM admin_users WHERE email = ${EMAIL}`; - await db`INSERT INTO admin_users (email, password_hash, role) VALUES (${EMAIL}, ${await hashPassword(PASSWORD)}, 'owner')`; await db`DELETE FROM projects WHERE name LIKE 'AdminIT %'`; + await deleteTestOrg(ORG); + const org = await createTestOrg(ORG); + await db`INSERT INTO admin_users (email, password_hash, role, organization_id) VALUES (${EMAIL}, ${await hashPassword(PASSWORD)}, 'owner', ${org})`; }); afterAll(async () => { if (!hasDb) return; await db`DELETE FROM admin_users WHERE email = ${EMAIL}`; await db`DELETE FROM projects WHERE name LIKE 'AdminIT %'`; + await deleteTestOrg(ORG); await redis.quit(); await db.end(); }); diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index 7857d43..76f9ce1 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -97,9 +97,9 @@ adminRouter.post('/login', async (req: Request, res: Response): Promise => const email = parsed.data.email.trim().toLowerCase(); const rows = await db< - { id: string; password_hash: string; role: string; is_active: boolean; totp_enabled: boolean; totp_secret_enc: string | null }[] + { id: string; password_hash: string; role: string; is_active: boolean; totp_enabled: boolean; totp_secret_enc: string | null; organization_id: string }[] >` - SELECT id, password_hash, role, is_active, totp_enabled, totp_secret_enc + SELECT id, password_hash, role, is_active, totp_enabled, totp_secret_enc, organization_id FROM admin_users WHERE email = ${email} LIMIT 1 `; @@ -131,7 +131,7 @@ adminRouter.post('/login', async (req: Request, res: Response): Promise => await db`UPDATE admin_users SET last_login_at = now() WHERE id = ${user.id}`; res.cookie(SESSION_COOKIE, token, sessionCookieOptions(req)); issueCsrfToken(req, res); // double-submit token for subsequent mutations - const mustEnroll = !user.totp_enabled && (await isTwoFactorRequired()); + const mustEnroll = !user.totp_enabled && (await isTwoFactorRequired(user.organization_id)); res.json({ id: user.id, email, role: user.role, totpEnabled: user.totp_enabled, mustEnroll }); }); @@ -178,8 +178,8 @@ adminRouter.post('/invites/accept', async (req: Request, res: Response): Promise const passwordHash = await hashPassword(parsed.data.password); const [user] = await db<{ id: string }[]>` - INSERT INTO admin_users (email, password_hash, role, is_active) - VALUES (${invite.email}, ${passwordHash}, ${invite.role}, true) + INSERT INTO admin_users (email, password_hash, role, is_active, organization_id) + VALUES (${invite.email}, ${passwordHash}, ${invite.role}, true, ${invite.organization_id}) ON CONFLICT (email) DO NOTHING RETURNING id `; @@ -193,7 +193,7 @@ adminRouter.post('/invites/accept', async (req: Request, res: Response): Promise const token = await createSession(user.id); res.cookie(SESSION_COOKIE, token, sessionCookieOptions(req)); issueCsrfToken(req, res); - const mustEnroll = await isTwoFactorRequired(); + const mustEnroll = await isTwoFactorRequired(invite.organization_id); res.status(201).json({ id: user.id, email: invite.email, role: invite.role, totpEnabled: false, mustEnroll }); }); @@ -206,7 +206,7 @@ adminRouter.use(enforce2faEnrollment); adminRouter.get('/me', async (req: Request, res: Response): Promise => { const user = req.adminUser!; - const mustEnroll = !user.totpEnabled && (await isTwoFactorRequired()); + const mustEnroll = !user.totpEnabled && (await isTwoFactorRequired(user.organizationId)); res.json({ id: user.id, email: user.email, role: user.role, totpEnabled: user.totpEnabled, mustEnroll }); }); @@ -218,6 +218,7 @@ adminRouter.get('/projects', async (req: Request, res: Response): Promise ? await db` SELECT id, name, key_prefix, created_at, 'owner' AS role FROM projects + WHERE organization_id = ${user.organizationId} ORDER BY created_at DESC ` : await db` @@ -240,8 +241,8 @@ adminRouter.post('/projects', requireCsrf, requireOwner, async (req: Request, re const { apiKey, keyHash, keyPrefix } = mintApiKey(); const [project] = await db<{ id: string; name: string; key_prefix: string; created_at: Date }[]>` - INSERT INTO projects (api_key_hash, name, key_prefix) - VALUES (${keyHash}, ${parsed.data.name}, ${keyPrefix}) + INSERT INTO projects (api_key_hash, name, key_prefix, organization_id) + VALUES (${keyHash}, ${parsed.data.name}, ${keyPrefix}, ${req.adminUser!.organizationId}) RETURNING id, name, key_prefix, created_at `; @@ -255,8 +256,11 @@ adminRouter.post('/projects/:id/rotate', requireCsrf, async (req: Request, res: res.status(400).json({ error: 'Missing project id' }); return; } + // Org-scoped existence check: a project in another tenant is indistinguishable from a + // missing one (404, never 403 — no cross-tenant existence leak). const existing = await db<{ api_key_hash: string }[]>` - SELECT api_key_hash FROM projects WHERE id = ${id} LIMIT 1 + SELECT api_key_hash FROM projects + WHERE id = ${id} AND organization_id = ${req.adminUser!.organizationId} LIMIT 1 `; if (!existing[0]) { res.status(404).json({ error: 'Project not found' }); @@ -287,7 +291,8 @@ adminRouter.delete('/projects/:id', requireCsrf, requireOwner, async (req: Reque return; } const existing = await db<{ api_key_hash: string }[]>` - SELECT api_key_hash FROM projects WHERE id = ${id} LIMIT 1 + SELECT api_key_hash FROM projects + WHERE id = ${id} AND organization_id = ${req.adminUser!.organizationId} LIMIT 1 `; if (!existing[0]) { res.status(404).json({ error: 'Project not found' }); @@ -335,14 +340,16 @@ adminRouter.post('/me/password', requireCsrf, async (req: Request, res: Response const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // List admins and pending invites. -adminRouter.get('/users', requireOwner, async (_req: Request, res: Response): Promise => { +adminRouter.get('/users', requireOwner, async (req: Request, res: Response): Promise => { + const org = req.adminUser!.organizationId; const users = await db` SELECT id, email, role, is_active, totp_enabled, last_login_at, created_at - FROM admin_users ORDER BY created_at ASC + FROM admin_users WHERE organization_id = ${org} ORDER BY created_at ASC `; const invites = await db` SELECT id, email, role, expires_at, created_at - FROM admin_invites WHERE accepted_at IS NULL AND expires_at > now() + FROM admin_invites + WHERE organization_id = ${org} AND accepted_at IS NULL AND expires_at > now() ORDER BY created_at DESC `; res.json({ users, invites }); @@ -367,7 +374,7 @@ adminRouter.post('/users/invite', requireCsrf, requireOwner, async (req: Request res.status(409).json({ error: 'An account with this email already exists' }); return; } - const { token, invite } = await createInvite(email, parsed.data.role, req.adminUser!.id); + const { token, invite } = await createInvite(email, parsed.data.role, req.adminUser!.id, req.adminUser!.organizationId); res.status(201).json({ invite, token }); }); @@ -392,21 +399,24 @@ adminRouter.patch('/users/:id', requireCsrf, requireOwner, async (req: Request, res.status(400).json({ error: 'You cannot change your own role or status' }); return; } + const org = req.adminUser!.organizationId; + // Org-scoped lookup: a user in another tenant reads as not-found (no cross-org edits). const target = await db<{ id: string; role: string; is_active: boolean }[]>` - SELECT id, role, is_active FROM admin_users WHERE id = ${id} LIMIT 1 + SELECT id, role, is_active FROM admin_users WHERE id = ${id} AND organization_id = ${org} LIMIT 1 `; if (!target[0]) { res.status(404).json({ error: 'User not found' }); return; } - // Don't strip the install of its last active owner (demote or deactivate). + // Don't strip the ORG of its last active owner (demote or deactivate). const losingOwner = target[0].role === 'owner' && ((parsed.data.role !== undefined && parsed.data.role !== 'owner') || parsed.data.is_active === false); if (losingOwner) { const ownerCount = await db<{ count: string }[]>` - SELECT COUNT(*) AS count FROM admin_users WHERE role = 'owner' AND is_active = true + SELECT COUNT(*) AS count FROM admin_users + WHERE role = 'owner' AND is_active = true AND organization_id = ${org} `; if (parseInt(ownerCount[0]?.count ?? '0', 10) <= 1) { res.status(400).json({ error: 'Cannot remove the last active owner' }); @@ -428,7 +438,7 @@ adminRouter.delete('/invites/:id', requireCsrf, requireOwner, async (req: Reques res.status(400).json({ error: 'Invalid invite id' }); return; } - await db`DELETE FROM admin_invites WHERE id = ${id}`; + await db`DELETE FROM admin_invites WHERE id = ${id} AND organization_id = ${req.adminUser!.organizationId}`; res.json({ deleted: true }); }); @@ -439,10 +449,13 @@ adminRouter.get('/users/:id/projects', requireOwner, async (req: Request, res: R res.status(400).json({ error: 'Invalid user id' }); return; } + // Constrain to the caller's org on both the user and the joined projects so an owner + // can't probe another tenant's grants. + const org = req.adminUser!.organizationId; const memberships = await db` SELECT pm.project_id, p.name, pm.role FROM project_members pm JOIN projects p ON p.id = pm.project_id - WHERE pm.user_id = ${id} + WHERE pm.user_id = ${id} AND p.organization_id = ${org} ORDER BY p.created_at DESC `; res.json({ memberships }); @@ -463,12 +476,15 @@ adminRouter.put('/users/:id/projects/:projectId', requireCsrf, requireOwner, asy res.status(400).json({ error: 'Invalid payload' }); return; } - const user = await db`SELECT id FROM admin_users WHERE id = ${userId} LIMIT 1`; + // Both the user and the project must live in the caller's org — you can only grant a + // member of your tenant access to a project of your tenant. + const org = req.adminUser!.organizationId; + const user = await db`SELECT id FROM admin_users WHERE id = ${userId} AND organization_id = ${org} LIMIT 1`; if (!user[0]) { res.status(404).json({ error: 'User not found' }); return; } - const project = await db`SELECT id FROM projects WHERE id = ${projectId} LIMIT 1`; + const project = await db`SELECT id FROM projects WHERE id = ${projectId} AND organization_id = ${org} LIMIT 1`; if (!project[0]) { res.status(404).json({ error: 'Project not found' }); return; @@ -489,7 +505,12 @@ adminRouter.delete('/users/:id/projects/:projectId', requireCsrf, requireOwner, res.status(400).json({ error: 'Invalid id' }); return; } - await db`DELETE FROM project_members WHERE user_id = ${userId} AND project_id = ${projectId}`; + // Only touch grants on projects in the caller's org. + await db` + DELETE FROM project_members + WHERE user_id = ${userId} AND project_id = ${projectId} + AND project_id IN (SELECT id FROM projects WHERE organization_id = ${req.adminUser!.organizationId}) + `; res.json({ deleted: true }); }); @@ -582,8 +603,8 @@ adminRouter.post('/me/2fa/disable', requireCsrf, async (req: Request, res: Respo // --- Install settings (owner-only) ------------------------------------------------- -adminRouter.get('/settings', requireOwner, async (_req: Request, res: Response): Promise => { - res.json({ require_2fa: await isTwoFactorRequired() }); +adminRouter.get('/settings', requireOwner, async (req: Request, res: Response): Promise => { + res.json({ require_2fa: await isTwoFactorRequired(req.adminUser!.organizationId) }); }); const SettingsSchema = z.object({ require_2fa: z.boolean() }); @@ -600,6 +621,6 @@ adminRouter.put('/settings', requireCsrf, requireOwner, async (req: Request, res res.status(400).json({ error: 'Cannot require two-factor: server encryption key (SCENT_SECRET_KEY) is not configured' }); return; } - await setTwoFactorRequired(parsed.data.require_2fa); + await setTwoFactorRequired(req.adminUser!.organizationId, parsed.data.require_2fa); res.json({ require_2fa: parsed.data.require_2fa }); }); diff --git a/packages/server/src/routes/org-isolation.integration.test.ts b/packages/server/src/routes/org-isolation.integration.test.ts new file mode 100644 index 0000000..6400bac --- /dev/null +++ b/packages/server/src/routes/org-isolation.integration.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import { createApp } from '../app.js'; +import { migrate } from '../db/migrate.js'; +import { db } from '../db/client.js'; +import { redis } from '../db/redis.js'; +import { hashPassword } from '../admin/password.js'; +import { createTestOrg, deleteTestOrg } from '../test-support/org.js'; + +// Cross-tenant isolation for the organizations layer (migration 013): an owner is a +// superuser WITHIN its org only. Two orgs (A, B), each with its own owner and project; +// asserts owner-A can never see, view, rotate, delete, or grant against B's resources, +// that user/invite listings are org-scoped, that an invite lands the new admin in the +// inviting org, and that the last-owner guard counts per-org. Gated on DATABASE_URL. +const hasDb = Boolean(process.env['DATABASE_URL']); + +const ORG_A = 'OrgIsoIT A'; +const ORG_B = 'OrgIsoIT B'; +const OWNER_A = 'orgiso-owner-a@example.com'; +const OWNER_B = 'orgiso-owner-b@example.com'; +const INVITEE = 'orgiso-invitee@example.com'; +const PASSWORD = 'test-password-123'; +const EMAILS = [OWNER_A, OWNER_B, INVITEE]; + +const app = createApp(); +let projectB: string; + +function csrfFrom(setCookie: string[] | string | undefined): string { + const cookies = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : []; + const c = cookies.find((x) => x.startsWith('scent_csrf=')); + return c ? c.split(';')[0]!.slice('scent_csrf='.length) : ''; +} + +async function cleanup(): Promise { + await db`DELETE FROM admin_users WHERE email = ANY(${EMAILS})`; + await db`DELETE FROM admin_invites WHERE email = ANY(${EMAILS})`; + await db`DELETE FROM projects WHERE name LIKE 'OrgIsoIT %'`; + await deleteTestOrg(ORG_A); + await deleteTestOrg(ORG_B); +} + +beforeAll(async () => { + if (!hasDb) return; + await migrate(); + await redis.flushdb(); + await cleanup(); + + const orgA = await createTestOrg(ORG_A); + const orgB = await createTestOrg(ORG_B); + await db`INSERT INTO admin_users (email, password_hash, role, is_active, organization_id) VALUES (${OWNER_A}, ${await hashPassword(PASSWORD)}, 'owner', true, ${orgA})`; + await db`INSERT INTO admin_users (email, password_hash, role, is_active, organization_id) VALUES (${OWNER_B}, ${await hashPassword(PASSWORD)}, 'owner', true, ${orgB})`; + const [pB] = await db<{ id: string }[]>` + INSERT INTO projects (api_key_hash, name, organization_id) VALUES ('orgiso-hash-b', 'OrgIsoIT Project B', ${orgB}) RETURNING id + `; + projectB = pB!.id; +}); + +afterAll(async () => { + if (!hasDb) return; + await cleanup(); + await redis.quit(); + await db.end(); +}); + +describe.skipIf(!hasDb)('organization isolation (integration)', () => { + const ownerA = request.agent(app); + let csrfA = ''; + + beforeAll(async () => { + if (!hasDb) return; + const a = await ownerA.post('/admin/login').send({ email: OWNER_A, password: PASSWORD }); + csrfA = csrfFrom(a.headers['set-cookie']); + }); + + it('owner-A does not see org-B projects in the list', async () => { + const res = await ownerA.get('/admin/projects'); + expect(res.status).toBe(200); + const ids = res.body.projects.map((p: { id: string }) => p.id); + expect(ids).not.toContain(projectB); + }); + + it('owner-A gets 404 (not 403) viewing an org-B project — no existence leak', async () => { + const res = await ownerA.get('/v1/dashboard').set('X-Project-Id', projectB); + expect(res.status).toBe(404); + }); + + it('owner-A gets 404 rotating an org-B project key', async () => { + const res = await ownerA.post(`/admin/projects/${projectB}/rotate`).set('X-CSRF-Token', csrfA); + expect(res.status).toBe(404); + }); + + it('owner-A gets 404 deleting an org-B project', async () => { + const res = await ownerA.delete(`/admin/projects/${projectB}`).set('X-CSRF-Token', csrfA); + expect(res.status).toBe(404); + }); + + it('owner-A only sees same-org admins in the user list', async () => { + const res = await ownerA.get('/admin/users'); + expect(res.status).toBe(200); + const emails = res.body.users.map((u: { email: string }) => u.email); + expect(emails).toContain(OWNER_A); + expect(emails).not.toContain(OWNER_B); + }); + + it('an invite from owner-A lands the new admin in org-A', async () => { + const inv = await ownerA.post('/admin/users/invite').set('X-CSRF-Token', csrfA).send({ email: INVITEE, role: 'member' }); + expect(inv.status).toBe(201); + + const accepter = request.agent(app); + const accepted = await accepter.post('/admin/invites/accept').send({ token: inv.body.token, password: PASSWORD }); + expect(accepted.status).toBe(201); + + // The accepted account is visible to owner-A (same org) and carries org-A. + const usersA = await ownerA.get('/admin/users'); + expect(usersA.body.users.map((u: { email: string }) => u.email)).toContain(INVITEE); + + // ...and invisible to owner-B. + const ownerB = request.agent(app); + await ownerB.post('/admin/login').send({ email: OWNER_B, password: PASSWORD }); + const usersB = await ownerB.get('/admin/users'); + expect(usersB.body.users.map((u: { email: string }) => u.email)).not.toContain(INVITEE); + }); + + it('the last-owner guard is per-org: owner-A can be the sole owner of its org', async () => { + // org-A has exactly one owner (OWNER_A). Trying to demote self is blocked by the + // self-edit guard, but the per-org last-owner count must not be inflated by org-B's + // owner. Demoting owner-A via owner-B must 404 (cross-org), proving separation. + const ownerB = request.agent(app); + const b = await ownerB.post('/admin/login').send({ email: OWNER_B, password: PASSWORD }); + const csrfB = csrfFrom(b.headers['set-cookie']); + const usersA = await ownerA.get('/admin/users'); + const aId = usersA.body.users.find((u: { email: string }) => u.email === OWNER_A)?.id; + const res = await ownerB.patch(`/admin/users/${aId}`).set('X-CSRF-Token', csrfB).send({ role: 'member' }); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/server/src/routes/project-access.integration.test.ts b/packages/server/src/routes/project-access.integration.test.ts index 700a04d..0c2d610 100644 --- a/packages/server/src/routes/project-access.integration.test.ts +++ b/packages/server/src/routes/project-access.integration.test.ts @@ -6,6 +6,7 @@ import { db } from '../db/client.js'; import { redis } from '../db/redis.js'; import { hashApiKey } from '../middleware/api-key.js'; import { hashPassword } from '../admin/password.js'; +import { createTestOrg, deleteTestOrg } from '../test-support/org.js'; // Integration coverage for requireProjectRead: the /v1 read routes accept EITHER a // project API key OR an admin session + X-Project-Id (GET only). Asserts both auth @@ -18,6 +19,7 @@ const PASSWORD = 'test-password-123'; const KEY_A = 'project-access-it-key-a'; const KEY_B = 'project-access-it-key-b'; const UNKNOWN_UUID = '00000000-0000-4000-8000-000000000000'; +const ORG = 'ProjAccessIT Org'; const app = createApp(); let idA: string; @@ -29,16 +31,18 @@ beforeAll(async () => { await redis.flushdb(); await db`DELETE FROM admin_users WHERE email = ${EMAIL}`; await db`DELETE FROM projects WHERE name LIKE 'ProjAccessIT %'`; + await deleteTestOrg(ORG); + const org = await createTestOrg(ORG); - // Owner — exercises the "view any project" path; member-scoping is covered in the - // dedicated RBAC suite. - await db`INSERT INTO admin_users (email, password_hash, role) VALUES (${EMAIL}, ${await hashPassword(PASSWORD)}, 'owner')`; + // Owner — exercises the "view any project in the org" path; member-scoping is covered + // in the dedicated RBAC suite. + await db`INSERT INTO admin_users (email, password_hash, role, organization_id) VALUES (${EMAIL}, ${await hashPassword(PASSWORD)}, 'owner', ${org})`; const [projA] = await db<{ id: string }[]>` - INSERT INTO projects (api_key_hash, name) VALUES (${hashApiKey(KEY_A)}, 'ProjAccessIT A') RETURNING id + INSERT INTO projects (api_key_hash, name, organization_id) VALUES (${hashApiKey(KEY_A)}, 'ProjAccessIT A', ${org}) RETURNING id `; const [projB] = await db<{ id: string }[]>` - INSERT INTO projects (api_key_hash, name) VALUES (${hashApiKey(KEY_B)}, 'ProjAccessIT B') RETURNING id + INSERT INTO projects (api_key_hash, name, organization_id) VALUES (${hashApiKey(KEY_B)}, 'ProjAccessIT B', ${org}) RETURNING id `; idA = projA!.id; idB = projB!.id; @@ -61,6 +65,7 @@ afterAll(async () => { if (!hasDb) return; await db`DELETE FROM admin_users WHERE email = ${EMAIL}`; await db`DELETE FROM projects WHERE name LIKE 'ProjAccessIT %'`; + await deleteTestOrg(ORG); await redis.quit(); await db.end(); }); diff --git a/packages/server/src/test-support/org.ts b/packages/server/src/test-support/org.ts new file mode 100644 index 0000000..0a22b7e --- /dev/null +++ b/packages/server/src/test-support/org.ts @@ -0,0 +1,15 @@ +import { db } from '../db/client.js'; + +// Test helper: create an organization and return its id. Multi-tenant tests assign their +// admins and projects to an org so the org-scoped admin queries (migration 013) resolve. +// Pair with deleteTestOrg in afterAll (delete dependent rows first — the FK is RESTRICT). +export async function createTestOrg(name: string, requireTwoFactor = false): Promise { + const [org] = await db<{ id: string }[]>` + INSERT INTO organizations (name, require_2fa) VALUES (${name}, ${requireTwoFactor}) RETURNING id + `; + return org!.id; +} + +export async function deleteTestOrg(name: string): Promise { + await db`DELETE FROM organizations WHERE name = ${name}`; +} From 8fe107c4e187e6801a7c60f659aeff9771a2a66e Mon Sep 17 00:00:00 2001 From: Ikerlaforga <19539979+Isonimus@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:52:29 +0200 Subject: [PATCH 2/2] chore(deps): override undici to >=6.27.0 (dev-only audit fix) A newly-published high-severity advisory (GHSA-vxpw-j846-p89q, undici DoS via WebSocket fragment-count bypass, fixed in 6.27.0) broke the CI audit step. undici 6.24.0 is a dev-only transitive dependency of @redocly/cli (the OpenAPI linter) and never ships in the prod Docker image. Pin it via pnpm.overrides, mirroring the earlier ws/form-data/protobufjs overrides. --- package.json | 3 ++- pnpm-lock.yaml | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index d6345fa..27ddb53 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "esbuild@>=0.17.0 <0.28.1": ">=0.28.1", "ws@>=8.0.0 <8.21.0": ">=8.21.0", "form-data@>=4.0.0 <4.0.6": ">=4.0.6", - "protobufjs@<=7.6.0": ">=7.6.1" + "protobufjs@<=7.6.0": ">=7.6.1", + "undici@<6.27.0": ">=6.27.0" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf45b68..976b617 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,7 @@ overrides: ws@>=8.0.0 <8.21.0: '>=8.21.0' form-data@>=4.0.0 <4.0.6: '>=4.0.6' protobufjs@<=7.6.0: '>=7.6.1' + undici@<6.27.0: '>=6.27.0' importers: @@ -4171,9 +4172,9 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@6.24.0: - resolution: {integrity: sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA==} - engines: {node: '>=18.17'} + undici@8.5.0: + resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} + engines: {node: '>=22.19.0'} universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} @@ -6028,7 +6029,7 @@ snapshots: simple-websocket: 9.1.0 styled-components: 6.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) ulid: 3.0.2 - undici: 6.24.0 + undici: 8.5.0 yargs: 17.0.1 transitivePeerDependencies: - '@opentelemetry/api' @@ -8622,7 +8623,7 @@ snapshots: undici-types@7.24.6: {} - undici@6.24.0: {} + undici@8.5.0: {} universalify@0.1.2: {}