feat(P2): Wave B identity & RBAC — WorkOS SSO, tenant provisioning, RBAC at gateway
REQ-001: SSO session via WorkOS (getAuthorizationUrl + exchangeCodeForSession) REQ-002: tenant provisioning on first signup (admin role) REQ-003: invitations via single-use acceptance link REQ-004: RBAC role assignment (admin/operator/viewer) REQ-005: RBAC enforcement at API gateway from first endpoint (/api/me) ---ci--- phase: 2 milestone: v0.1 status: execute ---/ci---
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "coverage/**"],
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@coreci/auth",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./rbac": {
|
||||
"types": "./dist/rbac.d.ts",
|
||||
"import": "./dist/rbac.js"
|
||||
},
|
||||
"./sessions": {
|
||||
"types": "./dist/sessions.d.ts",
|
||||
"import": "./dist/sessions.js"
|
||||
},
|
||||
"./middleware": {
|
||||
"types": "./dist/middleware.d.ts",
|
||||
"import": "./dist/middleware.js"
|
||||
},
|
||||
"./provisioning": {
|
||||
"types": "./dist/provisioning.d.ts",
|
||||
"import": "./dist/provisioning.js"
|
||||
},
|
||||
"./invitations": {
|
||||
"types": "./dist/invitations.d.ts",
|
||||
"import": "./dist/invitations.js"
|
||||
},
|
||||
"./workos": {
|
||||
"types": "./dist/workos.d.ts",
|
||||
"import": "./dist/workos.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coreci/config": "workspace:*",
|
||||
"@coreci/db": "workspace:*",
|
||||
"@coreci/secrets": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @coreci/auth — identity & RBAC for CoreCI Chat (Wave B, REQ-001..005).
|
||||
*
|
||||
* - sessions: server-side session + signed JWT (REQ-001)
|
||||
* - workos: thin WorkOS SSO wrapper with dev/mock mode (REQ-001)
|
||||
* - provisioning: tenant provisioning on first signup (REQ-002)
|
||||
* - invitations: single-use acceptance links (REQ-003)
|
||||
* - rbac: role → permission map + gateway enforcement (REQ-004, REQ-005)
|
||||
* - middleware: API gateway auth + tenant resolution (REQ-005 critical-path)
|
||||
*/
|
||||
|
||||
export type { Role, Permission, RbacDecision } from "./rbac.js";
|
||||
export {
|
||||
ROLE_PERMISSIONS,
|
||||
checkPermission,
|
||||
enforceRbac,
|
||||
isWriteMethod,
|
||||
} from "./rbac.js";
|
||||
|
||||
export type { SessionData, SessionToken, CreateSessionOptions } from "./sessions.js";
|
||||
export {
|
||||
createSession,
|
||||
verifySession,
|
||||
destroySession,
|
||||
destroySessionByToken,
|
||||
SessionError,
|
||||
} from "./sessions.js";
|
||||
|
||||
export type { WorkosConfig, WorkosUser } from "./workos.js";
|
||||
export {
|
||||
getAuthorizationUrl,
|
||||
exchangeCodeForSession,
|
||||
SsoProviderError,
|
||||
} from "./workos.js";
|
||||
|
||||
export type { ProvisionResult, ProvisionDb } from "./provisioning.js";
|
||||
export { provisionTenant } from "./provisioning.js";
|
||||
|
||||
export type { Invitation, CreateInvitationOptions, AcceptInvitationResult, InvitationDb } from "./invitations.js";
|
||||
export {
|
||||
createInvitation,
|
||||
acceptInvitation,
|
||||
markInvitationBounced,
|
||||
InvitationError,
|
||||
} from "./invitations.js";
|
||||
|
||||
export type { AuthRequest, AuthResult, AuthMiddlewareOptions, AuthUser } from "./middleware.js";
|
||||
export {
|
||||
createAuthMiddleware,
|
||||
authenticate,
|
||||
SESSION_COOKIE,
|
||||
} from "./middleware.js";
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* @coreci/auth/invitations — single-use invitation links (REQ-003).
|
||||
*
|
||||
* M1 implementation: `createInvitation` INSERTs an invitation row (status=
|
||||
* pending) and returns the single-use acceptance link
|
||||
* `{baseUrl}/api/invitations/accept?token={invitationId}`.
|
||||
*
|
||||
* In prod, the WorkOS invitation API sends the email (we persist the
|
||||
* workos_invitation_id). In dev/test (no WorkOS keys), the link is returned
|
||||
* directly so the flow is exercisable end-to-end. The DB row is the source of
|
||||
* truth either way.
|
||||
*
|
||||
* `acceptInvitation(token)`:
|
||||
* - marks the invitation accepted (status=accepted, accepted_at=now)
|
||||
* - if the email is new: creates a user + membership (role from the invite)
|
||||
* - if the user already exists: creates a membership (or no-op if one exists)
|
||||
* - Edge 15 (bounce): `markInvitationBounced(token)` sets status=bounced.
|
||||
*
|
||||
* All tenant-scoped writes run inside withTenant. Audit `auth` event on accept.
|
||||
*/
|
||||
|
||||
import { withTenant, appendAudit, type ScopedClient } from "@coreci/db";
|
||||
import type { Role } from "./rbac.js";
|
||||
|
||||
export interface Invitation {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
status: "pending" | "accepted" | "bounced" | "revoked";
|
||||
createdAt: string;
|
||||
acceptedAt: string | null;
|
||||
/** The single-use acceptance link (only populated by createInvitation). */
|
||||
acceptanceLink: string | null;
|
||||
}
|
||||
|
||||
export interface CreateInvitationOptions {
|
||||
/** Base URL for building the acceptance link (e.g. https://app.coreci.dev). */
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export interface AcceptInvitationResult {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
role: Role;
|
||||
/** True if a new user was created. */
|
||||
createdUser: boolean;
|
||||
/** True if a new membership was created (false if one already existed). */
|
||||
createdMembership: boolean;
|
||||
}
|
||||
|
||||
export class InvitationError extends Error {
|
||||
override readonly cause: unknown | undefined;
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message);
|
||||
this.name = "InvitationError";
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export interface InvitationDb {
|
||||
query<T = Record<string, unknown>>(text: string, params?: unknown[]): Promise<{ rows: T[]; rowCount: number }>;
|
||||
}
|
||||
|
||||
interface InvitationRow {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
status: Invitation["status"];
|
||||
created_at: string;
|
||||
accepted_at: string | null;
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an invitation. Admin-only (RBAC enforced by the caller). The invite
|
||||
* is tenant-scoped, so the INSERT runs inside withTenant(tenantId).
|
||||
*/
|
||||
export async function createInvitation(
|
||||
db: InvitationDb,
|
||||
tenantId: string,
|
||||
adminUserId: string,
|
||||
email: string,
|
||||
role: Role,
|
||||
opts: CreateInvitationOptions,
|
||||
): Promise<Invitation> {
|
||||
// `db` is part of the DI contract (callers pass the resolved DbClient); the
|
||||
// tenant-scoped INSERT below runs via withTenant which uses the globally
|
||||
// set client. Touch db so the parameter is not flagged unused.
|
||||
void db;
|
||||
let invitationId = "";
|
||||
let createdAt = "";
|
||||
await withTenant(tenantId, async (c: ScopedClient) => {
|
||||
const res = await c.query<InvitationRow>(
|
||||
`INSERT INTO invitations (tenant_id, email, role, status)
|
||||
VALUES ($1, $2, $3, 'pending')
|
||||
RETURNING id, tenant_id, email, role, status, created_at, accepted_at`,
|
||||
[tenantId, email, role],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) throw new InvitationError("createInvitation: RETURNING yielded no row");
|
||||
invitationId = row.id;
|
||||
createdAt = row.created_at;
|
||||
|
||||
// Audit the invite creation (auth event under REQ-038).
|
||||
await appendAudit(c, {
|
||||
tenantId,
|
||||
eventType: "auth",
|
||||
payload: { action: "invite_created", email, role },
|
||||
userId: adminUserId,
|
||||
});
|
||||
});
|
||||
|
||||
const acceptanceLink = `${opts.baseUrl}/api/invitations/accept?token=${encodeURIComponent(invitationId)}`;
|
||||
|
||||
return {
|
||||
id: invitationId,
|
||||
tenantId,
|
||||
email,
|
||||
role,
|
||||
status: "pending",
|
||||
createdAt,
|
||||
acceptedAt: null,
|
||||
acceptanceLink,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invitation by its single-use token (the invitation id). Marks the
|
||||
* invite accepted, creates the user (if new) + membership (if new).
|
||||
*
|
||||
* Edge 15: `markInvitationBounced` is a separate path (webhook) — this function
|
||||
* throws InvitationError if the invite is not in `pending` status.
|
||||
*/
|
||||
export async function acceptInvitation(
|
||||
db: InvitationDb,
|
||||
token: string,
|
||||
): Promise<AcceptInvitationResult> {
|
||||
// 1. Resolve the pending invitation. Not tenant-scoped lookup (we don't have
|
||||
// a tenant context yet — the acceptor may be a brand-new user). We
|
||||
// temporarily DISABLE RLS on invitations for this read the same way the
|
||||
// audit tests do — but to keep this package free of DDL side effects, we
|
||||
// read via a direct query and rely on the migrator having GRANT'd SELECT
|
||||
// on invitations to the app role. In PGlite RLS on SELECT is not enforced
|
||||
// (see migration comment), so the bare SELECT works in dev/test.
|
||||
const invRes = await db.query<InvitationRow>(
|
||||
"SELECT id, tenant_id, email, role, status, created_at, accepted_at FROM invitations WHERE id = $1",
|
||||
[token],
|
||||
);
|
||||
const inv = invRes.rows[0];
|
||||
if (!inv) throw new InvitationError("invitation not found", { token });
|
||||
if (inv.status !== "pending") {
|
||||
throw new InvitationError(`invitation is not pending (status=${inv.status})`);
|
||||
}
|
||||
|
||||
// 2. Resolve-or-create the global user by email.
|
||||
let user = await resolveUserByEmail(db, inv.email);
|
||||
let createdUser = false;
|
||||
if (!user) {
|
||||
user = await createUserByEmail(db, inv.email);
|
||||
createdUser = true;
|
||||
}
|
||||
|
||||
// 3. Inside withTenant(tenant_id): mark accepted, create membership, audit.
|
||||
let createdMembership = false;
|
||||
await withTenant(inv.tenant_id, async (c: ScopedClient) => {
|
||||
// Mark accepted.
|
||||
await c.query(
|
||||
`UPDATE invitations SET status = 'accepted', accepted_at = now() WHERE id = $1 AND status = 'pending'`,
|
||||
[inv.id],
|
||||
);
|
||||
|
||||
// Idempotent membership create.
|
||||
const existing = await c.query<{ role: Role }>(
|
||||
"SELECT role FROM tenant_memberships WHERE tenant_id = $1 AND user_id = $2",
|
||||
[inv.tenant_id, user!.id],
|
||||
);
|
||||
if (!existing.rows[0]) {
|
||||
await c.query(
|
||||
`INSERT INTO tenant_memberships (tenant_id, user_id, role)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[inv.tenant_id, user!.id, inv.role],
|
||||
);
|
||||
createdMembership = true;
|
||||
} else {
|
||||
// Membership already exists — update the role to match the accepted invite.
|
||||
await c.query(
|
||||
`UPDATE tenant_memberships SET role = $1 WHERE tenant_id = $2 AND user_id = $3`,
|
||||
[inv.role, inv.tenant_id, user!.id],
|
||||
);
|
||||
}
|
||||
|
||||
// Audit the acceptance (auth event).
|
||||
await appendAudit(c, {
|
||||
tenantId: inv.tenant_id,
|
||||
eventType: "auth",
|
||||
payload: {
|
||||
action: "invite_accepted",
|
||||
email: inv.email,
|
||||
role: inv.role,
|
||||
createdUser,
|
||||
createdMembership,
|
||||
},
|
||||
userId: user!.id,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
userId: user!.id,
|
||||
tenantId: inv.tenant_id,
|
||||
role: inv.role,
|
||||
createdUser,
|
||||
createdMembership,
|
||||
};
|
||||
}
|
||||
|
||||
/** Edge 15: mark an invitation as bounced (via WorkOS webhook or admin action). */
|
||||
export async function markInvitationBounced(db: InvitationDb, token: string): Promise<void> {
|
||||
await withTenantBounce(db, token);
|
||||
}
|
||||
|
||||
async function withTenantBounce(db: InvitationDb, token: string): Promise<void> {
|
||||
// Resolve tenant to scope the UPDATE under RLS.
|
||||
const invRes = await db.query<{ tenant_id: string }>(
|
||||
"SELECT tenant_id FROM invitations WHERE id = $1",
|
||||
[token],
|
||||
);
|
||||
const inv = invRes.rows[0];
|
||||
if (!inv) throw new InvitationError("invitation not found", { token });
|
||||
|
||||
await withTenant(inv.tenant_id, async (c: ScopedClient) => {
|
||||
await c.query(
|
||||
`UPDATE invitations SET status = 'bounced' WHERE id = $1 AND status = 'pending'`,
|
||||
[token],
|
||||
);
|
||||
await appendAudit(c, {
|
||||
tenantId: inv.tenant_id,
|
||||
eventType: "auth",
|
||||
payload: { action: "invite_bounced", token },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveUserByEmail(db: InvitationDb, email: string): Promise<UserRow | null> {
|
||||
const res = await db.query<UserRow>("SELECT id, email FROM users WHERE email = $1", [email]);
|
||||
return res.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function createUserByEmail(db: InvitationDb, email: string): Promise<UserRow> {
|
||||
const res = await db.query<UserRow>(
|
||||
`INSERT INTO users (email) VALUES ($1) RETURNING id, email`,
|
||||
[email],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) throw new InvitationError("createUserByEmail: RETURNING yielded no row");
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @coreci/auth/middleware — API gateway auth + tenant resolution middleware.
|
||||
*
|
||||
* This is the FIRST thing every /api/* request hits (REQ-005 critical-path:
|
||||
* auth + tenant resolve + RBAC at the gateway from the first endpoint).
|
||||
*
|
||||
* Framework-agnostic: operates on a minimal `AuthRequest` abstraction so it
|
||||
* can be adapted to Next.js Route Handlers, Express, or a test harness without
|
||||
* coupling to any one framework's Request type. The control-plane's
|
||||
* `app/middleware.ts` wraps this for Next's `NextRequest`.
|
||||
*
|
||||
* Flow:
|
||||
* 1. read `coreci_session` httpOnly cookie
|
||||
* 2. verifySession(token) → SessionData | null
|
||||
* 3. if null → return { status: "unauthorized" }
|
||||
* 4. enforceRbac(role, method, path) → RbacDecision
|
||||
* 5. if not allowed → return { status: "forbidden", decision }
|
||||
* 6. return { status: "authorized", user, decision } — caller attaches
|
||||
* `req.user = user` and runs DB work inside withTenant(user.tenantId).
|
||||
*/
|
||||
|
||||
import type { DbClient } from "@coreci/db";
|
||||
import { verifySession, type SessionData } from "./sessions.js";
|
||||
import { enforceRbac, type RbacDecision, type Role } from "./rbac.js";
|
||||
|
||||
/** The cookie name carrying the signed session JWT. */
|
||||
export const SESSION_COOKIE = "coreci_session";
|
||||
|
||||
/** Minimal request surface the middleware needs (framework-agnostic). */
|
||||
export interface AuthRequest {
|
||||
/** HTTP method (GET/POST/...). */
|
||||
method: string;
|
||||
/** Path portion of the URL (e.g. "/api/me"). Query string excluded. */
|
||||
path: string;
|
||||
/** Read a cookie by name. Returns undefined if absent. */
|
||||
getCookie(name: string): string | undefined;
|
||||
}
|
||||
|
||||
export interface AuthMiddlewareOptions {
|
||||
/** HS256 session signing key (from @coreci/config session.signingKey). */
|
||||
sessionSigningKey: string;
|
||||
}
|
||||
|
||||
export type AuthResult =
|
||||
| { status: "authorized"; user: SessionData; decision: RbacDecision }
|
||||
| { status: "unauthorized"; reason: "no_cookie" | "invalid_session" }
|
||||
| { status: "forbidden"; user: SessionData; decision: RbacDecision };
|
||||
|
||||
/** Build the auth middleware with a pinned DbClient + signing key. */
|
||||
export function createAuthMiddleware(db: DbClient, opts: AuthMiddlewareOptions) {
|
||||
const signingKey = opts.sessionSigningKey;
|
||||
return async function authMiddleware(req: AuthRequest): Promise<AuthResult> {
|
||||
const cookie = req.getCookie(SESSION_COOKIE);
|
||||
if (!cookie) {
|
||||
return { status: "unauthorized", reason: "no_cookie" };
|
||||
}
|
||||
|
||||
const session = await verifySession(db, signingKey, cookie);
|
||||
if (!session) {
|
||||
return { status: "unauthorized", reason: "invalid_session" };
|
||||
}
|
||||
|
||||
const decision = enforceRbac(session.role, req.method, req.path);
|
||||
if (!decision.allowed) {
|
||||
return { status: "forbidden", user: session, decision };
|
||||
}
|
||||
|
||||
return { status: "authorized", user: session, decision };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone auth check (no DbClient pinning) — useful for tests + edge
|
||||
* functions that already have a DbClient in scope.
|
||||
*/
|
||||
export async function authenticate(
|
||||
db: DbClient,
|
||||
signingKey: string,
|
||||
req: AuthRequest,
|
||||
): Promise<AuthResult> {
|
||||
const cookie = req.getCookie(SESSION_COOKIE);
|
||||
if (!cookie) return { status: "unauthorized", reason: "no_cookie" };
|
||||
const session = await verifySession(db, signingKey, cookie);
|
||||
if (!session) return { status: "unauthorized", reason: "invalid_session" };
|
||||
const decision = enforceRbac(session.role, req.method, req.path);
|
||||
if (!decision.allowed) return { status: "forbidden", user: session, decision };
|
||||
return { status: "authorized", user: session, decision };
|
||||
}
|
||||
|
||||
/** A user attached to an authorized request (the shape routes consume). */
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
role: Role;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @coreci/auth/provisioning — tenant provisioning on first signup (REQ-002).
|
||||
*
|
||||
* `provisionTenant` is called on the auth callback after a successful WorkOS
|
||||
* code exchange. It maps a WorkOS (orgId, userId, email) pair to a CoreCI
|
||||
* (tenantId, userId, role) pair:
|
||||
*
|
||||
* - If no tenant exists for `workosOrgId`: INSERT tenant + user + membership
|
||||
* (role=admin). First signup is always Admin. Audit `provision` event.
|
||||
* - If a tenant exists but the user doesn't: INSERT user + membership
|
||||
* (role=viewer — invited users go through acceptInvitation; a direct SSO
|
||||
* of an unknown user into an existing org is provisioned as viewer by
|
||||
* default, auditable). Audit `provision` event.
|
||||
* - If both exist: idempotent no-op (return existing ids). No audit event.
|
||||
*
|
||||
* Tenant-scoped INSERTs run inside withTenant(). The tenant row itself is NOT
|
||||
* tenant-scoped (it's the scoping anchor), so it's inserted outside withTenant.
|
||||
* The user row is global (not tenant-scoped). The membership IS tenant-scoped
|
||||
* and goes inside withTenant.
|
||||
*/
|
||||
|
||||
import { withTenant, appendAudit, type ScopedClient } from "@coreci/db";
|
||||
|
||||
export interface ProvisionResult {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
/** The role the user holds in this tenant (admin for first signup). */
|
||||
role: "admin" | "operator" | "viewer";
|
||||
/** True if a new tenant was created in this call. */
|
||||
createdTenant: boolean;
|
||||
/** True if a new user/membership was created in this call. */
|
||||
createdUser: boolean;
|
||||
}
|
||||
|
||||
export interface ProvisionDb {
|
||||
/** Run a single statement. */
|
||||
query<T = Record<string, unknown>>(text: string, params?: unknown[]): Promise<{ rows: T[]; rowCount: number }>;
|
||||
}
|
||||
|
||||
interface TenantRow {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface MembershipRow {
|
||||
role: "admin" | "operator" | "viewer";
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a tenant + user from a WorkOS identity. Idempotent.
|
||||
*/
|
||||
export async function provisionTenant(
|
||||
db: ProvisionDb,
|
||||
workosOrgId: string,
|
||||
workosUserId: string,
|
||||
email: string,
|
||||
name: string,
|
||||
): Promise<ProvisionResult> {
|
||||
// 1. Resolve-or-create the tenant by WorkOS org id (NOT tenant-scoped).
|
||||
let tenant = await resolveTenantByOrg(db, workosOrgId);
|
||||
let createdTenant = false;
|
||||
if (!tenant) {
|
||||
tenant = await createTenant(db, workosOrgId, name);
|
||||
createdTenant = true;
|
||||
}
|
||||
|
||||
// 2. Resolve-or-create the global user (by workos_user_id, fallback by email).
|
||||
let user = await resolveUserByWorkosId(db, workosUserId);
|
||||
let createdUser = false;
|
||||
if (!user) {
|
||||
user = await resolveUserByEmail(db, email);
|
||||
}
|
||||
if (!user) {
|
||||
user = await createUser(db, workosUserId, email);
|
||||
createdUser = true;
|
||||
}
|
||||
|
||||
// 3. Resolve-or-create the membership (tenant-scoped).
|
||||
let membership = await resolveMembership(db, tenant.id, user.id);
|
||||
if (!membership) {
|
||||
const role: "admin" | "operator" | "viewer" = createdTenant ? "admin" : "viewer";
|
||||
await withTenant(tenant.id, async (c: ScopedClient) => {
|
||||
await c.query(
|
||||
`INSERT INTO tenant_memberships (tenant_id, user_id, role)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[tenant.id, user.id, role],
|
||||
);
|
||||
// Audit: provision event (only when we actually created something).
|
||||
await appendAudit(c, {
|
||||
tenantId: tenant.id,
|
||||
eventType: "provision",
|
||||
payload: {
|
||||
workosOrgId,
|
||||
workosUserId,
|
||||
email,
|
||||
role,
|
||||
createdTenant,
|
||||
createdUser,
|
||||
},
|
||||
userId: user.id,
|
||||
});
|
||||
});
|
||||
return {
|
||||
tenantId: tenant.id,
|
||||
userId: user.id,
|
||||
role,
|
||||
createdTenant,
|
||||
createdUser,
|
||||
};
|
||||
}
|
||||
|
||||
// Both exist + membership exists → idempotent. No audit (nothing changed).
|
||||
return {
|
||||
tenantId: tenant.id,
|
||||
userId: user.id,
|
||||
role: membership.role,
|
||||
createdTenant: false,
|
||||
createdUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveTenantByOrg(db: ProvisionDb, workosOrgId: string): Promise<TenantRow | null> {
|
||||
const res = await db.query<TenantRow>(
|
||||
"SELECT id, name FROM tenants WHERE workos_org_id = $1",
|
||||
[workosOrgId],
|
||||
);
|
||||
return res.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function createTenant(db: ProvisionDb, workosOrgId: string, name: string): Promise<TenantRow> {
|
||||
const res = await db.query<TenantRow>(
|
||||
`INSERT INTO tenants (name, workos_org_id) VALUES ($1, $2) RETURNING id, name`,
|
||||
[name || workosOrgId, workosOrgId],
|
||||
);
|
||||
// noUncheckedIndexedAccess: RETURNING guarantees a row but TS can't see it.
|
||||
const row = res.rows[0];
|
||||
if (!row) throw new Error("createTenant: RETURNING yielded no row");
|
||||
return row;
|
||||
}
|
||||
|
||||
async function resolveUserByWorkosId(db: ProvisionDb, workosUserId: string): Promise<UserRow | null> {
|
||||
const res = await db.query<UserRow>(
|
||||
"SELECT id, email FROM users WHERE workos_user_id = $1",
|
||||
[workosUserId],
|
||||
);
|
||||
return res.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function resolveUserByEmail(db: ProvisionDb, email: string): Promise<UserRow | null> {
|
||||
const res = await db.query<UserRow>(
|
||||
"SELECT id, email FROM users WHERE email = $1",
|
||||
[email],
|
||||
);
|
||||
return res.rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function createUser(db: ProvisionDb, workosUserId: string, email: string): Promise<UserRow> {
|
||||
const res = await db.query<UserRow>(
|
||||
`INSERT INTO users (email, workos_user_id) VALUES ($1, $2) RETURNING id, email`,
|
||||
[email, workosUserId],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) throw new Error("createUser: RETURNING yielded no row");
|
||||
return row;
|
||||
}
|
||||
|
||||
async function resolveMembership(
|
||||
db: ProvisionDb,
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
): Promise<MembershipRow | null> {
|
||||
const res = await db.query<MembershipRow>(
|
||||
"SELECT role FROM tenant_memberships WHERE tenant_id = $1 AND user_id = $2",
|
||||
[tenantId, userId],
|
||||
);
|
||||
return res.rows[0] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @coreci/auth/rbac — RBAC role → permission map + gateway enforcement (REQ-005).
|
||||
*
|
||||
* Roles (REQ-004): admin | operator | viewer.
|
||||
* Permissions: read (GET), write (POST/PUT/PATCH on chat/investigate), admin
|
||||
* (tenant management, BYOM config, RBAC management, relay token issuance).
|
||||
*
|
||||
* Role → permission map:
|
||||
* admin → read + write + admin
|
||||
* operator → read + write (chat, investigate — no tenant mgmt)
|
||||
* viewer → read only
|
||||
*
|
||||
* `enforceRbac(role, method, path)` is called by the API gateway on EVERY
|
||||
* /api/* request. It maps the HTTP method + path to a required Permission and
|
||||
* checks the role. This is the critical-path pattern from REQ-005: RBAC is set
|
||||
* at the API gateway from the FIRST endpoint — no auth-later stubs.
|
||||
*/
|
||||
|
||||
export type Role = "admin" | "operator" | "viewer";
|
||||
|
||||
export type Permission = "read" | "write" | "admin";
|
||||
|
||||
/** Role → set of granted permissions. The single source of truth. */
|
||||
export const ROLE_PERMISSIONS: Record<Role, ReadonlySet<Permission>> = {
|
||||
admin: new Set<Permission>(["read", "write", "admin"]),
|
||||
operator: new Set<Permission>(["read", "write"]),
|
||||
viewer: new Set<Permission>(["read"]),
|
||||
};
|
||||
|
||||
/** Does `role` grant `required`? */
|
||||
export function checkPermission(role: Role, required: Permission): boolean {
|
||||
const granted = ROLE_PERMISSIONS[role];
|
||||
return granted !== undefined && granted.has(required);
|
||||
}
|
||||
|
||||
export interface RbacDecision {
|
||||
allowed: boolean;
|
||||
/** The permission the route required (useful for 403 responses + audit). */
|
||||
required: Permission;
|
||||
/** True if the role itself was unknown (treated as denied). */
|
||||
unknownRole: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A route permission rule. `match` tests a path; `method` narrows the HTTP
|
||||
* method (undefined = any method). `required` is the permission needed.
|
||||
*/
|
||||
interface RouteRule {
|
||||
match: (path: string) => boolean;
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | undefined;
|
||||
required: Permission;
|
||||
}
|
||||
|
||||
// Method → write-or-admin classification helper for the method-narrowed rules.
|
||||
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
/**
|
||||
* Route permission table. Order matters: the FIRST matching rule wins, so the
|
||||
* most specific rules are listed first. Anything not matched falls through to
|
||||
* the default rule (read — any authenticated role).
|
||||
*
|
||||
* Per the Wave B task spec:
|
||||
* /api/me → read
|
||||
* /api/byom* GET → read; POST/PATCH/DELETE → admin
|
||||
* /api/team* GET → read; POST/PATCH → admin
|
||||
* /api/invitations* → POST admin; GET read
|
||||
* /api/relay/* → admin (relay token issuance is admin-only)
|
||||
* /api/targets* GET → read
|
||||
* default → read
|
||||
*/
|
||||
const ROUTE_RULES: readonly RouteRule[] = [
|
||||
// /api/relay/* is admin-only for every method (token issuance, WS admin).
|
||||
{
|
||||
match: (p) => p === "/api/relay" || p.startsWith("/api/relay/"),
|
||||
method: undefined,
|
||||
required: "admin",
|
||||
},
|
||||
// /api/byom — admin to mutate, read to inspect.
|
||||
{
|
||||
match: (p) => p === "/api/byom" || p.startsWith("/api/byom/"),
|
||||
method: "GET",
|
||||
required: "read",
|
||||
},
|
||||
{
|
||||
match: (p) => p === "/api/byom" || p.startsWith("/api/byom/"),
|
||||
method: undefined, // any non-GET → admin
|
||||
required: "admin",
|
||||
},
|
||||
// /api/team — admin to invite / change roles; read to list.
|
||||
{
|
||||
match: (p) => p === "/api/team" || p.startsWith("/api/team/"),
|
||||
method: "GET",
|
||||
required: "read",
|
||||
},
|
||||
{
|
||||
match: (p) => p === "/api/team" || p.startsWith("/api/team/"),
|
||||
method: undefined, // POST (invite), PATCH (role change) → admin
|
||||
required: "admin",
|
||||
},
|
||||
// /api/invitations/accept is special: a POST here is an unauthenticated-ish
|
||||
// acceptance flow, but we still gate it behind `read` (any authenticated role)
|
||||
// so an authenticated admin/operator/viewer cannot be tricked into escalating.
|
||||
// The acceptance route itself verifies the single-use token. MUST come before
|
||||
// the generic /api/invitations POST→admin rule (longest-prefix match wins).
|
||||
{
|
||||
match: (p) => p === "/api/invitations/accept" || p.startsWith("/api/invitations/accept/"),
|
||||
method: "POST",
|
||||
required: "read",
|
||||
},
|
||||
// /api/invitations — admin to create; read to list.
|
||||
{
|
||||
match: (p) => p === "/api/invitations" || p.startsWith("/api/invitations/"),
|
||||
method: "GET",
|
||||
required: "read",
|
||||
},
|
||||
{
|
||||
match: (p) => p === "/api/invitations" || p.startsWith("/api/invitations/"),
|
||||
method: "POST",
|
||||
required: "admin",
|
||||
},
|
||||
// /api/targets — read only in M1 (no target mutation routes yet).
|
||||
{
|
||||
match: (p) => p === "/api/targets" || p.startsWith("/api/targets/"),
|
||||
method: "GET",
|
||||
required: "read",
|
||||
},
|
||||
{
|
||||
match: (p) => p === "/api/targets" || p.startsWith("/api/targets/"),
|
||||
method: undefined, // any mutation → admin (future-proof)
|
||||
required: "admin",
|
||||
},
|
||||
// /api/me — any authenticated role.
|
||||
{
|
||||
match: (p) => p === "/api/me",
|
||||
method: undefined,
|
||||
required: "read",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_RULE: RouteRule = {
|
||||
match: () => true,
|
||||
method: undefined,
|
||||
required: "read",
|
||||
};
|
||||
|
||||
function normalizeMethod(method: string): "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | undefined {
|
||||
const upper = method.toUpperCase();
|
||||
if (upper === "GET" || upper === "POST" || upper === "PUT" || upper === "PATCH" || upper === "DELETE") {
|
||||
return upper;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findRule(path: string, methodNorm: string | undefined): RouteRule {
|
||||
for (const rule of ROUTE_RULES) {
|
||||
if (!rule.match(path)) continue;
|
||||
// A rule with method: undefined matches any method (it's a catch-all for
|
||||
// this path prefix). A rule with a specific method only matches that
|
||||
// method; if it doesn't match, keep looking (e.g. GET rule then admin rule).
|
||||
if (rule.method === undefined || rule.method === methodNorm) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
return DEFAULT_RULE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce RBAC for (role, method, path). Called by the API gateway on every
|
||||
* /api/* request.
|
||||
*
|
||||
* - Maps (method, path) → required Permission via ROUTE_RULES.
|
||||
* - Returns { allowed, required, unknownRole }.
|
||||
* - Write methods (POST/PUT/PATCH/DELETE) on a path with only a GET rule fall
|
||||
* through to the admin catch-all rule for that prefix (so a stray POST to
|
||||
* /api/targets is admin-gated, not silently read-allowed).
|
||||
*/
|
||||
export function enforceRbac(role: Role, method: string, path: string): RbacDecision {
|
||||
const methodNorm = normalizeMethod(method);
|
||||
// Normalize trailing slash so "/api/team/" and "/api/team" match the same.
|
||||
const normPath = path.endsWith("/") && path.length > 1 ? path.slice(0, -1) : path;
|
||||
|
||||
const rule = findRule(normPath, methodNorm);
|
||||
const isKnownRole = (ROLE_PERMISSIONS[role] !== undefined) as boolean;
|
||||
const allowed = isKnownRole && checkPermission(role, rule.required);
|
||||
return { allowed, required: rule.required, unknownRole: !isKnownRole };
|
||||
}
|
||||
|
||||
/** True if `method` is a write-class HTTP method (used by callers/tests). */
|
||||
export function isWriteMethod(method: string): boolean {
|
||||
return WRITE_METHODS.has(method.toUpperCase());
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @coreci/auth/sessions — server-side session management (REQ-001).
|
||||
*
|
||||
* `createSession` INSERTs a row into the `sessions` table (NOT tenant-scoped;
|
||||
* see migration 0002_sessions.sql) and returns a signed HS256 JWT carrying only
|
||||
* the session id (+ iat/exp for cheap pre-DB rejection).
|
||||
*
|
||||
* `verifySession` decodes the JWT, looks up the session row by id, and returns
|
||||
* { userId, tenantId, role } — or null if expired / revoked / tampered.
|
||||
*
|
||||
* `destroySession` DELETEs the row (logout / role-change invalidation).
|
||||
*
|
||||
* The JWT is hand-rolled (same pattern as @coreci/secrets/relay-token) so the
|
||||
* auth package has no hard dependency on a JWT lib. The signing key is the
|
||||
* platform bootstrap SESSION_SIGNING_KEY (G-010 tier a infra cred, NOT a tenant
|
||||
* secret) loaded via @coreci/config.
|
||||
*/
|
||||
|
||||
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import type { DbClient } from "@coreci/db";
|
||||
|
||||
/** Data the API gateway attaches to req.user after a verified session. */
|
||||
export interface SessionData {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
role: "admin" | "operator" | "viewer";
|
||||
}
|
||||
|
||||
/** A signed session token (the JWT string to put in the httpOnly cookie). */
|
||||
export interface SessionToken {
|
||||
/** The JWT string. */
|
||||
token: string;
|
||||
/** The session row id (also the JWT `sid` claim). */
|
||||
sessionId: string;
|
||||
/** Absolute expiry (epoch seconds). */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export class SessionError extends Error {
|
||||
override readonly cause: unknown | undefined;
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message);
|
||||
this.name = "SessionError";
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
const JWT_HEADER = { alg: "HS256", typ: "JWT" };
|
||||
|
||||
function base64url(input: Buffer | string): string {
|
||||
const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
|
||||
return buf.toString("base64url");
|
||||
}
|
||||
|
||||
function base64urlDecode(input: string): Buffer {
|
||||
return Buffer.from(input, "base64url");
|
||||
}
|
||||
|
||||
function hmacSign(data: string, key: string): Buffer {
|
||||
return createHmac("sha256", key).update(data, "utf8").digest();
|
||||
}
|
||||
|
||||
interface SessionJwtClaims {
|
||||
sid: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function signJwt(claims: SessionJwtClaims, signingKey: string): string {
|
||||
const headerB64 = base64url(JSON.stringify(JWT_HEADER));
|
||||
const payloadB64 = base64url(JSON.stringify(claims));
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const sig = hmacSign(signingInput, signingKey);
|
||||
return `${signingInput}.${base64url(sig)}`;
|
||||
}
|
||||
|
||||
/** Decode + signature-verify a session JWT. Returns claims or null. */
|
||||
function verifyJwt(token: string, signingKey: string): SessionJwtClaims | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const [headerB64, payloadB64, sigB64] = parts as [string, string, string];
|
||||
|
||||
let header: { alg?: string; typ?: string };
|
||||
try {
|
||||
header = JSON.parse(base64urlDecode(headerB64).toString("utf8")) as typeof header;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (header.alg !== "HS256") return null;
|
||||
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const expectedSig = hmacSign(signingInput, signingKey);
|
||||
let providedSig: Buffer;
|
||||
try {
|
||||
providedSig = base64urlDecode(sigB64);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (expectedSig.length !== providedSig.length || !timingSafeEqual(expectedSig, providedSig)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let claims: SessionJwtClaims;
|
||||
try {
|
||||
claims = JSON.parse(base64urlDecode(payloadB64).toString("utf8")) as SessionJwtClaims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof claims.sid !== "string" || typeof claims.exp !== "number" || typeof claims.iat !== "number") {
|
||||
return null;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (claims.exp <= now) return null;
|
||||
return claims;
|
||||
}
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
/** Session lifetime in seconds (default 7 days, matches @coreci/config default). */
|
||||
lifetimeSeconds?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session row + signed JWT. NOT scoped by withTenant — sessions are
|
||||
* cross-tenant by design (the lookup is by session id, not tenant_id).
|
||||
*/
|
||||
export async function createSession(
|
||||
db: DbClient,
|
||||
signingKey: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
role: SessionData["role"],
|
||||
opts: CreateSessionOptions = {},
|
||||
): Promise<SessionToken> {
|
||||
const lifetimeSeconds = opts.lifetimeSeconds ?? 7 * 24 * 60 * 60;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + lifetimeSeconds;
|
||||
|
||||
// INSERT with explicit id so the JWT sid == row id (no read-back needed).
|
||||
const sessionId = randomUUID();
|
||||
await db.query(
|
||||
`INSERT INTO sessions (id, user_id, tenant_id, role, expires_at)
|
||||
VALUES ($1, $2, $3, $4, to_timestamp($5))`,
|
||||
[sessionId, userId, tenantId, role, exp],
|
||||
);
|
||||
|
||||
const token = signJwt({ sid: sessionId, iat: now, exp }, signingKey);
|
||||
return { token, sessionId, expiresAt: exp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a session token: JWT signature + exp check, then DB row lookup +
|
||||
* expires_at check. Returns the resolved SessionData or null.
|
||||
*/
|
||||
export async function verifySession(
|
||||
db: DbClient,
|
||||
signingKey: string,
|
||||
token: string,
|
||||
): Promise<SessionData | null> {
|
||||
const claims = verifyJwt(token, signingKey);
|
||||
if (!claims) return null;
|
||||
|
||||
const res = await db.query<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
tenant_id: string;
|
||||
role: SessionData["role"];
|
||||
expires_at: string;
|
||||
}>(
|
||||
`SELECT id, user_id, tenant_id, role, expires_at
|
||||
FROM sessions
|
||||
WHERE id = $1 AND expires_at > now()`,
|
||||
[claims.sid],
|
||||
);
|
||||
const row = res.rows[0];
|
||||
if (!row) return null;
|
||||
if (row.role !== "admin" && row.role !== "operator" && row.role !== "viewer") return null;
|
||||
|
||||
return {
|
||||
sessionId: row.id,
|
||||
userId: row.user_id,
|
||||
tenantId: row.tenant_id,
|
||||
role: row.role,
|
||||
};
|
||||
}
|
||||
|
||||
/** Delete the session row (logout / role-change invalidation). */
|
||||
export async function destroySession(db: DbClient, sessionId: string): Promise<void> {
|
||||
await db.query("DELETE FROM sessions WHERE id = $1", [sessionId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy by token: decode + delete the row by id. Convenience for logout.
|
||||
* Returns true if a session was deleted, false if the token was invalid / gone.
|
||||
*/
|
||||
export async function destroySessionByToken(
|
||||
db: DbClient,
|
||||
signingKey: string,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
const claims = verifyJwt(token, signingKey);
|
||||
if (!claims) return false;
|
||||
const res = await db.query("DELETE FROM sessions WHERE id = $1", [claims.sid]);
|
||||
return res.rowCount > 0;
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Ambient module declaration for the optional WorkOS SDK.
|
||||
*
|
||||
* The real package is `@workos-inc/node` (installed only in prod). In dev/test
|
||||
* the auth package runs without it (workos.ts falls back to a mock when no
|
||||
* WORKOS_API_KEY is set). This ambient shim lets `import("@workos-inc/node")`
|
||||
* typecheck without the package being installed; the runtime import is only
|
||||
* reached when an apiKey is configured, at which point the package MUST be
|
||||
* installed (a missing install surfaces as SsoProviderError, not a crash).
|
||||
*
|
||||
* The shape mirrors only the surface workos.ts uses (userManagement.authenticateWithCode).
|
||||
*/
|
||||
|
||||
declare module "@workos-inc/node" {
|
||||
export interface WorkosUser {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}
|
||||
export interface AuthenticateWithCodeResponse {
|
||||
user: WorkosUser;
|
||||
organizationId?: string;
|
||||
}
|
||||
export interface UserManagement {
|
||||
authenticateWithCode(params: {
|
||||
code: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
}): Promise<AuthenticateWithCodeResponse>;
|
||||
}
|
||||
export interface WorkosClient {
|
||||
userManagement: UserManagement;
|
||||
}
|
||||
export function WorkOS(apiKey: string, options?: { clientId?: string }): WorkosClient;
|
||||
export const WorkOS: typeof WorkOS;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @coreci/auth/workos — thin WorkOS SSO wrapper (REQ-001).
|
||||
*
|
||||
* The real WorkOS Node SDK is `@workos-inc/node`. It is an optional dependency
|
||||
* loaded via dynamic import ONLY when `apiKey` is configured. In dev/test
|
||||
* (no WORKOS_API_KEY), a deterministic MOCK implementation is used so the auth
|
||||
* flow is fully exercisable without a WorkOS account or network access.
|
||||
*
|
||||
* API contract (locked, used by apps/control-plane routes):
|
||||
* getAuthorizationUrl(config) → WorkOS SSO authorization URL (redirect target)
|
||||
* exchangeCodeForSession(config, code) → { workosOrgId, workosUserId, email, name }
|
||||
*
|
||||
* R-002 pitfalls accounted for:
|
||||
* - Edge 10 (SSO provider down): the real exchange path throws SsoProviderError;
|
||||
* the control plane surfaces a retry screen + blocks tenant creation.
|
||||
* - We do NOT trust WorkOS role/group claims (REQ-005 enforces at OUR gateway).
|
||||
*/
|
||||
|
||||
export interface WorkosConfig {
|
||||
apiKey: string | undefined;
|
||||
clientId: string | undefined;
|
||||
/** OAuth/OIDC redirect URI (must match WorkOS dashboard). */
|
||||
redirectUrl: string;
|
||||
/** Public app base URL (for building absolute redirect URLs). */
|
||||
appBaseUrl: string;
|
||||
}
|
||||
|
||||
/** Resolved WorkOS user (after code exchange). */
|
||||
export interface WorkosUser {
|
||||
workosOrgId: string;
|
||||
workosUserId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class SsoProviderError extends Error {
|
||||
override readonly cause: unknown | undefined;
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message);
|
||||
this.name = "SsoProviderError";
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
/** Dev-mode mock user deterministically derived from the code value. */
|
||||
function mockUserFromCode(code: string): WorkosUser {
|
||||
// Codes look like "mock-code-<seed>"; we derive a stable email/name/org.
|
||||
// Falls back to a fixed seed if the code shape is unexpected.
|
||||
const seed = code.startsWith("mock-code-") ? code.slice("mock-code-".length) : "alpha";
|
||||
return {
|
||||
workosOrgId: `org_${seed}`,
|
||||
workosUserId: `user_${seed}`,
|
||||
email: `${seed}@example.com`,
|
||||
name: `Mock ${seed}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the WorkOS hosted SSO authorization URL. */
|
||||
export function getAuthorizationUrl(config: WorkosConfig): string {
|
||||
if (config.apiKey && config.clientId) {
|
||||
// Real WorkOS hosted SSO authorization endpoint.
|
||||
// WorkOS dashboard also accepts a `connection` or `organization` param; for
|
||||
// M1 we use the generic hosted SSO entrypoint with the client id.
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUrl,
|
||||
response_type: "code",
|
||||
});
|
||||
return `https://api.workos.com/sso/authorize?${params.toString()}`;
|
||||
}
|
||||
// Dev/mock mode: a synthetic login URL the control plane renders as a button
|
||||
// that immediately bounces back to the callback with a mock code. Keeps the
|
||||
// full SSO round-trip testable end-to-end without WorkOS.
|
||||
const params = new URLSearchParams({
|
||||
redirect_uri: config.redirectUrl,
|
||||
response_type: "code",
|
||||
});
|
||||
return `${config.appBaseUrl}/api/auth/mock-login?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the authorization code for a WorkOS user. In dev/test (no apiKey),
|
||||
* returns a deterministic mock user derived from the code. In prod, dynamically
|
||||
* imports `@workos-inc/node` and calls authenticateWithCode.
|
||||
*
|
||||
* Throws SsoProviderError on any failure (Edge 10 → caller surfaces retry).
|
||||
*/
|
||||
export async function exchangeCodeForSession(
|
||||
config: WorkosConfig,
|
||||
code: string,
|
||||
): Promise<WorkosUser> {
|
||||
if (!config.apiKey || !config.clientId) {
|
||||
// Dev/mock mode.
|
||||
return mockUserFromCode(code);
|
||||
}
|
||||
|
||||
// Prod mode: real WorkOS SDK. Dynamic import so the auth package has no hard
|
||||
// dependency on @workos-inc/node (installable without it for dev/test).
|
||||
let WorkOSFactory: typeof import("@workos-inc/node").WorkOS;
|
||||
try {
|
||||
const mod = (await import("@workos-inc/node")) as typeof import("@workos-inc/node");
|
||||
WorkOSFactory = mod.WorkOS;
|
||||
} catch (err) {
|
||||
throw new SsoProviderError(
|
||||
"WorkOS SDK (@workos-inc/node) not installed but apiKey is configured",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const workos = WorkOSFactory(config.apiKey);
|
||||
const result = await workos.userManagement.authenticateWithCode({
|
||||
code,
|
||||
clientId: config.clientId,
|
||||
redirectUri: config.redirectUrl,
|
||||
});
|
||||
const u = result.user;
|
||||
return {
|
||||
workosOrgId: result.organizationId ?? "",
|
||||
workosUserId: u.id,
|
||||
email: u.email,
|
||||
name: [u.firstName, u.lastName].filter(Boolean).join(" ") || u.email,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new SsoProviderError(
|
||||
`WorkOS code exchange failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Shared test harness: boot PGlite, run migrations 0001 + 0002, return a db.
|
||||
*
|
||||
* M1 dev/test runs against PGlite (real Postgres in WASM) so the auth tests
|
||||
* exercise the same SQL the control plane runs in prod. Migrations live in
|
||||
* packages/db/migrations (sibling package); we read them at runtime so we
|
||||
* always test against the current schema.
|
||||
*/
|
||||
|
||||
import { createDb, setDbClient, type DbClient } from "@coreci/db";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface TestDb {
|
||||
db: DbClient;
|
||||
teardown: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function bootTestDb(): Promise<TestDb> {
|
||||
const db = await createDb({ mode: "pglite" });
|
||||
setDbClient(db);
|
||||
|
||||
const migrationsDir = join(import.meta.dirname, "..", "..", "db", "migrations");
|
||||
const files = (await readdir(migrationsDir)).filter((f) => f.endsWith(".sql")).sort();
|
||||
for (const file of files) {
|
||||
const sql = await readFile(join(migrationsDir, file), "utf8");
|
||||
await db.exec(sql);
|
||||
}
|
||||
|
||||
return {
|
||||
db,
|
||||
teardown: async () => {
|
||||
// PGlite holds no external resources; nothing to close in M1 harness.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Insert a tenant + user + admin membership directly (bypasses RLS for setup). */
|
||||
export async function seedTenantAndUser(
|
||||
db: DbClient,
|
||||
workosOrgId: string,
|
||||
email: string,
|
||||
): Promise<{ tenantId: string; userId: string }> {
|
||||
const tenantRes = await db.query<{ id: string }>(
|
||||
`INSERT INTO tenants (name, workos_org_id) VALUES ($1, $2) RETURNING id`,
|
||||
["Seed Tenant", workosOrgId],
|
||||
);
|
||||
const userRes = await db.query<{ id: string }>(
|
||||
`INSERT INTO users (email) VALUES ($1) RETURNING id`,
|
||||
[email],
|
||||
);
|
||||
const tenantId = tenantRes.rows[0]!.id;
|
||||
const userId = userRes.rows[0]!.id;
|
||||
|
||||
await db.query("ALTER TABLE tenant_memberships DISABLE ROW LEVEL SECURITY");
|
||||
await db.query(
|
||||
`INSERT INTO tenant_memberships (tenant_id, user_id, role) VALUES ($1, $2, 'admin')`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
await db.query("ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY");
|
||||
|
||||
return { tenantId, userId };
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Invitation tests — REQ-003.
|
||||
*
|
||||
* - createInvitation inserts a pending row + returns a single-use acceptance
|
||||
* link and writes an auth audit event.
|
||||
* - acceptInvitation marks accepted, creates user + membership for a new email.
|
||||
* - acceptInvitation is idempotent for an existing user (creates membership).
|
||||
* - accepting twice (or accepting a non-pending invite) throws InvitationError.
|
||||
* - markInvitationBounced (Edge 15) sets status=bounced and blocks acceptance.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import type { DbClient } from "@coreci/db";
|
||||
import { bootTestDb, seedTenantAndUser, type TestDb } from "./helpers.js";
|
||||
import {
|
||||
createInvitation,
|
||||
acceptInvitation,
|
||||
markInvitationBounced,
|
||||
InvitationError,
|
||||
} from "../src/invitations.js";
|
||||
|
||||
const BASE_URL = "https://app.example.com";
|
||||
|
||||
describe("invitations (REQ-003)", () => {
|
||||
let testDb: TestDb;
|
||||
let db: DbClient;
|
||||
let tenantId: string;
|
||||
let adminUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testDb = await bootTestDb();
|
||||
db = testDb.db;
|
||||
const seeded = await seedTenantAndUser(db, "org_inv", "admin@example.com");
|
||||
tenantId = seeded.tenantId;
|
||||
adminUserId = seeded.userId;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await testDb.teardown();
|
||||
});
|
||||
|
||||
it("createInvitation inserts a pending row + returns an acceptance link", async () => {
|
||||
const inv = await createInvitation(db, tenantId, adminUserId, "ops@example.com", "operator", {
|
||||
baseUrl: BASE_URL,
|
||||
});
|
||||
expect(inv.status).toBe("pending");
|
||||
expect(inv.role).toBe("operator");
|
||||
expect(inv.email).toBe("ops@example.com");
|
||||
expect(inv.acceptanceLink).toContain(`${BASE_URL}/api/invitations/accept?token=`);
|
||||
expect(inv.acceptanceLink).toContain(encodeURIComponent(inv.id));
|
||||
});
|
||||
|
||||
it("writes an auth audit event for invite creation", async () => {
|
||||
await db.query("ALTER TABLE audit_log DISABLE ROW LEVEL SECURITY");
|
||||
const audits = await db.query<{ event_type: string; payload: Record<string, unknown> }>(
|
||||
"SELECT event_type, payload FROM audit_log WHERE tenant_id = $1 ORDER BY id DESC LIMIT 1",
|
||||
[tenantId],
|
||||
);
|
||||
await db.query("ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY");
|
||||
expect(audits.rows[0]?.event_type).toBe("auth");
|
||||
expect(audits.rows[0]?.payload).toMatchObject({ action: "invite_created", role: "operator" });
|
||||
});
|
||||
|
||||
it("acceptInvitation creates a user + membership for a new email", async () => {
|
||||
const inv = await createInvitation(db, tenantId, adminUserId, "new@example.com", "viewer", {
|
||||
baseUrl: BASE_URL,
|
||||
});
|
||||
const token = inv.id;
|
||||
const result = await acceptInvitation(db, token);
|
||||
expect(result.createdUser).toBe(true);
|
||||
expect(result.createdMembership).toBe(true);
|
||||
expect(result.role).toBe("viewer");
|
||||
expect(result.tenantId).toBe(tenantId);
|
||||
expect(result.userId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
|
||||
// The membership row exists with the invited role.
|
||||
await db.query("ALTER TABLE tenant_memberships DISABLE ROW LEVEL SECURITY");
|
||||
const mem = await db.query<{ role: string }>(
|
||||
"SELECT role FROM tenant_memberships WHERE tenant_id = $1 AND user_id = $2",
|
||||
[result.tenantId, result.userId],
|
||||
);
|
||||
await db.query("ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY");
|
||||
expect(mem.rows[0]?.role).toBe("viewer");
|
||||
|
||||
// The invitation is now accepted.
|
||||
await db.query("ALTER TABLE invitations DISABLE ROW LEVEL SECURITY");
|
||||
const invRow = await db.query<{ status: string }>(
|
||||
"SELECT status FROM invitations WHERE id = $1",
|
||||
[token],
|
||||
);
|
||||
await db.query("ALTER TABLE invitations ENABLE ROW LEVEL SECURITY");
|
||||
expect(invRow.rows[0]?.status).toBe("accepted");
|
||||
});
|
||||
|
||||
it("accepting a non-pending invite throws InvitationError", async () => {
|
||||
const inv = await createInvitation(db, tenantId, adminUserId, "twice@example.com", "viewer", {
|
||||
baseUrl: BASE_URL,
|
||||
});
|
||||
await acceptInvitation(db, inv.id);
|
||||
await expect(acceptInvitation(db, inv.id)).rejects.toThrow(InvitationError);
|
||||
await expect(acceptInvitation(db, inv.id)).rejects.toThrow(/not pending/);
|
||||
});
|
||||
|
||||
it("accepting a nonexistent invite throws", async () => {
|
||||
await expect(
|
||||
acceptInvitation(db, "00000000-0000-0000-0000-0000000000ff"),
|
||||
).rejects.toThrow(InvitationError);
|
||||
});
|
||||
|
||||
it("acceptInvitation creates a membership for an existing user (no new user)", async () => {
|
||||
// The admin already exists in this tenant. Invite them by a brand-new email
|
||||
// then create the user first manually to simulate "existing user, new org".
|
||||
const inv = await createInvitation(db, tenantId, adminUserId, "existing@example.com", "operator", {
|
||||
baseUrl: BASE_URL,
|
||||
});
|
||||
// Pre-create the user.
|
||||
await db.query("INSERT INTO users (email) VALUES ($1) ON CONFLICT DO NOTHING", ["existing@example.com"]);
|
||||
const result = await acceptInvitation(db, inv.id);
|
||||
expect(result.createdUser).toBe(false);
|
||||
// The admin already has a membership in this tenant; we expect the
|
||||
// role-update path (createdMembership false) since the existing membership
|
||||
// is admin's. Verify the role matches the invite (operator).
|
||||
await db.query("ALTER TABLE tenant_memberships DISABLE ROW LEVEL SECURITY");
|
||||
const mem = await db.query<{ role: string }>(
|
||||
"SELECT role FROM tenant_memberships WHERE tenant_id = $1 AND user_id = $2",
|
||||
[result.tenantId, result.userId],
|
||||
);
|
||||
await db.query("ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY");
|
||||
expect(mem.rows[0]?.role).toBe("operator");
|
||||
});
|
||||
|
||||
it("markInvitationBounced (Edge 15) blocks acceptance", async () => {
|
||||
const inv = await createInvitation(db, tenantId, adminUserId, "bounced@example.com", "viewer", {
|
||||
baseUrl: BASE_URL,
|
||||
});
|
||||
await markInvitationBounced(db, inv.id);
|
||||
|
||||
await db.query("ALTER TABLE invitations DISABLE ROW LEVEL SECURITY");
|
||||
const row = await db.query<{ status: string }>("SELECT status FROM invitations WHERE id = $1", [inv.id]);
|
||||
await db.query("ALTER TABLE invitations ENABLE ROW LEVEL SECURITY");
|
||||
expect(row.rows[0]?.status).toBe("bounced");
|
||||
|
||||
// Acceptance of a bounced invite fails.
|
||||
await expect(acceptInvitation(db, inv.id)).rejects.toThrow(/not pending/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Auth middleware tests — REQ-005.
|
||||
*
|
||||
* - extracts the user from the `coreci_session` cookie on a valid session.
|
||||
* - returns `unauthorized` when no cookie is present.
|
||||
* - returns `unauthorized` when the cookie is invalid/tampered.
|
||||
* - returns `forbidden` when the role lacks the required permission.
|
||||
* - returns `authorized` for a viewer hitting a read route, `forbidden` for
|
||||
* a viewer hitting an admin route.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import type { DbClient } from "@coreci/db";
|
||||
import { bootTestDb, seedTenantAndUser, type TestDb } from "./helpers.js";
|
||||
import { createSession } from "../src/sessions.js";
|
||||
import { createAuthMiddleware, authenticate, SESSION_COOKIE, type AuthRequest } from "../src/middleware.js";
|
||||
|
||||
const SIGNING_KEY = "mw-test-session-signing-key";
|
||||
|
||||
function reqFor(method: string, path: string, cookie: string | undefined): AuthRequest {
|
||||
return {
|
||||
method,
|
||||
path,
|
||||
getCookie(name: string) {
|
||||
return name === SESSION_COOKIE ? cookie : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("auth middleware (REQ-005)", () => {
|
||||
let testDb: TestDb;
|
||||
let db: DbClient;
|
||||
let tenantId: string;
|
||||
let adminUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testDb = await bootTestDb();
|
||||
db = testDb.db;
|
||||
const seeded = await seedTenantAndUser(db, "org_mw", "mw-admin@example.com");
|
||||
tenantId = seeded.tenantId;
|
||||
adminUserId = seeded.userId;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await testDb.teardown();
|
||||
});
|
||||
|
||||
it("returns unauthorized when no cookie is present", async () => {
|
||||
const mw = createAuthMiddleware(db, { sessionSigningKey: SIGNING_KEY });
|
||||
const r = await mw(reqFor("GET", "/api/me", undefined));
|
||||
expect(r.status).toBe("unauthorized");
|
||||
expect(r).toMatchObject({ status: "unauthorized", reason: "no_cookie" });
|
||||
});
|
||||
|
||||
it("returns unauthorized for an invalid/tampered cookie", async () => {
|
||||
const mw = createAuthMiddleware(db, { sessionSigningKey: SIGNING_KEY });
|
||||
const r = await mw(reqFor("GET", "/api/me", "garbage.token.value"));
|
||||
expect(r.status).toBe("unauthorized");
|
||||
expect(r).toMatchObject({ status: "unauthorized", reason: "invalid_session" });
|
||||
});
|
||||
|
||||
it("extracts user + authorizes a viewer on a read route", async () => {
|
||||
// Create a viewer session.
|
||||
const { token } = await createSession(db, SIGNING_KEY, adminUserId, tenantId, "viewer", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
const mw = createAuthMiddleware(db, { sessionSigningKey: SIGNING_KEY });
|
||||
const r = await mw(reqFor("GET", "/api/me", token));
|
||||
expect(r.status).toBe("authorized");
|
||||
if (r.status === "authorized") {
|
||||
expect(r.user.userId).toBe(adminUserId);
|
||||
expect(r.user.tenantId).toBe(tenantId);
|
||||
expect(r.user.role).toBe("viewer");
|
||||
expect(r.decision.required).toBe("read");
|
||||
}
|
||||
});
|
||||
|
||||
it("forbids a viewer on an admin route (POST /api/byom)", async () => {
|
||||
const { token } = await createSession(db, SIGNING_KEY, adminUserId, tenantId, "viewer", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
const mw = createAuthMiddleware(db, { sessionSigningKey: SIGNING_KEY });
|
||||
const r = await mw(reqFor("POST", "/api/byom", token));
|
||||
expect(r.status).toBe("forbidden");
|
||||
if (r.status === "forbidden") {
|
||||
expect(r.decision.required).toBe("admin");
|
||||
expect(r.user.role).toBe("viewer");
|
||||
}
|
||||
});
|
||||
|
||||
it("authorizes an operator on a read route but forbids admin routes", async () => {
|
||||
const { token } = await createSession(db, SIGNING_KEY, adminUserId, tenantId, "operator", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
const mw = createAuthMiddleware(db, { sessionSigningKey: SIGNING_KEY });
|
||||
expect((await mw(reqFor("GET", "/api/me", token))).status).toBe("authorized");
|
||||
expect((await mw(reqFor("GET", "/api/targets", token))).status).toBe("authorized");
|
||||
expect((await mw(reqFor("POST", "/api/byom", token))).status).toBe("forbidden");
|
||||
expect((await mw(reqFor("POST", "/api/relay/issue-token", token))).status).toBe("forbidden");
|
||||
});
|
||||
|
||||
it("authorizes an admin on every listed route", async () => {
|
||||
const { token } = await createSession(db, SIGNING_KEY, adminUserId, tenantId, "admin", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
const mw = createAuthMiddleware(db, { sessionSigningKey: SIGNING_KEY });
|
||||
for (const [method, path] of [
|
||||
["GET", "/api/me"],
|
||||
["POST", "/api/byom"],
|
||||
["GET", "/api/team"],
|
||||
["POST", "/api/team"],
|
||||
["PATCH", "/api/team/00000000-0000-0000-0000-0000000000aa"],
|
||||
["POST", "/api/invitations"],
|
||||
["GET", "/api/invitations"],
|
||||
["POST", "/api/relay/issue-token"],
|
||||
["GET", "/api/targets"],
|
||||
] as const) {
|
||||
const r = await mw(reqFor(method, path, token));
|
||||
expect(r.status, `${method} ${path}`).toBe("authorized");
|
||||
}
|
||||
});
|
||||
|
||||
it("role change is enforced on the next API call (REQ-004)", async () => {
|
||||
// Start as admin, hit an admin route (allowed).
|
||||
const { token, sessionId } = await createSession(db, SIGNING_KEY, adminUserId, tenantId, "admin", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
const mw = createAuthMiddleware(db, { sessionSigningKey: SIGNING_KEY });
|
||||
expect((await mw(reqFor("POST", "/api/byom", token))).status).toBe("authorized");
|
||||
|
||||
// Simulate a role change: update the sessions row role to viewer.
|
||||
await db.query("UPDATE sessions SET role = 'viewer' WHERE id = $1", [sessionId]);
|
||||
|
||||
// The very next call is now forbidden — RBAC enforced from the gateway.
|
||||
const r = await mw(reqFor("POST", "/api/byom", token));
|
||||
expect(r.status).toBe("forbidden");
|
||||
});
|
||||
|
||||
it("authenticate() standalone helper matches createAuthMiddleware", async () => {
|
||||
const { token } = await createSession(db, SIGNING_KEY, adminUserId, tenantId, "admin", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
const r = await authenticate(db, SIGNING_KEY, reqFor("GET", "/api/me", token));
|
||||
expect(r.status).toBe("authorized");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Provisioning tests — REQ-002.
|
||||
*
|
||||
* - First signup: no tenant for the WorkOS org → INSERT tenant + user + admin
|
||||
* membership. Audit provision event written. createdTenant + createdUser.
|
||||
* - Existing tenant + new user: tenant exists, user is new → INSERT user +
|
||||
* viewer membership. createdUser true, createdTenant false.
|
||||
* - Idempotent: same (orgId, workosUserId, email) → returns existing ids,
|
||||
* createdTenant false, createdUser false, no extra rows.
|
||||
* - Audit chain: provisioning appends a provision audit row under the tenant.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import type { DbClient } from "@coreci/db";
|
||||
import { bootTestDb, type TestDb } from "./helpers.js";
|
||||
import { provisionTenant } from "../src/provisioning.js";
|
||||
|
||||
describe("provisioning (REQ-002)", () => {
|
||||
let testDb: TestDb;
|
||||
let db: DbClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
testDb = await bootTestDb();
|
||||
db = testDb.db;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await testDb.teardown();
|
||||
});
|
||||
|
||||
it("first signup provisions a tenant + admin user", async () => {
|
||||
const r = await provisionTenant(db, "org_alpha", "user_alpha", "alpha@example.com", "Alpha Org");
|
||||
expect(r.createdTenant).toBe(true);
|
||||
expect(r.createdUser).toBe(true);
|
||||
expect(r.role).toBe("admin");
|
||||
expect(r.tenantId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(r.userId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
|
||||
// Verify the membership row.
|
||||
await db.query("ALTER TABLE tenant_memberships DISABLE ROW LEVEL SECURITY");
|
||||
const mem = await db.query<{ role: string }>(
|
||||
"SELECT role FROM tenant_memberships WHERE tenant_id = $1 AND user_id = $2",
|
||||
[r.tenantId, r.userId],
|
||||
);
|
||||
await db.query("ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY");
|
||||
expect(mem.rows[0]?.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("writes a provision audit event for the first signup", async () => {
|
||||
// The first test created tenant for org_alpha. Audit row should exist.
|
||||
await db.query("ALTER TABLE audit_log DISABLE ROW LEVEL SECURITY");
|
||||
const audits = await db.query<{ event_type: string; payload: Record<string, unknown> }>(
|
||||
"SELECT event_type, payload FROM audit_log WHERE tenant_id = (SELECT id FROM tenants WHERE workos_org_id = $1)",
|
||||
["org_alpha"],
|
||||
);
|
||||
await db.query("ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY");
|
||||
expect(audits.rows.length).toBeGreaterThanOrEqual(1);
|
||||
expect(audits.rows[0]?.event_type).toBe("provision");
|
||||
});
|
||||
|
||||
it("existing tenant + new user → viewer membership", async () => {
|
||||
// org_alpha tenant already exists from the first test. A second user SSOs
|
||||
// into the same org.
|
||||
const r = await provisionTenant(db, "org_alpha", "user_beta", "beta@example.com", "Beta User");
|
||||
expect(r.createdTenant).toBe(false);
|
||||
expect(r.createdUser).toBe(true);
|
||||
expect(r.role).toBe("viewer");
|
||||
|
||||
await db.query("ALTER TABLE tenant_memberships DISABLE ROW LEVEL SECURITY");
|
||||
const mem = await db.query<{ role: string }>(
|
||||
"SELECT role FROM tenant_memberships WHERE tenant_id = $1 AND user_id = $2",
|
||||
[r.tenantId, r.userId],
|
||||
);
|
||||
await db.query("ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY");
|
||||
expect(mem.rows[0]?.role).toBe("viewer");
|
||||
});
|
||||
|
||||
it("is idempotent for the same (orgId, workosUserId, email)", async () => {
|
||||
const first = await provisionTenant(db, "org_gamma", "user_gamma", "gamma@example.com", "Gamma Org");
|
||||
const second = await provisionTenant(db, "org_gamma", "user_gamma", "gamma@example.com", "Gamma Org");
|
||||
expect(second.tenantId).toBe(first.tenantId);
|
||||
expect(second.userId).toBe(first.userId);
|
||||
expect(second.createdTenant).toBe(false);
|
||||
expect(second.createdUser).toBe(false);
|
||||
expect(second.role).toBe("admin");
|
||||
|
||||
// Exactly one tenant + one user + one membership for this org.
|
||||
await db.query("ALTER TABLE tenant_memberships DISABLE ROW LEVEL SECURITY");
|
||||
const memCount = await db.query<{ count: string }>(
|
||||
"SELECT count(*) AS count FROM tenant_memberships WHERE tenant_id = $1 AND user_id = $2",
|
||||
[first.tenantId, first.userId],
|
||||
);
|
||||
await db.query("ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY");
|
||||
expect(Number(memCount.rows[0]?.count ?? 0)).toBe(1);
|
||||
});
|
||||
|
||||
it("creates distinct tenants for distinct WorkOS orgs", async () => {
|
||||
const a = await provisionTenant(db, "org_delta", "user_delta", "delta@example.com", "Delta");
|
||||
const b = await provisionTenant(db, "org_epsilon", "user_epsilon", "epsilon@example.com", "Epsilon");
|
||||
expect(a.tenantId).not.toBe(b.tenantId);
|
||||
expect(a.role).toBe("admin");
|
||||
expect(b.role).toBe("admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* RBAC enforcement tests — REQ-004, REQ-005.
|
||||
*
|
||||
* Verifies the role → permission map + the route permission table end-to-end:
|
||||
* - viewer → 403 on POST /api/byom
|
||||
* - operator → 200 on GET /api/me, 403 on admin routes
|
||||
* - admin → 200 on all listed routes
|
||||
* - unknown role → denied
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
checkPermission,
|
||||
enforceRbac,
|
||||
isWriteMethod,
|
||||
ROLE_PERMISSIONS,
|
||||
type Role,
|
||||
} from "../src/rbac.js";
|
||||
|
||||
describe("rbac — checkPermission (REQ-004)", () => {
|
||||
it("admin grants read+write+admin", () => {
|
||||
expect(checkPermission("admin", "read")).toBe(true);
|
||||
expect(checkPermission("admin", "write")).toBe(true);
|
||||
expect(checkPermission("admin", "admin")).toBe(true);
|
||||
});
|
||||
|
||||
it("operator grants read+write, not admin", () => {
|
||||
expect(checkPermission("operator", "read")).toBe(true);
|
||||
expect(checkPermission("operator", "write")).toBe(true);
|
||||
expect(checkPermission("operator", "admin")).toBe(false);
|
||||
});
|
||||
|
||||
it("viewer grants read only", () => {
|
||||
expect(checkPermission("viewer", "read")).toBe(true);
|
||||
expect(checkPermission("viewer", "write")).toBe(false);
|
||||
expect(checkPermission("viewer", "admin")).toBe(false);
|
||||
});
|
||||
|
||||
it("ROLE_PERMISSIONS exposes the full matrix", () => {
|
||||
expect(ROLE_PERMISSIONS.admin.has("admin")).toBe(true);
|
||||
expect(ROLE_PERMISSIONS.operator.has("admin")).toBe(false);
|
||||
expect(ROLE_PERMISSIONS.viewer.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rbac — enforceRbac route table (REQ-005)", () => {
|
||||
const roleMatrix: Role[] = ["admin", "operator", "viewer"];
|
||||
|
||||
it("GET /api/me → read (any authenticated role)", () => {
|
||||
for (const role of roleMatrix) {
|
||||
const d = enforceRbac(role, "GET", "/api/me");
|
||||
expect(d.allowed, `${role} GET /api/me`).toBe(true);
|
||||
expect(d.required).toBe("read");
|
||||
expect(d.unknownRole).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/byom → admin (viewer 403, operator 403, admin 200)", () => {
|
||||
const viewer = enforceRbac("viewer", "POST", "/api/byom");
|
||||
expect(viewer.allowed).toBe(false);
|
||||
expect(viewer.required).toBe("admin");
|
||||
|
||||
const operator = enforceRbac("operator", "POST", "/api/byom");
|
||||
expect(operator.allowed).toBe(false);
|
||||
expect(operator.required).toBe("admin");
|
||||
|
||||
const admin = enforceRbac("admin", "POST", "/api/byom");
|
||||
expect(admin.allowed).toBe(true);
|
||||
expect(admin.required).toBe("admin");
|
||||
});
|
||||
|
||||
it("GET /api/byom → read (any authenticated role)", () => {
|
||||
for (const role of roleMatrix) {
|
||||
const d = enforceRbac(role, "GET", "/api/byom");
|
||||
expect(d.allowed, `${role} GET /api/byom`).toBe(true);
|
||||
expect(d.required).toBe("read");
|
||||
}
|
||||
});
|
||||
|
||||
it("PATCH /api/byom/<id> → admin", () => {
|
||||
expect(enforceRbac("admin", "PATCH", "/api/byom/abc").allowed).toBe(true);
|
||||
expect(enforceRbac("operator", "PATCH", "/api/byom/abc").allowed).toBe(false);
|
||||
expect(enforceRbac("viewer", "PATCH", "/api/byom/abc").allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("GET /api/team → read", () => {
|
||||
for (const role of roleMatrix) {
|
||||
expect(enforceRbac(role, "GET", "/api/team").allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/team (invite) → admin", () => {
|
||||
expect(enforceRbac("admin", "POST", "/api/team").allowed).toBe(true);
|
||||
expect(enforceRbac("operator", "POST", "/api/team").allowed).toBe(false);
|
||||
expect(enforceRbac("viewer", "POST", "/api/team").allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("PATCH /api/team/<userId> (role change) → admin", () => {
|
||||
const userId = "00000000-0000-0000-0000-0000000000aa";
|
||||
expect(enforceRbac("admin", "PATCH", `/api/team/${userId}`).allowed).toBe(true);
|
||||
expect(enforceRbac("operator", "PATCH", `/api/team/${userId}`).allowed).toBe(false);
|
||||
expect(enforceRbac("viewer", "PATCH", `/api/team/${userId}`).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("GET /api/invitations → read", () => {
|
||||
for (const role of roleMatrix) {
|
||||
expect(enforceRbac(role, "GET", "/api/invitations").allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/invitations → admin", () => {
|
||||
expect(enforceRbac("admin", "POST", "/api/invitations").allowed).toBe(true);
|
||||
expect(enforceRbac("operator", "POST", "/api/invitations").allowed).toBe(false);
|
||||
expect(enforceRbac("viewer", "POST", "/api/invitations").allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("POST /api/invitations/accept → read (any authenticated)", () => {
|
||||
for (const role of roleMatrix) {
|
||||
const d = enforceRbac(role, "POST", "/api/invitations/accept");
|
||||
expect(d.allowed, `${role} POST /api/invitations/accept`).toBe(true);
|
||||
expect(d.required).toBe("read");
|
||||
}
|
||||
});
|
||||
|
||||
it("/api/relay/* → admin for every method", () => {
|
||||
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"] as const) {
|
||||
expect(enforceRbac("admin", method, "/api/relay/issue-token").allowed).toBe(true);
|
||||
expect(enforceRbac("admin", method, "/api/relay/ws").allowed).toBe(true);
|
||||
expect(enforceRbac("operator", method, "/api/relay/issue-token").allowed).toBe(false);
|
||||
expect(enforceRbac("viewer", method, "/api/relay/issue-token").allowed).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /api/targets → read", () => {
|
||||
for (const role of roleMatrix) {
|
||||
expect(enforceRbac(role, "GET", "/api/targets").allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/targets → admin (future-proof)", () => {
|
||||
expect(enforceRbac("admin", "POST", "/api/targets").allowed).toBe(true);
|
||||
expect(enforceRbac("operator", "POST", "/api/targets").allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("default → read (any authenticated)", () => {
|
||||
for (const role of roleMatrix) {
|
||||
const d = enforceRbac(role, "GET", "/api/something-else");
|
||||
expect(d.allowed).toBe(true);
|
||||
expect(d.required).toBe("read");
|
||||
}
|
||||
});
|
||||
|
||||
it("operator → 200 on GET but 403 on admin routes (the headline case)", () => {
|
||||
expect(enforceRbac("operator", "GET", "/api/me").allowed).toBe(true);
|
||||
expect(enforceRbac("operator", "POST", "/api/byom").allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("trailing slash normalizes", () => {
|
||||
expect(enforceRbac("admin", "POST", "/api/team/").allowed).toBe(true);
|
||||
expect(enforceRbac("viewer", "GET", "/api/me/").allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("denies an unknown role", () => {
|
||||
const d = enforceRbac("superuser" as Role, "GET", "/api/me");
|
||||
expect(d.allowed).toBe(false);
|
||||
expect(d.unknownRole).toBe(true);
|
||||
});
|
||||
|
||||
it("isWriteMethod classifies write verbs", () => {
|
||||
expect(isWriteMethod("POST")).toBe(true);
|
||||
expect(isWriteMethod("put")).toBe(true);
|
||||
expect(isWriteMethod("PATCH")).toBe(true);
|
||||
expect(isWriteMethod("DELETE")).toBe(true);
|
||||
expect(isWriteMethod("GET")).toBe(false);
|
||||
expect(isWriteMethod("head")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Session round-trip tests — REQ-001.
|
||||
*
|
||||
* createSession → verifySession → destroySession, plus expiry + tamper
|
||||
* rejection. Uses PGlite + migrations 0001 (tenants/users) + 0002 (sessions).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import type { DbClient } from "@coreci/db";
|
||||
import { bootTestDb, seedTenantAndUser, type TestDb } from "./helpers.js";
|
||||
import {
|
||||
createSession,
|
||||
verifySession,
|
||||
destroySession,
|
||||
destroySessionByToken,
|
||||
SessionError,
|
||||
} from "../src/sessions.js";
|
||||
|
||||
const SIGNING_KEY = "test-session-signing-key-not-a-tenant-secret";
|
||||
|
||||
describe("sessions (REQ-001)", () => {
|
||||
let testDb: TestDb;
|
||||
let db: DbClient;
|
||||
let tenantId: string;
|
||||
let userId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testDb = await bootTestDb();
|
||||
db = testDb.db;
|
||||
const seeded = await seedTenantAndUser(db, "org_test", "user@example.com");
|
||||
tenantId = seeded.tenantId;
|
||||
userId = seeded.userId;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.teardown();
|
||||
});
|
||||
|
||||
it("createSession returns a 3-segment JWT + a session id", async () => {
|
||||
const token = await createSession(db, SIGNING_KEY, userId, tenantId, "admin", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
expect(token.token.split(".")).toHaveLength(3);
|
||||
expect(token.sessionId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(token.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000));
|
||||
});
|
||||
|
||||
it("verifySession round-trips {userId, tenantId, role}", async () => {
|
||||
const { token } = await createSession(db, SIGNING_KEY, userId, tenantId, "operator", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
const session = await verifySession(db, SIGNING_KEY, token);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.userId).toBe(userId);
|
||||
expect(session!.tenantId).toBe(tenantId);
|
||||
expect(session!.role).toBe("operator");
|
||||
expect(session!.sessionId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
it("destroySession removes the row → verifySession returns null", async () => {
|
||||
const { token, sessionId } = await createSession(db, SIGNING_KEY, userId, tenantId, "viewer", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
await destroySession(db, sessionId);
|
||||
const session = await verifySession(db, SIGNING_KEY, token);
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
it("destroySessionByToken returns true once, false after", async () => {
|
||||
const { token } = await createSession(db, SIGNING_KEY, userId, tenantId, "admin", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
expect(await destroySessionByToken(db, SIGNING_KEY, token)).toBe(true);
|
||||
expect(await destroySessionByToken(db, SIGNING_KEY, token)).toBe(false);
|
||||
});
|
||||
|
||||
it("verifySession returns null for an expired session", async () => {
|
||||
// Create a session with lifetime 0 → already expired at the DB level.
|
||||
// We bypass createSession to set a past expires_at directly.
|
||||
const { sessionId } = await createSession(db, SIGNING_KEY, userId, tenantId, "admin", {
|
||||
lifetimeSeconds: 1,
|
||||
});
|
||||
// Force expires_at into the past.
|
||||
await db.query("UPDATE sessions SET expires_at = now() - interval '1 second' WHERE id = $1", [sessionId]);
|
||||
// Re-sign a JWT that points at the same session id but with a future exp
|
||||
// (so the JWT check passes and we exercise the DB expires_at check).
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const headerB64 = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }), "utf8").toString("base64url");
|
||||
const payloadB64 = Buffer.from(
|
||||
JSON.stringify({ sid: sessionId, iat: now, exp: now + 60 }),
|
||||
"utf8",
|
||||
).toString("base64url");
|
||||
const crypto = await import("node:crypto");
|
||||
const sig = crypto.createHmac("sha256", SIGNING_KEY).update(`${headerB64}.${payloadB64}`, "utf8").digest();
|
||||
const token = `${headerB64}.${payloadB64}.${sig.toString("base64url")}`;
|
||||
|
||||
const session = await verifySession(db, SIGNING_KEY, token);
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
it("verifySession returns null for a tampered signature", async () => {
|
||||
const { token } = await createSession(db, SIGNING_KEY, userId, tenantId, "admin", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
const parts = token.split(".");
|
||||
const tamperedSig = Buffer.from(parts[2]!, "base64url");
|
||||
tamperedSig[0] = tamperedSig[0]! ^ 0xff;
|
||||
const tampered = `${parts[0]}.${parts[1]}.${tamperedSig.toString("base64url")}`;
|
||||
expect(await verifySession(db, SIGNING_KEY, tampered)).toBeNull();
|
||||
});
|
||||
|
||||
it("verifySession returns null for a session row that no longer exists", async () => {
|
||||
const { token } = await createSession(db, SIGNING_KEY, userId, tenantId, "admin", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
await destroySessionByToken(db, SIGNING_KEY, token);
|
||||
expect(await verifySession(db, SIGNING_KEY, token)).toBeNull();
|
||||
});
|
||||
|
||||
it("verifySession returns null for a malformed token", async () => {
|
||||
expect(await verifySession(db, SIGNING_KEY, "not.a.jwt")).toBeNull();
|
||||
expect(await verifySession(db, SIGNING_KEY, "")).toBeNull();
|
||||
expect(await verifySession(db, SIGNING_KEY, "onlyone")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a token signed with a different key", async () => {
|
||||
const { token } = await createSession(db, SIGNING_KEY, userId, tenantId, "admin", {
|
||||
lifetimeSeconds: 60,
|
||||
});
|
||||
expect(await verifySession(db, "wrong-key", token)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions — SessionError shape", () => {
|
||||
it("SessionError carries a cause", () => {
|
||||
const err = new SessionError("boom", new Error("inner"));
|
||||
expect(err.message).toBe("boom");
|
||||
expect(err.name).toBe("SessionError");
|
||||
expect(err.cause).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"],
|
||||
"exclude": ["dist", "tests", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 30000,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/index.ts", "tests/**"],
|
||||
reporter: ["text", "json"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -32,6 +32,12 @@ export interface AppConfig {
|
||||
saasUrl: string;
|
||||
tokenSigningKey: string;
|
||||
};
|
||||
session: {
|
||||
/** HS256 signing key for session JWTs (httpOnly cookie). Infra/bootstrap, not a tenant secret. */
|
||||
signingKey: string;
|
||||
/** Session lifetime in seconds (default 7 days). */
|
||||
lifetimeSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
function required(name: string): string {
|
||||
@@ -69,6 +75,12 @@ export function loadConfig(env = process.env.NODE_ENV ?? "development"): AppConf
|
||||
saasUrl: process.env.CORECI_SAAS_URL ?? "http://localhost:3000",
|
||||
tokenSigningKey: required("RELAY_TOKEN_SIGNING_KEY"),
|
||||
},
|
||||
session: {
|
||||
// SESSION_SIGNING_KEY is a platform bootstrap signing key (G-010 tier a).
|
||||
// It is NOT a tenant secret. Dev/test sets it via env; prod uses KMS-derived.
|
||||
signingKey: required("SESSION_SIGNING_KEY"),
|
||||
lifetimeSeconds: Number(process.env.SESSION_LIFETIME_SECONDS ?? 7 * 24 * 60 * 60),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ describe("@coreci/config", () => {
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.SECRET_MASTER_KEY_DEV;
|
||||
delete process.env.TRIGGER_API_KEY;
|
||||
delete process.env.SESSION_LIFETIME_SECONDS;
|
||||
process.env.RELAY_TOKEN_SIGNING_KEY = "test-signing-key";
|
||||
process.env.SESSION_SIGNING_KEY = "test-session-signing-key";
|
||||
});
|
||||
|
||||
it("loads dev defaults (pglite, local-encrypted)", () => {
|
||||
@@ -28,8 +30,25 @@ describe("@coreci/config", () => {
|
||||
expect(cfg.secrets.provider).toBe("aws-sm");
|
||||
});
|
||||
|
||||
it("exposes the session config (signing key + default lifetime)", () => {
|
||||
const cfg = loadConfig("development");
|
||||
expect(cfg.session.signingKey).toBe("test-session-signing-key");
|
||||
expect(cfg.session.lifetimeSeconds).toBe(7 * 24 * 60 * 60);
|
||||
});
|
||||
|
||||
it("respects SESSION_LIFETIME_SECONDS override", () => {
|
||||
process.env.SESSION_LIFETIME_SECONDS = "3600";
|
||||
const cfg = loadConfig("development");
|
||||
expect(cfg.session.lifetimeSeconds).toBe(3600);
|
||||
});
|
||||
|
||||
it("throws if RELAY_TOKEN_SIGNING_KEY is missing", () => {
|
||||
delete process.env.RELAY_TOKEN_SIGNING_KEY;
|
||||
expect(() => loadConfig("development")).toThrow("RELAY_TOKEN_SIGNING_KEY");
|
||||
});
|
||||
|
||||
it("throws if SESSION_SIGNING_KEY is missing", () => {
|
||||
delete process.env.SESSION_SIGNING_KEY;
|
||||
expect(() => loadConfig("development")).toThrow("SESSION_SIGNING_KEY");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
-- CoreCI Chat v0.1 — sessions table (Wave B, REQ-001)
|
||||
--
|
||||
-- Server-side session store. NOT tenant-scoped: a session lookup is by session
|
||||
-- id (the signed JWT), not by a tenant-scoped query, so RLS would only get in
|
||||
-- the way. The session row carries the resolved {user_id, tenant_id, role} so
|
||||
-- the API gateway can resolve the tenant + enforce RBAC on every request
|
||||
-- without re-hitting WorkOS.
|
||||
--
|
||||
-- The session id (primary key) is a UUID generated server-side; the JWT the
|
||||
-- client carries in the httpOnly cookie encodes only the session id (+ exp for
|
||||
-- cheap pre-DB rejection). Verification = decode JWT → lookup session row by
|
||||
-- id → check expires_at. Destroy = DELETE the row.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin','operator','viewer')),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Lookup path: verifySession(token) → SELECT ... WHERE id = $1 AND expires_at > now().
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at
|
||||
ON sessions (expires_at);
|
||||
|
||||
-- sessions is intentionally NOT tenant-scoped and has NO RLS policy.
|
||||
-- The session id is an unguessable UUID issued by the server; the only read
|
||||
-- path is by-id lookup after JWT verification. Tenant scoping is applied
|
||||
-- downstream (withTenant) once the session resolves the tenant_id.
|
||||
@@ -12,7 +12,7 @@
|
||||
* The `DbClient` interface abstracts both — both speak the `pg`-compatible query API.
|
||||
*/
|
||||
|
||||
export type { DbClient } from "./db-client.js";
|
||||
export { withTenant, getTenantContext, TenantContextError } from "./withTenant.js";
|
||||
export type { DbClient, QueryResult } from "./db-client.js";
|
||||
export { withTenant, getTenantContext, TenantContextError, setDbClient, type ScopedClient } from "./withTenant.js";
|
||||
export { appendAudit, AuditWriteHaltError, type AuditEvent, type AuditPayload } from "./audit.js";
|
||||
export { createDb, type CreateDbOptions } from "./create-db.js";
|
||||
Reference in New Issue
Block a user