- Welcome, {session.user?.name ?? session.user?.email ?? "user"}. This is the{" "}
- {slug} dashboard. Real product tiles, settings, billing — land
- in M5.2 / M10.1.
+
+
Dashboard
+
+ Welcome, {session.user?.name ?? session.user?.email ?? "user"}. Signed in
+ as {session.org_roles?.join(", ") ?? "(no roles)"}.
-
-
+
);
}
diff --git a/src/app/[slug]/layout.tsx b/src/app/[slug]/layout.tsx
index 11785b1..c75fb97 100644
--- a/src/app/[slug]/layout.tsx
+++ b/src/app/[slug]/layout.tsx
@@ -1,5 +1,8 @@
-import { notFound } from "next/navigation";
+import { notFound, redirect } from "next/navigation";
import type { ReactNode } from "react";
+import { auth } from "@/auth";
+import { Nav } from "@/components/Nav";
+import type { SessionWithExtras } from "@/lib/session";
import { fetchTenantBySlug } from "@/lib/tenant-registry";
export default async function TenantLayout({
@@ -13,24 +16,36 @@ export default async function TenantLayout({
const tenant = await fetchTenantBySlug(slug);
if (!tenant) notFound();
+ const session = (await auth()) as SessionWithExtras | null;
+
+ // Tenant mismatch guard — a JWT scoped to tenant A must not be allowed
+ // to view tenant B. If the slug in the path doesn't match the session
+ // tenant_slug, redirect back to whatever this user CAN see.
+ if (session && session.tenant_slug && session.tenant_slug !== slug) {
+ redirect(`/${session.tenant_slug}/dashboard`);
+ }
+
return (
-
+ This surface requires a role your account doesn't have. If you think
+ that's a mistake, ask an IT_ADMIN on your tenant to invite you with
+ the right role.
+
+
+ );
+}
diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts
new file mode 100644
index 0000000..cebe850
--- /dev/null
+++ b/src/lib/session.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, test } from "vitest";
+import { canSee, hasAnyOrgRole, hasOrgRole, hasProduct } from "./session";
+import type { SessionWithExtras } from "./session";
+
+function s(roles: SessionWithExtras["org_roles"], products: string[] = []): SessionWithExtras {
+ return {
+ user: { name: "Test", email: "t@x.test" },
+ expires: "2099-01-01T00:00:00Z",
+ org_roles: roles,
+ products,
+ };
+}
+
+describe("hasOrgRole", () => {
+ test("null session has no roles", () => {
+ expect(hasOrgRole(null, "IT_ADMIN")).toBe(false);
+ });
+ test("matches single role", () => {
+ expect(hasOrgRole(s(["CXO"]), "CXO")).toBe(true);
+ expect(hasOrgRole(s(["CXO"]), "IT_ADMIN")).toBe(false);
+ });
+});
+
+describe("hasAnyOrgRole", () => {
+ test("any match wins", () => {
+ expect(hasAnyOrgRole(s(["LEGAL"]), ["IT_ADMIN", "LEGAL"])).toBe(true);
+ expect(hasAnyOrgRole(s(["USER"]), ["IT_ADMIN", "CXO"])).toBe(false);
+ });
+ test("empty roles", () => {
+ expect(hasAnyOrgRole(s(undefined), ["IT_ADMIN"])).toBe(false);
+ });
+});
+
+describe("hasProduct", () => {
+ test("checks products array", () => {
+ expect(hasProduct(s(["USER"], ["certifai"]), "certifai")).toBe(true);
+ expect(hasProduct(s(["USER"], ["certifai"]), "compliance")).toBe(false);
+ expect(hasProduct(null, "certifai")).toBe(false);
+ });
+});
+
+describe("canSee", () => {
+ test("IT_ADMIN sees settings, USER does not", () => {
+ expect(canSee(s(["IT_ADMIN"]), "settings")).toBe(true);
+ expect(canSee(s(["USER"]), "settings")).toBe(false);
+ });
+ test("CXO can see billing", () => {
+ expect(canSee(s(["CXO"]), "billing")).toBe(true);
+ });
+ test("LEGAL can see audit but not settings", () => {
+ expect(canSee(s(["LEGAL"]), "audit")).toBe(true);
+ expect(canSee(s(["LEGAL"]), "settings")).toBe(false);
+ });
+ test("FINANCE sees billing but not settings", () => {
+ expect(canSee(s(["FINANCE"]), "billing")).toBe(true);
+ expect(canSee(s(["FINANCE"]), "settings")).toBe(false);
+ });
+ test("dashboard visible to everyone with any role", () => {
+ expect(canSee(s(["USER"]), "dashboard")).toBe(true);
+ expect(canSee(s(["LEGAL"]), "dashboard")).toBe(true);
+ });
+ test("null session sees nothing", () => {
+ expect(canSee(null, "dashboard")).toBe(false);
+ });
+});
diff --git a/src/lib/session.ts b/src/lib/session.ts
new file mode 100644
index 0000000..1fd7bd7
--- /dev/null
+++ b/src/lib/session.ts
@@ -0,0 +1,70 @@
+// Session-derived types & helpers — keep all session-shape knowledge in
+// one place so route components don't all repeat the same casts.
+//
+// The breakpilot-dev realm projects these claims into every JWT via
+// protocol mappers (see platform/orca-platform/dev/keycloak/realm-export.json).
+// Auth.js v5 callbacks copy them onto the session in src/auth.ts.
+
+import type { Session } from "next-auth";
+
+export type OrgRole = "IT_ADMIN" | "CXO" | "FINANCE" | "LEGAL" | "USER";
+export type TenantStatus = "demo" | "trial" | "active" | "frozen" | "archived";
+export type Plan = "starter" | "professional" | "enterprise";
+
+export type SessionExtras = {
+ tenant_id?: string;
+ tenant_slug?: string;
+ org_roles?: OrgRole[];
+ products?: string[];
+ plan?: Plan;
+ tenant_status?: TenantStatus;
+};
+
+export type SessionWithExtras = Session & SessionExtras;
+
+export function hasOrgRole(s: SessionWithExtras | null, role: OrgRole): boolean {
+ return !!s?.org_roles?.includes(role);
+}
+
+export function hasAnyOrgRole(s: SessionWithExtras | null, roles: OrgRole[]): boolean {
+ if (!s?.org_roles) return false;
+ return roles.some((r) => s.org_roles?.includes(r));
+}
+
+export function hasProduct(s: SessionWithExtras | null, product: string): boolean {
+ return !!s?.products?.includes(product);
+}
+
+// Permission matrix per PLATFORM_ARCHITECTURE.md §5a "Operating principles":
+// hide what the user can't access. Each portal surface declares which
+// org_roles can see it; the nav uses this to filter links.
+export type Surface =
+ | "dashboard"
+ | "products"
+ | "projects"
+ | "settings"
+ | "users"
+ | "api-keys"
+ | "integrations"
+ | "billing"
+ | "audit"
+ | "support"
+ | "catalog";
+
+export const surfaceRoles: Record = {
+ dashboard: ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"],
+ products: ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"],
+ projects: ["IT_ADMIN", "CXO"],
+ settings: ["IT_ADMIN"],
+ users: ["IT_ADMIN"],
+ "api-keys": ["IT_ADMIN"],
+ integrations: ["IT_ADMIN"],
+ billing: ["IT_ADMIN", "CXO", "FINANCE"],
+ audit: ["IT_ADMIN", "CXO", "LEGAL"],
+ support: ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"],
+ catalog: ["IT_ADMIN", "CXO"],
+};
+
+export function canSee(s: SessionWithExtras | null, surface: Surface): boolean {
+ return hasAnyOrgRole(s, surfaceRoles[surface]);
+}