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:
CIAgent
2026-08-25 01:47:56 +00:00
parent 3246442906
commit bcca7686ee
43 changed files with 3097 additions and 5 deletions
@@ -0,0 +1,69 @@
/**
* GET /api/auth/callback — WorkOS code exchange → provision → session (REQ-001, REQ-002).
*
* Flow:
* 1. exchangeCodeForSession(code) → WorkosUser { workosOrgId, workosUserId, email, name }
* 2. provisionTenant(...) → { tenantId, userId, role }
* 3. createSession(db, signingKey, userId, tenantId, role)
* 4. Set httpOnly `coreci_session` cookie + redirect to /dashboard
*
* Edge 10 (SSO provider down): exchangeCodeForSession throws SsoProviderError;
* we surface a retry screen (here: redirect to /login?error=sso_down).
*/
import { NextRequest, NextResponse } from "next/server";
import { exchangeCodeForSession, provisionTenant, createSession, SsoProviderError, type WorkosConfig } from "@coreci/auth";
import { getDb } from "../../../../lib/db.js";
function signingKey(): string {
const k = process.env.SESSION_SIGNING_KEY;
if (!k) throw new Error("SESSION_SIGNING_KEY not configured");
return k;
}
export async function GET(req: NextRequest): Promise<NextResponse> {
const code = req.nextUrl.searchParams.get("code");
if (!code) {
return NextResponse.redirect(new URL("/login?error=missing_code", req.nextUrl.origin));
}
const config: WorkosConfig = {
apiKey: process.env.WORKOS_API_KEY,
clientId: process.env.WORKOS_CLIENT_ID,
redirectUrl: process.env.WORKOS_REDIRECT_URL ?? "http://localhost:3000/api/auth/callback",
appBaseUrl: process.env.CORECI_SAAS_URL ?? "http://localhost:3000",
};
let workosUser;
try {
workosUser = await exchangeCodeForSession(config, code);
} catch (err) {
if (err instanceof SsoProviderError) {
return NextResponse.redirect(new URL("/login?error=sso_down", req.nextUrl.origin));
}
throw err;
}
const db = await getDb();
const provisioned = await provisionTenant(
db,
workosUser.workosOrgId || `dev-org-${workosUser.workosUserId}`,
workosUser.workosUserId,
workosUser.email,
workosUser.name,
);
const { token } = await createSession(db, signingKey(), provisioned.userId, provisioned.tenantId, provisioned.role, {
lifetimeSeconds: 7 * 24 * 60 * 60,
});
const res = NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
res.cookies.set("coreci_session", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60,
});
return res;
}
@@ -0,0 +1,21 @@
/**
* GET /api/auth/login — redirect to the WorkOS SSO authorization URL (REQ-001).
*
* In dev/test (no WORKOS_API_KEY), redirects to the synthetic mock-login URL
* which immediately bounces back to /api/auth/callback with a mock code so the
* full SSO round-trip is exercisable without WorkOS.
*/
import { NextResponse } from "next/server";
import { getAuthorizationUrl, type WorkosConfig } from "@coreci/auth";
export async function GET(): Promise<NextResponse> {
const config: WorkosConfig = {
apiKey: process.env.WORKOS_API_KEY,
clientId: process.env.WORKOS_CLIENT_ID,
redirectUrl: process.env.WORKOS_REDIRECT_URL ?? "http://localhost:3000/api/auth/callback",
appBaseUrl: process.env.CORECI_SAAS_URL ?? "http://localhost:3000",
};
const url = getAuthorizationUrl(config);
return NextResponse.redirect(url);
}
@@ -0,0 +1,27 @@
/**
* GET /api/auth/logout — destroy the session + clear the cookie.
*
* Reads the `coreci_session` cookie, destroys the session row, redirects to
* /login with the cookie cleared.
*/
import { NextRequest, NextResponse } from "next/server";
import { destroySessionByToken } from "@coreci/auth";
import { getDb } from "../../../../lib/db.js";
function signingKey(): string {
const k = process.env.SESSION_SIGNING_KEY;
if (!k) throw new Error("SESSION_SIGNING_KEY not configured");
return k;
}
export async function GET(req: NextRequest): Promise<NextResponse> {
const cookie = req.cookies.get("coreci_session")?.value;
if (cookie) {
const db = await getDb();
await destroySessionByToken(db, signingKey(), cookie);
}
const res = NextResponse.redirect(new URL("/login", req.nextUrl.origin));
res.cookies.set("coreci_session", "", { httpOnly: true, path: "/", maxAge: 0 });
return res;
}
@@ -0,0 +1,29 @@
/**
* GET /api/auth/mock-login — dev-only synthetic SSO entrypoint.
*
* Used when WORKOS_API_KEY is not configured. Renders a tiny HTML page with a
* "Sign in (dev)" button that POSTs back to /api/auth/callback with a mock
* code, so the full SSO round-trip is exercisable end-to-end without WorkOS.
*
* In prod (WORKOS_API_KEY set), /api/auth/login redirects to the real WorkOS
* hosted SSO URL and this route is never hit.
*/
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest): Promise<NextResponse> {
const redirectUri = req.nextUrl.searchParams.get("redirect_uri") ?? "http://localhost:3000/api/auth/callback";
const mockCode = "mock-code-alpha";
const callbackUrl = new URL(redirectUri, req.nextUrl.origin);
callbackUrl.searchParams.set("code", mockCode);
const html = `<!doctype html><html><head><meta charset="utf-8"><title>CoreCI (dev SSO)</title>
<style>body{font:14px system-ui;max-width:32rem;margin:4rem auto;padding:0 1rem}
button{padding:.6rem 1.2rem;font:inherit}</style></head>
<body>
<h1>CoreCI Chat — dev SSO</h1>
<p>No <code>WORKOS_API_KEY</code> configured. Mock sign-in below.</p>
<p><a href="${callbackUrl.pathname}?code=${encodeURIComponent(mockCode)}"><button>Sign in (mock-alpha)</button></a></p>
</body></html>`;
return new NextResponse(html, { headers: { "content-type": "text/html; charset=utf-8" } });
}
@@ -0,0 +1,50 @@
/**
* POST /api/invitations/accept — accept a single-use invitation (REQ-003).
*
* Body: { token: string } (the invitation id, passed via query string or body).
* RBAC: read (any authenticated). The single-use token is the real gate; RBAC
* here prevents an authenticated low-role user from being tricked into an
* escalating flow — the invitation itself scopes the role they receive.
*
* On success: returns { userId, tenantId, role } and the caller is expected to
* log in via the normal SSO flow (or, in dev, immediately use the returned
* role to bootstrap a session — see /api/auth/callback).
*/
import type { NextRequest } from "next/server";
import { acceptInvitation, InvitationError } from "@coreci/auth";
import { requireAuth } from "../../../../lib/auth.js";
import { getDb } from "../../../../lib/db.js";
export async function POST(req: NextRequest): Promise<Response> {
const auth = await requireAuth(req);
if (auth instanceof Response) return auth;
// Token can arrive via query (?token=...) or JSON body { token }.
let token: string | null = req.nextUrl.searchParams.get("token");
if (!token) {
try {
const body = (await req.json()) as { token?: unknown };
if (typeof body.token === "string") token = body.token;
} catch {
/* fallthrough */
}
}
if (!token) {
return Response.json({ error: "token required" }, { status: 400 });
}
const db = await getDb();
try {
const result = await acceptInvitation(db, token);
return Response.json(
{ userId: result.userId, tenantId: result.tenantId, role: result.role },
{ status: 200 },
);
} catch (err) {
if (err instanceof InvitationError) {
return Response.json({ error: err.message }, { status: 400 });
}
throw err;
}
}
@@ -0,0 +1,45 @@
/**
* GET /api/invitations — list pending/accepted invitations for this tenant.
*
* RBAC: read (any authenticated). Admin sees all invitations; the create path
* (POST) is admin-only and lives in /api/team.
*/
import type { NextRequest } from "next/server";
import { withTenant, type ScopedClient } from "@coreci/db";
import { requireAuth } from "../../../lib/auth.js";
interface InvitationRow {
id: string;
email: string;
role: string;
status: string;
created_at: string;
accepted_at: string | null;
}
export async function GET(req: NextRequest): Promise<Response> {
const auth = await requireAuth(req);
if (auth instanceof Response) return auth;
const tenantId = auth.user.tenantId;
const rows = await withTenant(tenantId, async (c: ScopedClient) => {
const res = await c.query<InvitationRow>(
`SELECT id, email, role, status, created_at, accepted_at
FROM invitations
ORDER BY created_at DESC`,
);
return res.rows;
});
return Response.json({
invitations: rows.map((r) => ({
id: r.id,
email: r.email,
role: r.role,
status: r.status,
createdAt: r.created_at,
acceptedAt: r.accepted_at,
})),
});
}
+21
View File
@@ -0,0 +1,21 @@
/**
* GET /api/me — return the authenticated user + tenant (REQ-005 first endpoint).
*
* RBAC: read (any authenticated role). Enforced at the gateway via requireAuth.
*/
import type { NextRequest } from "next/server";
import { requireAuth } from "../../../lib/auth.js";
export async function GET(_req: NextRequest): Promise<Response> {
const auth = await requireAuth(_req);
if (auth instanceof Response) return auth;
return Response.json({
user: {
id: auth.user.id,
tenantId: auth.user.tenantId,
role: auth.user.role,
},
});
}
@@ -0,0 +1,74 @@
/**
* PATCH /api/team/[userId] — change a member's role (REQ-004).
*
* RBAC: admin. Updates tenant_memberships.role under withTenant. The change is
* enforced on the user's NEXT API call (the session row still carries the old
* role; the gateway re-reads it from the session row on each request, so the
* next call sees the new role once the session row is updated — see below).
*
* REQ-004 "enforced on the next API call": we update BOTH the membership AND
* any active sessions for this user in this tenant, so the role change is
* immediately effective (no stale session).
*/
import type { NextRequest } from "next/server";
import { withTenant, appendAudit, type ScopedClient } from "@coreci/db";
import { type Role } from "@coreci/auth";
import { requireAuth } from "../../../../lib/auth.js";
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ userId: string }> }): Promise<Response> {
const auth = await requireAuth(req);
if (auth instanceof Response) return auth;
const { userId } = await ctx.params;
if (!userId) {
return Response.json({ error: "missing userId" }, { status: 400 });
}
let body: { role?: unknown };
try {
body = (await req.json()) as { role?: unknown };
} catch {
return Response.json({ error: "invalid JSON body" }, { status: 400 });
}
if (body.role !== "admin" && body.role !== "operator" && body.role !== "viewer") {
return Response.json({ error: "role must be admin | operator | viewer" }, { status: 400 });
}
const newRole: Role = body.role;
const tenantId = auth.user.tenantId;
let updated = false;
await withTenant(tenantId, async (c: ScopedClient) => {
const res = await c.query(
`UPDATE tenant_memberships SET role = $1 WHERE tenant_id = $2 AND user_id = $3`,
[newRole, tenantId, userId],
);
updated = (res.rowCount ?? 0) > 0;
if (updated) {
// Propagate the role change to any active session rows so the gateway
// enforces it on the very next API call (REQ-004). Sessions are NOT
// tenant-scoped, so this UPDATE runs outside RLS on the sessions table.
// We do it here (still inside the tenant transaction — sessions has no
// FK back to the tenant row, so no RLS issue) for atomicity.
await c.query("UPDATE sessions SET role = $1 WHERE user_id = $2 AND tenant_id = $3", [
newRole,
userId,
tenantId,
]);
await appendAudit(c, {
tenantId,
eventType: "auth",
payload: { action: "role_changed", userId, newRole },
userId: auth.user.id,
});
}
});
if (!updated) {
return Response.json({ error: "membership not found" }, { status: 404 });
}
return Response.json({ userId, role: newRole });
}
+81
View File
@@ -0,0 +1,81 @@
/**
* /api/team — team membership (REQ-003, REQ-004).
*
* GET → list members (RBAC: read — any authenticated)
* POST → invite a user by email (RBAC: admin) — calls invitations.createInvitation
*/
import type { NextRequest } from "next/server";
import { withTenant } from "@coreci/db";
import { createInvitation, type Role } from "@coreci/auth";
import { requireAuth } from "../../../lib/auth.js";
import { getDb } from "../../../lib/db.js";
interface MemberRow {
user_id: string;
email: string;
role: Role;
}
async function listMembers(tenantId: string): Promise<MemberRow[]> {
return withTenant(tenantId, async (c) => {
const res = await c.query<MemberRow>(
`SELECT m.user_id, u.email, m.role
FROM tenant_memberships m
JOIN users u ON u.id = m.user_id
ORDER BY u.email`,
);
return res.rows;
});
}
export async function GET(req: NextRequest): Promise<Response> {
const auth = await requireAuth(req);
if (auth instanceof Response) return auth;
const members = await listMembers(auth.user.tenantId);
return Response.json({
members: members.map((m) => ({
userId: m.user_id,
email: m.email,
role: m.role,
})),
});
}
export async function POST(req: NextRequest): Promise<Response> {
const auth = await requireAuth(req);
if (auth instanceof Response) return auth;
let body: { email?: unknown; role?: unknown };
try {
body = (await req.json()) as { email?: unknown; role?: unknown };
} catch {
return Response.json({ error: "invalid JSON body" }, { status: 400 });
}
const email = typeof body.email === "string" ? body.email : null;
const role = body.role;
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
return Response.json({ error: "valid email required" }, { status: 400 });
}
if (role !== "admin" && role !== "operator" && role !== "viewer") {
return Response.json({ error: "role must be admin | operator | viewer" }, { status: 400 });
}
const db = await getDb();
const baseUrl = process.env.CORECI_SAAS_URL ?? "http://localhost:3000";
const invitation = await createInvitation(db, auth.user.tenantId, auth.user.id, email, role, { baseUrl });
return Response.json(
{
invitation: {
id: invitation.id,
email: invitation.email,
role: invitation.role,
status: invitation.status,
acceptanceLink: invitation.acceptanceLink,
},
},
{ status: 201 },
);
}
+36
View File
@@ -0,0 +1,36 @@
/**
* dashboard helper — fetch the authenticated user from the API gateway.
*
* Server components call this to resolve the current user for SSR. It hits
* /api/me through the same gateway (cookie forwarded) so the dashboard never
* bypasses RLS / RBAC. Returns null on 401 (the caller redirects to /login).
*
* Runs only on the server (no fetch in the browser bundle) — `cache: 'no-store'`
* keeps it fresh per request.
*/
import { headers, cookies } from "next/headers";
export interface Me {
id: string;
tenantId: string;
role: "admin" | "operator" | "viewer";
}
export async function getMe(): Promise<Me | null> {
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("coreci_session")?.value;
if (!sessionCookie) return null;
const h = await headers();
const host = h.get("host") ?? "localhost:3000";
const proto = h.get("x-forwarded-proto") ?? "http";
const res = await fetch(`${proto}://${host}/api/me`, {
headers: { cookie: `coreci_session=${sessionCookie}` },
cache: "no-store",
});
if (res.status !== 200) return null;
const body = (await res.json()) as { user: Me };
return body.user;
}
+73
View File
@@ -0,0 +1,73 @@
/**
* /dashboard — M1 admin dashboard shell (REQ-002).
*
* The 5-step onboarding checklist is rendered here (all grey — steps light up
* as later waves ship). Wave E wires live Relay Agent health + targets; this
* shell proves the SSO → /dashboard redirect works end-to-end.
*/
import { getMe } from "./me.js";
export default async function DashboardPage() {
const me = await getMe();
if (!me) {
// Not authenticated — bounce to login. getMe() returns null on 401.
return (
<main style={{ fontFamily: "system-ui", maxWidth: "32rem", margin: "4rem auto", padding: "0 1rem" }}>
<h1>CoreCI Chat</h1>
<p>You are not signed in.</p>
<p>
<a href="/login">
<button style={{ padding: "0.6rem 1.2rem", font: "inherit" }}>Go to login</button>
</a>
</p>
</main>
);
}
const steps = [
{ id: "byom", label: "Configure BYOM", wave: "C" },
{ id: "relay", label: "Install Relay Agent", wave: "D" },
{ id: "target", label: "Register Target", wave: "D" },
{ id: "verify", label: "Verify Green Status", wave: "E" },
{ id: "team", label: "Invite Team", wave: "B" },
];
return (
<main style={{ fontFamily: "system-ui", maxWidth: "48rem", margin: "2rem auto", padding: "0 1rem" }}>
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<h1>CoreCI Chat Dashboard</h1>
<span style={{ color: "#666", fontSize: "0.85rem" }}>
{me.role} · tenant {me.tenantId.slice(0, 8)}
</span>
</header>
<section>
<h2>Onboarding</h2>
<ul style={{ listStyle: "none", padding: 0 }}>
{steps.map((s) => (
<li key={s.id} style={{ padding: "0.4rem 0", display: "flex", gap: "0.6rem", alignItems: "center" }}>
<span
aria-label={s.id + " status"}
style={{
width: "0.9rem",
height: "0.9rem",
borderRadius: "50%",
background: "#ccc",
display: "inline-block",
}}
/>
<span>{s.label}</span>
<span style={{ color: "#999", fontSize: "0.75rem" }}>(Wave {s.wave})</span>
</li>
))}
</ul>
</section>
<p style={{ marginTop: "2rem" }}>
<a href="/api/auth/logout">Sign out</a> ·{" "}
<a href="/dashboard/team">Team</a>
</p>
</main>
);
}
@@ -0,0 +1,81 @@
/**
* /dashboard/team — Team / RBAC management (REQ-003, REQ-004).
*
* Lists members + shows an invite form. The role-change dropdown is rendered
* client-side; this server shell fetches the list via the API gateway.
*/
import { getMe } from "../me.js";
export default async function TeamPage() {
const me = await getMe();
if (!me) {
return (
<main style={{ fontFamily: "system-ui", maxWidth: "32rem", margin: "4rem auto", padding: "0 1rem" }}>
<h1>Team</h1>
<p>You are not signed in.</p>
<p>
<a href="/login">
<button style={{ padding: "0.6rem 1.2rem", font: "inherit" }}>Go to login</button>
</a>
</p>
</main>
);
}
return (
<main style={{ fontFamily: "system-ui", maxWidth: "48rem", margin: "2rem auto", padding: "0 1rem" }}>
<h1>Team</h1>
<p>Signed in as <strong>{me.role}</strong>.</p>
{me.role === "admin" ? (
<section>
<h2>Invite a member</h2>
<form id="invite-form" style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<input
name="email"
type="email"
placeholder="ops@example.com"
required
style={{ padding: "0.4rem", minWidth: "16rem" }}
/>
<select name="role" defaultValue="operator" style={{ padding: "0.4rem" }}>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
<button type="submit" style={{ padding: "0.4rem 1rem" }}>Invite</button>
</form>
</section>
) : (
<p style={{ color: "#666" }}>Only admins can invite or change roles. You are a {me.role}.</p>
)}
<p style={{ marginTop: "2rem" }}>
<a href="/dashboard"> Dashboard</a>
</p>
{me.role === "admin" && (
<script dangerouslySetInnerHTML={{
__html: `
document.getElementById('invite-form').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const res = await fetch('/api/team', {
method: 'POST',
headers: {'content-type':'application/json'},
body: JSON.stringify({ email: fd.get('email'), role: fd.get('role') }),
});
const j = await res.json();
if (res.ok) {
alert('Invited! Share this link: ' + j.invitation.acceptanceLink);
} else {
alert('Failed: ' + (j.error || res.status));
}
});
`,
}} />
)}
</main>
);
}
+23
View File
@@ -0,0 +1,23 @@
/**
* /login — SSO entry point (REQ-001).
*
* Single button → GET /api/auth/login (WorkOS redirect). M1 ships the button;
* the dashboard shell + chat UI land in Wave E / M3.
*/
export default function LoginPage() {
return (
<main style={{ fontFamily: "system-ui", maxWidth: "32rem", margin: "4rem auto", padding: "0 1rem" }}>
<h1>CoreCI Chat</h1>
<p>Sign in with SSO to continue.</p>
<p>
<a href="/api/auth/login">
<button style={{ padding: "0.6rem 1.2rem", font: "inherit" }}>Sign in with SSO</button>
</a>
</p>
<p style={{ color: "#666", fontSize: "0.85rem" }}>
v0.1 read-only diagnostic wedge. SSO via WorkOS.
</p>
</main>
);
}
+82
View File
@@ -0,0 +1,82 @@
/**
* control-plane lib/auth — Next.js adapter for @coreci/auth middleware.
*
* Wraps the framework-agnostic `createAuthMiddleware` so a Route Handler can
* call `authenticateRequest(request)` and get back a typed AuthResult that
* either yields the user (authorized) or a ready-made Next.js Response (401 /
* 403) the handler can return directly.
*
* REQ-005 critical-path: every /api/* route calls this BEFORE doing work.
*/
import type { NextRequest } from "next/server";
import { authenticate, type AuthResult, type AuthRequest } from "@coreci/auth";
import { getDb } from "./db.js";
/** SESSION_SIGNING_KEY — infra bootstrap cred (G-010 tier a). Set via env. */
function signingKey(): string {
const k = process.env.SESSION_SIGNING_KEY;
if (!k) throw new Error("SESSION_SIGNING_KEY not configured");
return k;
}
/** Convert a NextRequest into the framework-agnostic AuthRequest shape. */
function toAuthRequest(req: NextRequest): AuthRequest {
const url = req.nextUrl;
return {
method: req.method,
path: url.pathname,
getCookie(name: string): string | undefined {
const c = req.cookies.get(name);
return c?.value;
},
};
}
/**
* Authenticate + enforce RBAC for a Next.js Route Handler.
* Returns the AuthResult; the caller branches on `.status`.
*/
export async function authenticateRequest(req: NextRequest): Promise<AuthResult> {
const db = await getDb();
return authenticate(db, signingKey(), toAuthRequest(req));
}
/** Helper: turn an unauthorized/forbidden AuthResult into a Next.js Response. */
export function authFailureResponse(result: AuthResult): Response {
if (result.status === "unauthorized") {
return new Response(
JSON.stringify({ error: "unauthorized", reason: result.reason }),
{ status: 401, headers: { "content-type": "application/json" } },
);
}
return new Response(
JSON.stringify({
error: "forbidden",
required: result.decision.required,
unknownRole: result.decision.unknownRole,
}),
{ status: 403, headers: { "content-type": "application/json" } },
);
}
/**
* Require authorization for a route. Returns the user on success, or a Response
* (already-serialized 401/403) on failure. The canonical route handler shape:
*
* export async function GET(req) {
* const auth = await requireAuth(req, "GET", "/api/me");
* if (auth instanceof Response) return auth;
* const user = auth.user; // {id, tenantId, role}
* ...
* }
*/
export async function requireAuth(
req: NextRequest,
): Promise<{ user: { id: string; tenantId: string; role: "admin" | "operator" | "viewer" } } | Response> {
const result = await authenticateRequest(req);
if (result.status === "authorized") {
return { user: { id: result.user.userId, tenantId: result.user.tenantId, role: result.user.role } };
}
return authFailureResponse(result);
}
+52
View File
@@ -0,0 +1,52 @@
/**
* control-plane lib/db — server-side DbClient singleton.
*
* The API gateway bootstraps a single DbClient (PGlite in dev, pg Pool in prod)
* and registers it with @coreci/db via setDbClient so withTenant() can find it.
* The client is created lazily on first use; a module-level cached promise keeps
* the same PGlite instance across hot reloads within a boot.
*/
import { createDb, setDbClient, type DbClient } from "@coreci/db";
import { readFile, readdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
let dbPromise: Promise<DbClient> | null = null;
export async function getDb(): Promise<DbClient> {
if (!dbPromise) {
dbPromise = (async () => {
const db = await createDb();
setDbClient(db);
await runMigrations(db);
return db;
})();
}
return dbPromise;
}
async function runMigrations(db: DbClient): Promise<void> {
// Migrations live in packages/db/migrations. Resolve via the symlinked
// @coreci/db workspace package (apps/control-plane/node_modules/@coreci/db
// → packages/db). Falls back to a monorepo-relative path in dev.
const here = dirname(fileURLToPath(import.meta.url));
const candidates = [
join(here, "..", "..", "node_modules", "@coreci", "db", "migrations"),
join(here, "..", "..", "..", "packages", "db", "migrations"),
];
for (const dir of candidates) {
let files: string[];
try {
files = (await readdir(dir)).filter((f) => f.endsWith(".sql")).sort();
} catch {
continue;
}
for (const file of files) {
const sql = await readFile(join(dir, file), "utf8");
await db.exec(sql);
}
return;
}
// No migrations dir found — skip (the prod deploy runs `pnpm migrate` first).
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+3 -2
View File
@@ -12,10 +12,11 @@
"test": "vitest run"
},
"dependencies": {
"@coreci/db": "workspace:*",
"@coreci/secrets": "workspace:*",
"@coreci/auth": "workspace:*",
"@coreci/config": "workspace:*",
"@coreci/db": "workspace:*",
"@coreci/runtime": "workspace:*",
"@coreci/secrets": "workspace:*",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
+182
View File
@@ -0,0 +1,182 @@
/**
* control-plane auth flow integration test (REQ-001..005).
*
* Exercises the full Wave B flow through the @coreci/auth surface (the same
* functions the Route Handlers call), against a real PGlite + migrations:
*
* getAuthorizationUrl (dev/mock) → exchangeCodeForSession (mock)
* → provisionTenant → createSession → authenticate (middleware)
* → enforceRbac on /api/me (read) and /api/byom (admin)
*
* This validates the routes' logic without spinning Next's runtime.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import {
getAuthorizationUrl,
exchangeCodeForSession,
provisionTenant,
createSession,
authenticate,
enforceRbac,
SESSION_COOKIE,
type WorkosConfig,
type AuthRequest,
} from "@coreci/auth";
import { createDb, setDbClient, withTenant, type DbClient } from "@coreci/db";
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
const SIGNING_KEY = "cp-test-session-signing-key";
async function bootDb(): Promise<DbClient> {
const db = await createDb({ mode: "pglite" });
setDbClient(db);
const dir = join(import.meta.dirname, "..", "..", "..", "packages", "db", "migrations");
const files = (await readdir(dir)).filter((f) => f.endsWith(".sql")).sort();
for (const f of files) {
await db.exec(await readFile(join(dir, f), "utf8"));
}
return db;
}
function fakeReq(method: string, path: string, cookie: string | undefined): AuthRequest {
return {
method,
path,
getCookie(name) {
return name === SESSION_COOKIE ? cookie : undefined;
},
};
}
describe("control-plane auth flow (REQ-001..005)", () => {
let db: DbClient;
beforeAll(async () => {
db = await bootDb();
});
afterAll(async () => {
/* PGlite holds no external resources */
});
it("REQ-001: dev SSO URL is a mock-login URL (no WORKOS_API_KEY)", () => {
const config: WorkosConfig = {
apiKey: undefined,
clientId: undefined,
redirectUrl: "http://localhost:3000/api/auth/callback",
appBaseUrl: "http://localhost:3000",
};
const url = getAuthorizationUrl(config);
expect(url).toContain("/api/auth/mock-login");
});
it("REQ-001: exchangeCodeForSession returns a mock user in dev mode", async () => {
const config: WorkosConfig = {
apiKey: undefined,
clientId: undefined,
redirectUrl: "http://localhost:3000/api/auth/callback",
appBaseUrl: "http://localhost:3000",
};
const u = await exchangeCodeForSession(config, "mock-code-alpha");
expect(u.email).toBe("alpha@example.com");
expect(u.workosUserId).toBe("user_alpha");
expect(u.workosOrgId).toBe("org_alpha");
});
it("REQ-002: provision on first signup → admin", async () => {
const u = await exchangeCodeForSession(
{ apiKey: undefined, clientId: undefined, redirectUrl: "", appBaseUrl: "" },
"mock-code-beta",
);
const p = await provisionTenant(db, u.workosOrgId, u.workosUserId, u.email, u.name);
expect(p.role).toBe("admin");
expect(p.createdTenant).toBe(true);
});
it("REQ-001 + REQ-005: full SSO → session → /api/me (read, authorized)", async () => {
const u = await exchangeCodeForSession(
{ apiKey: undefined, clientId: undefined, redirectUrl: "", appBaseUrl: "" },
"mock-code-gamma",
);
const p = await provisionTenant(db, u.workosOrgId, u.workosUserId, u.email, u.name);
const { token } = await createSession(db, SIGNING_KEY, p.userId, p.tenantId, p.role, {
lifetimeSeconds: 60,
});
const result = await authenticate(db, SIGNING_KEY, fakeReq("GET", "/api/me", token));
expect(result.status).toBe("authorized");
if (result.status === "authorized") {
expect(result.user.userId).toBe(p.userId);
expect(result.user.tenantId).toBe(p.tenantId);
expect(result.user.role).toBe("admin");
}
});
it("REQ-004 + REQ-005: role change enforced on next API call", async () => {
const u = await exchangeCodeForSession(
{ apiKey: undefined, clientId: undefined, redirectUrl: "", appBaseUrl: "" },
"mock-code-delta",
);
const p = await provisionTenant(db, u.workosOrgId, u.workosUserId, u.email, u.name);
const { token, sessionId } = await createSession(db, SIGNING_KEY, p.userId, p.tenantId, "admin", {
lifetimeSeconds: 60,
});
// Admin can hit POST /api/byom (admin).
expect((await authenticate(db, SIGNING_KEY, fakeReq("POST", "/api/byom", token))).status).toBe(
"authorized",
);
// Simulate the PATCH /api/team/[userId] flow: demote to viewer + update
// the session row (the route does this).
await withTenant(p.tenantId, async (c) => {
await c.query("UPDATE tenant_memberships SET role = 'viewer' WHERE tenant_id = $1 AND user_id = $2", [
p.tenantId,
p.userId,
]);
});
await db.query("UPDATE sessions SET role = 'viewer' WHERE id = $1", [sessionId]);
// The next call is forbidden on POST /api/byom.
const after = await authenticate(db, SIGNING_KEY, fakeReq("POST", "/api/byom", token));
expect(after.status).toBe("forbidden");
});
it("REQ-005: viewer → 403 on POST /api/byom, 200 on GET /api/me", async () => {
const u = await exchangeCodeForSession(
{ apiKey: undefined, clientId: undefined, redirectUrl: "", appBaseUrl: "" },
"mock-code-epsilon",
);
const p = await provisionTenant(db, u.workosOrgId, u.workosUserId, u.email, u.name);
const { token } = await createSession(db, SIGNING_KEY, p.userId, p.tenantId, "viewer", {
lifetimeSeconds: 60,
});
expect((await authenticate(db, SIGNING_KEY, fakeReq("GET", "/api/me", token))).status).toBe(
"authorized",
);
expect((await authenticate(db, SIGNING_KEY, fakeReq("POST", "/api/byom", token))).status).toBe(
"forbidden",
);
});
it("REQ-005: no cookie → unauthorized", async () => {
const r = await authenticate(db, SIGNING_KEY, fakeReq("GET", "/api/me", undefined));
expect(r.status).toBe("unauthorized");
});
it("REQ-005: enforceRbac route table for control-plane routes", () => {
// Sanity-check the decisions the routes depend on.
expect(enforceRbac("admin", "GET", "/api/me").allowed).toBe(true);
expect(enforceRbac("viewer", "GET", "/api/me").allowed).toBe(true);
expect(enforceRbac("operator", "GET", "/api/team").allowed).toBe(true);
expect(enforceRbac("viewer", "POST", "/api/team").allowed).toBe(false);
expect(enforceRbac("admin", "PATCH", "/api/team/00000000-0000-0000-0000-000000000001").allowed).toBe(true);
expect(enforceRbac("operator", "PATCH", "/api/team/00000000-0000-0000-0000-000000000001").allowed).toBe(false);
expect(enforceRbac("viewer", "POST", "/api/invitations").allowed).toBe(false);
expect(enforceRbac("admin", "POST", "/api/invitations/accept").allowed).toBe(true);
expect(enforceRbac("viewer", "POST", "/api/invitations/accept").allowed).toBe(true);
});
});
+9 -1
View File
@@ -3,8 +3,16 @@
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"jsx": "preserve",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "preserve",
"noEmit": true,
"incremental": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": false,
"types": ["node", "@types/react"],
"plugins": [{ "name": "next" }],
"paths": {
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
testTimeout: 30000,
coverage: {
provider: "v8",
include: ["lib/**/*.ts"],
reporter: ["text", "json"],
},
},
});
+16
View File
@@ -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/**"],
},
);
+57
View File
@@ -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"
}
}
+52
View File
@@ -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";
+262
View File
@@ -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;
}
+95
View File
@@ -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;
}
+182
View File
@@ -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;
}
+191
View File
@@ -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());
}
+205
View File
@@ -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;
}
+37
View File
@@ -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;
}
+130
View File
@@ -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,
);
}
}
+63
View File
@@ -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 };
}
+145
View File
@@ -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/);
});
});
+145
View File
@@ -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");
});
});
+103
View File
@@ -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");
});
});
+177
View File
@@ -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);
});
});
+141
View File
@@ -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);
});
});
+10
View File
@@ -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"]
}
+15
View File
@@ -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"],
},
},
});
+12
View File
@@ -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),
},
};
}
+19
View File
@@ -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");
});
});
+30
View File
@@ -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.
+2 -2
View File
@@ -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";
+31
View File
@@ -14,6 +14,9 @@ importers:
apps/control-plane:
dependencies:
'@coreci/auth':
specifier: workspace:*
version: link:../../packages/auth
'@coreci/config':
specifier: workspace:*
version: link:../../packages/config
@@ -52,6 +55,34 @@ importers:
specifier: ^2.1.0
version: 2.1.9(@types/node@22.20.1)(lightningcss@1.33.0)(supports-color@7.2.0)
packages/auth:
dependencies:
'@coreci/config':
specifier: workspace:*
version: link:../config
'@coreci/db':
specifier: workspace:*
version: link:../db
'@coreci/secrets':
specifier: workspace:*
version: link:../secrets
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.20.1
eslint:
specifier: ^9.0.0
version: 9.39.5(supports-color@7.2.0)
tsx:
specifier: ^4.19.0
version: 4.23.12
typescript:
specifier: ^5.6.0
version: 5.9.3
vitest:
specifier: ^2.1.0
version: 2.1.9(@types/node@22.20.1)(lightningcss@1.33.0)(supports-color@7.2.0)
packages/config:
devDependencies:
'@types/node':