Role split and owner gating
This commit is contained in:
38
scripts/verify-role-gates.mjs
Normal file
38
scripts/verify-role-gates.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
// Standalone JS port of `lib/session.ts::canMutate` and `isCustomerOwner`
|
||||
// for offline verification.
|
||||
//
|
||||
// SessionUser shape mirrors the TypeScript interface:
|
||||
// { roles: Role[], isPlatform: boolean, ... }
|
||||
|
||||
function canMutate(user) {
|
||||
return user.isPlatform || user.roles.includes("owner");
|
||||
}
|
||||
|
||||
function isCustomerOwner(user) {
|
||||
return !user.isPlatform && user.roles.includes("owner");
|
||||
}
|
||||
|
||||
const cases = [
|
||||
// [user, fn, expected, note]
|
||||
[{ isPlatform: true, roles: ["platform_admin"] }, canMutate, true, "platform admin can mutate"],
|
||||
[{ isPlatform: true, roles: ["platform_operator"] }, canMutate, true, "platform operator can mutate"],
|
||||
[{ isPlatform: false, roles: ["owner"] }, canMutate, true, "customer owner can mutate"],
|
||||
[{ isPlatform: false, roles: ["user"] }, canMutate, false, "customer user cannot mutate"],
|
||||
[{ isPlatform: false, roles: [] }, canMutate, false, "no roles cannot mutate"],
|
||||
[{ isPlatform: false, roles: ["owner", "user"] }, canMutate, true, "owner+user (owner wins)"],
|
||||
|
||||
[{ isPlatform: true, roles: ["platform_admin", "owner"] }, isCustomerOwner, false, "platform user with owner role is NOT customerOwner"],
|
||||
[{ isPlatform: false, roles: ["owner"] }, isCustomerOwner, true, "pure customer owner"],
|
||||
[{ isPlatform: false, roles: ["user"] }, isCustomerOwner, false, "customer user is not customerOwner"],
|
||||
[{ isPlatform: false, roles: [] }, isCustomerOwner, false, "empty roles is not customerOwner"],
|
||||
];
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
for (const [user, fn, expected, note] of cases) {
|
||||
const got = fn(user);
|
||||
const ok = got === expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
console.log(`\n${pass} pass, ${fail} fail`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
@@ -16,11 +16,17 @@ import Link from "next/link";
|
||||
*
|
||||
* Platform admins are redirected to /dashboard — they shouldn't be
|
||||
* creating tenant instances under their own org.
|
||||
*
|
||||
* Slice 5: customer-side `user` role is also redirected — only owners
|
||||
* may create new instances. The server-side POST handler enforces the
|
||||
* same; this redirect is purely UX so /user-role members don't land on
|
||||
* a wizard that will 403 on submit.
|
||||
*/
|
||||
export default async function NewInstancePage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (user.isPlatform) redirect("/dashboard");
|
||||
if (!canMutate(user)) redirect("/dashboard");
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
@@ -149,8 +149,39 @@ export default async function DashboardPage() {
|
||||
(r) => !r.tenantName || !orgTenants.some((t) => t.metadata.name === r.tenantName)
|
||||
);
|
||||
|
||||
// Slice 5: only owners (and platform users, who'd typically be using
|
||||
// the admin panel anyway) see the "Create new instance" link. A
|
||||
// `user`-role member sees the dashboard but not the create flow —
|
||||
// they need to ask an owner.
|
||||
const canCreate = canMutate(user);
|
||||
|
||||
// First-time user: empty company. Show the onboarding wizard inline.
|
||||
// Note: the registering user is always granted `owner` on their new
|
||||
// org by registerCustomer, so this branch is only reachable by an
|
||||
// owner — no role check needed here. But a customer-side `user`
|
||||
// promoted into a fresh empty org (Slice 7 invites) would also land
|
||||
// here without permission to submit. Belt-and-braces gate.
|
||||
if (orgTenants.length === 0 && inflightRequests.length === 0) {
|
||||
if (!canCreate) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">
|
||||
{t("welcome", { name: user.name || user.email })}
|
||||
</p>
|
||||
</div>
|
||||
<Card className="animate-in animate-in-delay-1">
|
||||
<p className="text-sm text-text-secondary text-center py-6">
|
||||
{t("noAccessNoInstances")}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
@@ -170,7 +201,7 @@ export default async function DashboardPage() {
|
||||
}
|
||||
|
||||
// Returning customer: list of tenants + in-flight requests, plus
|
||||
// a button to add another instance.
|
||||
// a button to add another instance (owners only).
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in flex items-start justify-between gap-4">
|
||||
@@ -183,12 +214,14 @@ export default async function DashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/dashboard/new"
|
||||
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
<span>+</span> {t("createInstance")}
|
||||
</Link>
|
||||
{canCreate && (
|
||||
<Link
|
||||
href="/dashboard/new"
|
||||
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
<span>+</span> {t("createInstance")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* In-flight (pending/approved/provisioning/rejected) requests */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
@@ -34,6 +34,11 @@ export default async function TenantDetailPage({
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Slice 5: editable surface gated on owner role. Platform users always
|
||||
// can edit; customer-side, only `owner` may. `user`-role members see
|
||||
// the same page but with edit controls hidden / fields read-only.
|
||||
const canEdit = canMutate(user);
|
||||
|
||||
const enabledPackages = tenant.spec.packages || [];
|
||||
const workspaceFiles = tenant.spec.workspaceFiles || {};
|
||||
const enabledChannels = enabledPackages.filter((pkg) =>
|
||||
@@ -100,6 +105,7 @@ export default async function TenantDetailPage({
|
||||
tenantName={name}
|
||||
enabledPackages={enabledPackages}
|
||||
conditions={tenant.status?.conditions}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -110,6 +116,7 @@ export default async function TenantDetailPage({
|
||||
tenantName={name}
|
||||
enabledChannels={enabledChannels}
|
||||
initialChannelUsers={channelUsers}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
@@ -119,7 +126,7 @@ export default async function TenantDetailPage({
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("workspaceFiles")}
|
||||
</h2>
|
||||
<WorkspaceEditor tenantName={name} files={workspaceFiles} />
|
||||
<WorkspaceEditor tenantName={name} files={workspaceFiles} canEdit={canEdit} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import {
|
||||
createTenantRequest,
|
||||
getTenantRequestById,
|
||||
@@ -157,6 +157,15 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Slice 5: only owners (or platform users) may create new instances.
|
||||
// A `user`-role member of an existing org cannot self-provision.
|
||||
if (!canMutate(user)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only the organization owner can create new instances." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = onboardingSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { safeError } from "@/lib/errors";
|
||||
@@ -46,7 +46,7 @@ export async function PATCH(
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (!user.isPlatform && !user.roles.includes("owner")) {
|
||||
if (!canMutate(user)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { writePackageSecrets } from "@/lib/openbao";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
@@ -12,7 +12,7 @@ export async function POST(
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
if (!user.isPlatform && !user.roles.includes("owner")) {
|
||||
if (!canMutate(user)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,15 @@ interface ChannelUsersProps {
|
||||
enabledChannels: string[];
|
||||
/** Current channelUsers from the PiecedTenant spec */
|
||||
initialChannelUsers: Record<string, string[]>;
|
||||
/** Slice 5: when false, add inputs and remove ✕ buttons are hidden. */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
export function ChannelUsers({
|
||||
tenantName,
|
||||
enabledChannels,
|
||||
initialChannelUsers,
|
||||
canEdit = true,
|
||||
}: ChannelUsersProps) {
|
||||
const t = useTranslations("channelUsers");
|
||||
const router = useRouter();
|
||||
@@ -146,44 +149,48 @@ export function ChannelUsers({
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full"
|
||||
>
|
||||
{userId}
|
||||
<button
|
||||
onClick={() => handleRemove(channel, userId)}
|
||||
disabled={saving}
|
||||
className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
title={t("remove")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => handleRemove(channel, userId)}
|
||||
disabled={saving}
|
||||
className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
title={t("remove")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add user */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValues[channel] || ""}
|
||||
onChange={(e) =>
|
||||
setInputValues((prev) => ({
|
||||
...prev,
|
||||
[channel]: e.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAdd(channel);
|
||||
}}
|
||||
placeholder={t("placeholder")}
|
||||
className="flex-1 px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleAdd(channel)}
|
||||
disabled={saving || !inputValues[channel]?.trim()}
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "…" : t("add")}
|
||||
</button>
|
||||
</div>
|
||||
{/* Add user — hidden in read-only mode */}
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValues[channel] || ""}
|
||||
onChange={(e) =>
|
||||
setInputValues((prev) => ({
|
||||
...prev,
|
||||
[channel]: e.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAdd(channel);
|
||||
}}
|
||||
placeholder={t("placeholder")}
|
||||
className="flex-1 px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleAdd(channel)}
|
||||
disabled={saving || !inputValues[channel]?.trim()}
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "…" : t("add")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,9 +10,18 @@ interface Props {
|
||||
status?: "pending" | "active" | "error";
|
||||
tenantName: string;
|
||||
onToggled: () => void;
|
||||
/** Slice 5: when false, the enable/disable button is hidden. */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Props) {
|
||||
export function PackageCard({
|
||||
pkg,
|
||||
enabled,
|
||||
status,
|
||||
tenantName,
|
||||
onToggled,
|
||||
canEdit = true,
|
||||
}: Props) {
|
||||
const t = useTranslations();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [secrets, setSecrets] = useState<Record<string, string>>({});
|
||||
@@ -113,17 +122,27 @@ export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Pro
|
||||
{pkg.requiresSecrets && (
|
||||
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={enabled ? () => togglePackage(false) : handleEnable}
|
||||
disabled={saving}
|
||||
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
enabled
|
||||
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
||||
</button>
|
||||
{canEdit ? (
|
||||
<button
|
||||
onClick={enabled ? () => togglePackage(false) : handleEnable}
|
||||
disabled={saving}
|
||||
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
enabled
|
||||
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
||||
</button>
|
||||
) : (
|
||||
// Slice 5: read-only viewers see a static badge instead of a
|
||||
// toggle. The status badge above the divider already conveys
|
||||
// "active/pending/error"; this just clarifies "you can't change
|
||||
// it" without duplicating the status colour.
|
||||
<span className="ml-auto text-[10px] text-text-muted italic">
|
||||
{enabled ? t("packages.statusEnabled") : t("packages.statusDisabled")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ interface Props {
|
||||
enabledPackages: string[];
|
||||
conditions?: Array<{ type: string; status: string; reason?: string }>;
|
||||
onRefresh?: () => void;
|
||||
/** Slice 5: when false, package toggles and edit affordances are hidden. */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
@@ -30,7 +32,13 @@ function getPackageStatus(
|
||||
return "error";
|
||||
}
|
||||
|
||||
export function PackageList({ tenantName, enabledPackages, conditions, onRefresh }: Props) {
|
||||
export function PackageList({
|
||||
tenantName,
|
||||
enabledPackages,
|
||||
conditions,
|
||||
onRefresh,
|
||||
canEdit = true,
|
||||
}: Props) {
|
||||
const t = useTranslations("packages");
|
||||
const router = useRouter();
|
||||
const handleRefresh = onRefresh || (() => router.refresh());
|
||||
@@ -55,6 +63,7 @@ export function PackageList({ tenantName, enabledPackages, conditions, onRefresh
|
||||
status={getPackageStatus(pkg.id, enabledPackages.includes(pkg.id), conditions)}
|
||||
tenantName={tenantName}
|
||||
onToggled={handleRefresh}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,11 @@ const FILE_TABS = ["SOUL.md", "AGENTS.md", "TOOLS.md"] as const;
|
||||
interface Props {
|
||||
tenantName: string;
|
||||
files: Record<string, string>;
|
||||
/** Slice 5: when false, save button hidden and textarea is read-only. */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
export function WorkspaceEditor({ tenantName, files }: Props) {
|
||||
export function WorkspaceEditor({ tenantName, files, canEdit = true }: Props) {
|
||||
const t = useTranslations("workspace");
|
||||
const [activeTab, setActiveTab] = useState<string>("SOUL.md");
|
||||
const [localFiles, setLocalFiles] = useState<Record<string, string>>(files);
|
||||
@@ -19,6 +21,7 @@ export function WorkspaceEditor({ tenantName, files }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleChange(content: string) {
|
||||
if (!canEdit) return;
|
||||
setLocalFiles((prev) => ({ ...prev, [activeTab]: content }));
|
||||
setDirty(true);
|
||||
}
|
||||
@@ -62,20 +65,25 @@ export function WorkspaceEditor({ tenantName, files }: Props) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="rounded-lg bg-accent px-3 py-1 text-xs font-medium text-surface-0 hover:bg-accent-dim disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
{saving ? "…" : t("save")}
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className="rounded-lg bg-accent px-3 py-1 text-xs font-medium text-surface-0 hover:bg-accent-dim disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
{saving ? "…" : t("save")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={localFiles[activeTab] || ""}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
readOnly={!canEdit}
|
||||
spellCheck={false}
|
||||
className="w-full min-h-[300px] resize-y bg-transparent p-4 font-mono text-sm text-text-secondary placeholder:text-text-muted focus:outline-none"
|
||||
className={`w-full min-h-[300px] resize-y bg-transparent p-4 font-mono text-sm text-text-secondary placeholder:text-text-muted focus:outline-none ${
|
||||
!canEdit ? "cursor-default" : ""
|
||||
}`}
|
||||
placeholder={t("placeholder", { file: activeTab })}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import NextAuth from "next-auth";
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
import type { PlatformRole, SessionUser, ZitadelClaims } from "@/types";
|
||||
import type { PlatformRole, Role, SessionUser, ZitadelClaims } from "@/types";
|
||||
|
||||
const PLATFORM_ROLES: PlatformRole[] = ["platform_admin", "platform_operator"];
|
||||
|
||||
/**
|
||||
* Pull the role keys from the ZITADEL `urn:zitadel:iam:org:project:roles`
|
||||
* claim. The claim is shaped as { roleKey: { orgId: orgName } } — we only
|
||||
* need the keys.
|
||||
*
|
||||
* Slice 5: returns Role[] (the union) rather than PlatformRole[]. The
|
||||
* keys can be either platform or customer roles depending on what the
|
||||
* project authorization granted; the SessionUser carries them all and
|
||||
* downstream helpers (canMutate, isCustomerOwner, requirePlatformRole)
|
||||
* decide what each subset means.
|
||||
*/
|
||||
function extractRoles(
|
||||
rolesObj?: Record<string, Record<string, string>>
|
||||
): PlatformRole[] {
|
||||
): Role[] {
|
||||
if (!rolesObj) return [];
|
||||
return Object.keys(rolesObj) as PlatformRole[];
|
||||
return Object.keys(rolesObj) as Role[];
|
||||
}
|
||||
|
||||
export const authConfig: NextAuthConfig = {
|
||||
@@ -50,7 +61,7 @@ export const authConfig: NextAuthConfig = {
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
const roles = (token.roles as PlatformRole[]) ?? [];
|
||||
const roles = (token.roles as Role[]) ?? [];
|
||||
const sessionUser: SessionUser = {
|
||||
id: token.sub!,
|
||||
name: session.user?.name ?? "",
|
||||
@@ -58,7 +69,9 @@ export const authConfig: NextAuthConfig = {
|
||||
orgId: token.orgId as string,
|
||||
orgName: token.orgName as string,
|
||||
roles,
|
||||
isPlatform: roles.some((r) => PLATFORM_ROLES.includes(r)),
|
||||
isPlatform: roles.some((r) =>
|
||||
PLATFORM_ROLES.includes(r as PlatformRole)
|
||||
),
|
||||
};
|
||||
(session as any).platformUser = sessionUser;
|
||||
return session;
|
||||
|
||||
@@ -1,19 +1,87 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import type { SessionUser } from "@/types";
|
||||
|
||||
/**
|
||||
* Read-only session lookup. Returns the SessionUser stashed on the
|
||||
* NextAuth session by `auth.ts::callbacks.session`, or null if there
|
||||
* is no authenticated session.
|
||||
*/
|
||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
const session = await auth();
|
||||
return (session as any)?.platformUser ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws if there is no authenticated session. Otherwise returns the
|
||||
* SessionUser. Use at the top of any handler that requires a logged-in
|
||||
* user regardless of role.
|
||||
*/
|
||||
export async function requireSession(): Promise<SessionUser> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws unless the caller has a platform-level role
|
||||
* (platform_admin or platform_operator). Use to gate /api/admin/*
|
||||
* routes — these handle ANY customer's org and must not be accessible
|
||||
* to customer-role users.
|
||||
*/
|
||||
export async function requirePlatformRole(): Promise<SessionUser> {
|
||||
const user = await requireSession();
|
||||
if (!user.isPlatform) throw new Error("Forbidden: platform role required");
|
||||
return user;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slice 5: role predicates and gates
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Naming convention: `is*` are pure predicates over a SessionUser,
|
||||
// safe to call inline in JSX/server components. `require*` throw on
|
||||
// failure and are meant for the top of route handlers.
|
||||
|
||||
/**
|
||||
* True when the user is a platform admin/operator OR holds the
|
||||
* `owner` customer role on their org.
|
||||
*
|
||||
* This is the single check for "can mutate". Platform users always
|
||||
* win because they administer all orgs cross-cut. Customer-side, only
|
||||
* `owner` may mutate; `user` (and any future read-only customer role)
|
||||
* cannot.
|
||||
*/
|
||||
export function canMutate(user: SessionUser): boolean {
|
||||
return user.isPlatform || user.roles.includes("owner");
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user holds the customer `owner` role on their org.
|
||||
* Excludes platform users — use {@link canMutate} when both should
|
||||
* be allowed.
|
||||
*
|
||||
* Useful for permissions that are specifically about "this customer's
|
||||
* own owner", e.g. "owner can invite users into their own org" — a
|
||||
* platform user shouldn't be casually inviting users into a customer
|
||||
* org, that's an admin-console action and goes through different
|
||||
* tooling.
|
||||
*/
|
||||
export function isCustomerOwner(user: SessionUser): boolean {
|
||||
return !user.isPlatform && user.roles.includes("owner");
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws unless `canMutate(user) === true`. Use at the top of any
|
||||
* mutating customer-side handler.
|
||||
*
|
||||
* The thrown error message is intentionally generic — handlers
|
||||
* should catch and translate to a 403 JSON response so the client
|
||||
* doesn't see a stack trace.
|
||||
*/
|
||||
export async function requireOwnerRole(): Promise<SessionUser> {
|
||||
const user = await requireSession();
|
||||
if (!canMutate(user)) {
|
||||
throw new Error("Forbidden: owner role required");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
"instances": "Ihre Instanzen",
|
||||
"inflightRequests": "Laufende Anfragen",
|
||||
"createInstance": "Neue Instanz erstellen",
|
||||
"createInstanceDescription": "Eine weitere KI-Assistent-Instanz für Ihre Organisation bereitstellen. Die Anfrage wird von einem Administrator geprüft, bevor die Instanz erstellt wird."
|
||||
"createInstanceDescription": "Eine weitere KI-Assistent-Instanz für Ihre Organisation bereitstellen. Die Anfrage wird von einem Administrator geprüft, bevor die Instanz erstellt wird.",
|
||||
"noAccessNoInstances": "Ihre Organisation hat noch keine Instanzen. Bitte bitten Sie den Eigentümer der Organisation, eine einzurichten."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
@@ -179,7 +180,9 @@
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion."
|
||||
}
|
||||
},
|
||||
"statusEnabled": "aktiviert",
|
||||
"statusDisabled": "deaktiviert"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Plattform-Admin",
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
"instances": "Your instances",
|
||||
"inflightRequests": "In-flight requests",
|
||||
"createInstance": "Create new instance",
|
||||
"createInstanceDescription": "Provision an additional AI assistant instance for your organization. The request will be reviewed by an administrator before the instance is created."
|
||||
"createInstanceDescription": "Provision an additional AI assistant instance for your organization. The request will be reviewed by an administrator before the instance is created.",
|
||||
"noAccessNoInstances": "Your organization doesn't have any instances yet. Please ask the organization owner to set one up."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
@@ -179,7 +180,9 @@
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Enable document parsing, summarization, and extraction."
|
||||
}
|
||||
},
|
||||
"statusEnabled": "enabled",
|
||||
"statusDisabled": "disabled"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Platform Admin",
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
"instances": "Vos instances",
|
||||
"inflightRequests": "Demandes en cours",
|
||||
"createInstance": "Créer une nouvelle instance",
|
||||
"createInstanceDescription": "Provisionner une instance supplémentaire d'assistant IA pour votre organisation. La demande sera examinée par un administrateur avant la création de l'instance."
|
||||
"createInstanceDescription": "Provisionner une instance supplémentaire d'assistant IA pour votre organisation. La demande sera examinée par un administrateur avant la création de l'instance.",
|
||||
"noAccessNoInstances": "Votre organisation n'a pas encore d'instances. Demandez au propriétaire de l'organisation d'en configurer une."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
@@ -179,7 +180,9 @@
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Activez l'analyse, le résumé et l'extraction de documents."
|
||||
}
|
||||
},
|
||||
"statusEnabled": "activé",
|
||||
"statusDisabled": "désactivé"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin plateforme",
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
"instances": "Le tue istanze",
|
||||
"inflightRequests": "Richieste in corso",
|
||||
"createInstance": "Crea nuova istanza",
|
||||
"createInstanceDescription": "Effettua il provisioning di un'ulteriore istanza dell'assistente IA per la tua organizzazione. La richiesta sarà esaminata da un amministratore prima della creazione dell'istanza."
|
||||
"createInstanceDescription": "Effettua il provisioning di un'ulteriore istanza dell'assistente IA per la tua organizzazione. La richiesta sarà esaminata da un amministratore prima della creazione dell'istanza.",
|
||||
"noAccessNoInstances": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario dell'organizzazione di configurarne una."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agente",
|
||||
@@ -179,7 +180,9 @@
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Attiva l'analisi, il riassunto e l'estrazione di documenti."
|
||||
}
|
||||
},
|
||||
"statusEnabled": "abilitato",
|
||||
"statusDisabled": "disabilitato"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin piattaforma",
|
||||
|
||||
@@ -5,12 +5,39 @@ export interface ZitadelClaims {
|
||||
"urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
export type PlatformRole =
|
||||
| "platform_admin"
|
||||
| "platform_operator"
|
||||
| "owner"
|
||||
| "user"
|
||||
| "viewer";
|
||||
/**
|
||||
* Platform-level roles, granted to PieCed staff only. Hold the IAM-level
|
||||
* authority to administer the entire installation regardless of which
|
||||
* customer org a request lands on.
|
||||
*/
|
||||
export type PlatformRole = "platform_admin" | "platform_operator";
|
||||
|
||||
/**
|
||||
* Customer-level roles, granted by ZITADEL project authorizations on
|
||||
* each customer org's "OpenClaw Platform" project grant.
|
||||
*
|
||||
* Slice 5 dropped the previously-defined `viewer` role. With the portal
|
||||
* acting purely as a control plane (the assistant itself runs at
|
||||
* separate URLs with their own auth), `user` and `viewer` collapsed
|
||||
* to the same surface — read-only access to instance state and usage.
|
||||
*
|
||||
* - `owner` can mutate (packages, workspace files, channel users,
|
||||
* instance creation, member invites in Slice 7).
|
||||
* - `user` is read-only in the portal. From Slice 6 onwards `user`
|
||||
* visibility is also narrowed to assigned tenants only.
|
||||
*/
|
||||
export type CustomerRole = "owner" | "user";
|
||||
|
||||
/** Union of all roles a JWT can carry. */
|
||||
export type Role = PlatformRole | CustomerRole;
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link Role} for the union, or {@link PlatformRole}
|
||||
* / {@link CustomerRole} when you mean a specific subset.
|
||||
* Kept as a re-export only so existing imports don't
|
||||
* explode in mid-migration commits.
|
||||
*/
|
||||
export type LegacyPlatformRole = Role;
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
@@ -18,7 +45,7 @@ export interface SessionUser {
|
||||
email: string;
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
roles: PlatformRole[];
|
||||
roles: Role[];
|
||||
isPlatform: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user