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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
33 changes: 24 additions & 9 deletions packages/server/src/admin/authz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<boolean> {
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}
Expand All @@ -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<boolean> {
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'
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/admin/enforce-2fa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
15 changes: 9 additions & 6 deletions packages/server/src/admin/invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Invite[]>`
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! };
Expand All @@ -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
Expand Down
19 changes: 12 additions & 7 deletions packages/server/src/admin/settings.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<boolean> {
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<void> {
await db`UPDATE admin_settings SET require_2fa = ${value} WHERE id = true`;
export async function setTwoFactorRequired(organizationId: string, value: boolean): Promise<void> {
await db`UPDATE organizations SET require_2fa = ${value} WHERE id = ${organizationId}`;
}
8 changes: 5 additions & 3 deletions packages/server/src/middleware/project-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
14 changes: 9 additions & 5 deletions packages/server/src/routes/admin-2fa.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();

Expand All @@ -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();
});
Expand Down
9 changes: 7 additions & 2 deletions packages/server/src/routes/admin-account.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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;
});
Expand All @@ -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();
});
Expand Down
15 changes: 10 additions & 5 deletions packages/server/src/routes/admin-rbac.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<string> => {
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;
};
Expand All @@ -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();
});
Expand Down
7 changes: 6 additions & 1 deletion packages/server/src/routes/admin.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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
// the Redis auth-cache invalidation. Gated on DATABASE_URL like the other suites.
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();

Expand All @@ -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();
});
Expand Down
Loading
Loading