Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22fd5fb2cc | |||
| 7c4e20099d |
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);
|
||||
120
scripts/verify-visibility.mjs
Normal file
120
scripts/verify-visibility.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
// Standalone JS port of `lib/visibility.ts` for offline verification.
|
||||
// Mirrors the synchronous decision logic — DB call (assignments) is
|
||||
// faked as an array param.
|
||||
|
||||
function scopeFor(user) {
|
||||
if (user.isPlatform) return "all";
|
||||
if (user.roles.includes("owner")) return "org";
|
||||
return "assigned";
|
||||
}
|
||||
|
||||
function listVisibleTenants(user, all, assignments = []) {
|
||||
const scope = scopeFor(user);
|
||||
if (scope === "all") return all;
|
||||
|
||||
const orgScoped = all.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
if (scope === "org") return orgScoped;
|
||||
|
||||
const allowed = new Set(assignments);
|
||||
return orgScoped.filter((t) => allowed.has(t.metadata.name));
|
||||
}
|
||||
|
||||
function canUserSeeTenant(user, tenant, assignments = []) {
|
||||
const scope = scopeFor(user);
|
||||
if (scope === "all") return true;
|
||||
if (tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId) {
|
||||
return false;
|
||||
}
|
||||
if (scope === "org") return true;
|
||||
return assignments.includes(tenant.metadata.name);
|
||||
}
|
||||
|
||||
function canSeeInflightRequests(user) {
|
||||
return scopeFor(user) !== "assigned";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const platformAdmin = { isPlatform: true, roles: ["platform_admin"], orgId: "platform-org", id: "u-admin" };
|
||||
const owner = { isPlatform: false, roles: ["owner"], orgId: "org-acme", id: "u-owner" };
|
||||
const userOnly = { isPlatform: false, roles: ["user"], orgId: "org-acme", id: "u-alice" };
|
||||
const noRoles = { isPlatform: false, roles: [], orgId: "org-acme", id: "u-bob" };
|
||||
|
||||
const tenantA = { metadata: { name: "acme-prod-12345678", labels: { "pieced.ch/zitadel-org-id": "org-acme" } } };
|
||||
const tenantB = { metadata: { name: "acme-dev-87654321", labels: { "pieced.ch/zitadel-org-id": "org-acme" } } };
|
||||
const tenantC = { metadata: { name: "other-corp-aaaa", labels: { "pieced.ch/zitadel-org-id": "org-other" } } };
|
||||
|
||||
const allTenants = [tenantA, tenantB, tenantC];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listVisibleTenants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const listCases = [
|
||||
{ user: platformAdmin, assignments: [], expected: ["acme-prod-12345678", "acme-dev-87654321", "other-corp-aaaa"], note: "platform sees all" },
|
||||
{ user: owner, assignments: [], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "owner sees all org tenants" },
|
||||
{ user: owner, assignments: ["acme-prod-12345678"], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "owner ignores assignment table even if rows exist" },
|
||||
{ user: userOnly, assignments: [], expected: [], note: "user with no assignments sees nothing" },
|
||||
{ user: userOnly, assignments: ["acme-prod-12345678"], expected: ["acme-prod-12345678"], note: "user sees only assigned tenants" },
|
||||
{ user: userOnly, assignments: ["acme-prod-12345678", "acme-dev-87654321"], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "user sees multiple assigned tenants" },
|
||||
{ user: userOnly, assignments: ["other-corp-aaaa"], expected: [], note: "stale assignment to other-org tenant doesn't leak" },
|
||||
{ user: noRoles, assignments: [], expected: [], note: "no roles is treated as user-scope (empty)" },
|
||||
];
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
|
||||
console.log("--- listVisibleTenants ---");
|
||||
for (const c of listCases) {
|
||||
const got = listVisibleTenants(c.user, allTenants, c.assignments).map((t) => t.metadata.name);
|
||||
const ok = JSON.stringify(got) === JSON.stringify(c.expected);
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${JSON.stringify(got)} want=${JSON.stringify(c.expected)} [${c.note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// canUserSeeTenant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n--- canUserSeeTenant ---");
|
||||
const seeCases = [
|
||||
{ user: platformAdmin, tenant: tenantA, assignments: [], expected: true, note: "platform sees same-cluster tenant" },
|
||||
{ user: platformAdmin, tenant: tenantC, assignments: [], expected: true, note: "platform sees other-org tenant" },
|
||||
{ user: owner, tenant: tenantA, assignments: [], expected: true, note: "owner sees own-org tenant" },
|
||||
{ user: owner, tenant: tenantC, assignments: [], expected: false, note: "owner does NOT see other-org tenant" },
|
||||
{ user: userOnly, tenant: tenantA, assignments: ["acme-prod-12345678"], expected: true, note: "user sees assigned tenant" },
|
||||
{ user: userOnly, tenant: tenantA, assignments: [], expected: false, note: "user does NOT see un-assigned own-org tenant" },
|
||||
{ user: userOnly, tenant: tenantC, assignments: ["other-corp-aaaa"], expected: false, note: "user does NOT see other-org tenant even with stale assignment" },
|
||||
];
|
||||
|
||||
for (const c of seeCases) {
|
||||
const got = canUserSeeTenant(c.user, c.tenant, c.assignments);
|
||||
const ok = got === c.expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${c.expected} [${c.note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// canSeeInflightRequests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n--- canSeeInflightRequests ---");
|
||||
const requestCases = [
|
||||
{ user: platformAdmin, expected: true, note: "platform sees in-flight" },
|
||||
{ user: owner, expected: true, note: "owner sees in-flight" },
|
||||
{ user: userOnly, expected: false, note: "user-role does NOT see in-flight" },
|
||||
{ user: noRoles, expected: false, note: "no-roles does NOT see in-flight" },
|
||||
];
|
||||
|
||||
for (const c of requestCases) {
|
||||
const got = canSeeInflightRequests(c.user);
|
||||
const ok = got === c.expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${c.expected} [${c.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,8 +1,13 @@
|
||||
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";
|
||||
import { listActiveTenantRequestsByOrgId } from "@/lib/db";
|
||||
import {
|
||||
listVisibleTenants,
|
||||
canSeeInflightRequests,
|
||||
isUserScoped,
|
||||
} from "@/lib/visibility";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
@@ -134,23 +139,117 @@ export default async function DashboardPage() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Customer view (Slice 3 multi-tenant)
|
||||
// Customer view (Slice 3 multi-tenant + Slice 6 visibility scoping)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const orgTenants = allTenants.filter(
|
||||
// Slice 6: orgTenants becomes "visible tenants for this user". For an
|
||||
// owner that's all of the org's tenants; for a `user`-role member
|
||||
// it's only the tenants they've been assigned to via
|
||||
// tenant_user_assignments. The dashboard renders fewer cards in the
|
||||
// user-role case but otherwise uses the same template.
|
||||
const orgTenants = await listVisibleTenants(user, allTenants);
|
||||
|
||||
// For the "no instances yet" empty state, we want to know whether
|
||||
// this user is being scoped down. A `user`-role with 0 visible
|
||||
// tenants gets a different message than an owner with 0 tenants
|
||||
// (the user might just need an assignment; the owner needs to
|
||||
// create one).
|
||||
const userScoped = isUserScoped(user);
|
||||
|
||||
// Pending/in-flight requests are only shown to roles that can act on
|
||||
// them. `user`-role customers see no request cards.
|
||||
const orgRequests = canSeeInflightRequests(user)
|
||||
? await listActiveTenantRequestsByOrgId(user.orgId)
|
||||
: [];
|
||||
|
||||
// Pending requests that don't yet have a tenant CR. Once the CR
|
||||
// exists, the tenant card carries the live phase, so a separate
|
||||
// "request" card would just duplicate it. We compare against
|
||||
// *all* org tenants here (not just visible ones) — otherwise a
|
||||
// request whose tenant is invisible to the caller would erroneously
|
||||
// show as in-flight.
|
||||
const orgScopedTenants = allTenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
const orgRequests = await listActiveTenantRequestsByOrgId(user.orgId);
|
||||
|
||||
// Pending/in-flight requests that don't yet have a tenant CR. Once the
|
||||
// CR exists, the tenant card carries the live phase, so a separate
|
||||
// "request" card would just duplicate it.
|
||||
const inflightRequests = orgRequests.filter(
|
||||
(r) => !r.tenantName || !orgTenants.some((t) => t.metadata.name === r.tenantName)
|
||||
(r) => !r.tenantName || !orgScopedTenants.some((t) => t.metadata.name === r.tenantName)
|
||||
);
|
||||
|
||||
// First-time user: empty company. Show the onboarding wizard inline.
|
||||
// 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 / no-visibility branch.
|
||||
//
|
||||
// Three sub-cases:
|
||||
// 1. owner / platform with 0 tenants and 0 requests → show wizard.
|
||||
// 2. owner / platform with 0 visibility but the org HAS tenants →
|
||||
// shouldn't happen (owners see all org tenants). Defensive
|
||||
// fall-through to the wizard.
|
||||
// 3. user-role with 0 visible tenants → show "ask your owner"
|
||||
// message, with copy distinguishing whether the org has any
|
||||
// tenants at all.
|
||||
if (orgTenants.length === 0 && inflightRequests.length === 0) {
|
||||
if (userScoped) {
|
||||
// Slice 6 empty state for `user` role. The org might or might
|
||||
// not have tenants — either way this user has none assigned.
|
||||
// The two messages are subtly different: "no instances exist"
|
||||
// means owner needs to create one; "you're not assigned" means
|
||||
// owner needs to grant access.
|
||||
const orgHasTenants = orgScopedTenants.length > 0;
|
||||
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">
|
||||
<div className="text-center py-6">
|
||||
<h2 className="font-display text-base font-semibold text-text-primary mb-2">
|
||||
{orgHasTenants
|
||||
? t("noAssignmentsTitle")
|
||||
: t("noInstancesYetTitle")}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary max-w-sm mx-auto">
|
||||
{orgHasTenants
|
||||
? t("noAssignmentsDescription")
|
||||
: t("noInstancesYetDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canCreate) {
|
||||
// Belt-and-braces: any role that's neither owner-with-create nor
|
||||
// user-scope ends up here (e.g. weird cases like a session with
|
||||
// no roles at all). Same generic message as before.
|
||||
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 +269,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 +282,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,7 +1,8 @@
|
||||
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";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||
import { PackageList } from "@/components/packages/package-list";
|
||||
@@ -26,14 +27,18 @@ export default async function TenantDetailPage({
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) notFound();
|
||||
|
||||
// Scope check
|
||||
if (
|
||||
!user.isPlatform &&
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||
) {
|
||||
// Slice 6: visibility check encompasses org membership AND, for
|
||||
// user-role members, the tenant_user_assignments check. notFound()
|
||||
// (404) rather than redirect/403 to avoid leaking tenant existence.
|
||||
if (!(await canUserSeeTenant(user, tenant))) {
|
||||
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,13 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getTenant, deleteTenant } from "@/lib/k8s";
|
||||
import { markTenantRequestDeletedByTenantName } from "@/lib/db";
|
||||
import {
|
||||
markTenantRequestDeletedByTenantName,
|
||||
removeAllAssignmentsForTenant,
|
||||
} from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/tenants/[name]/delete
|
||||
* Delete a PiecedTenant CR. The operator handles cleanup
|
||||
* (namespace, vault, litellm team, etc.).
|
||||
*
|
||||
* Slice 6: also cascades the tenant_user_assignments rows so a
|
||||
* future tenant with the same name (won't happen given UUID-suffix
|
||||
* naming, but defense in depth) doesn't inherit stale assignments.
|
||||
*
|
||||
* Also marks the associated tenant_request as "deleted" so the
|
||||
* customer can re-submit the onboarding wizard.
|
||||
*/
|
||||
@@ -31,10 +39,14 @@ export async function POST(
|
||||
try {
|
||||
await deleteTenant(name);
|
||||
|
||||
// Mark the associated tenant_request as "deleted" so the customer
|
||||
// sees the wizard again instead of a stale "active" status
|
||||
// Best-effort DB cleanups. Both errors are logged but not surfaced —
|
||||
// the K8s deletion has already started, and the row state is just
|
||||
// for portal display.
|
||||
await markTenantRequestDeletedByTenantName(name).catch((e) =>
|
||||
console.error("Failed to update tenant request after delete:", e)
|
||||
console.error("Failed to mark tenant request deleted:", e)
|
||||
);
|
||||
await removeAllAssignmentsForTenant(name).catch((e) =>
|
||||
console.error("Failed to clean up tenant assignments:", e)
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import {
|
||||
createTenantRequest,
|
||||
getTenantRequestById,
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
getMostRecentApprovedRequestForOrg,
|
||||
} from "@/lib/db";
|
||||
import { getTenant, listTenants } from "@/lib/k8s";
|
||||
import {
|
||||
listVisibleTenants,
|
||||
canUserSeeTenant,
|
||||
canSeeInflightRequests,
|
||||
} from "@/lib/visibility";
|
||||
import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { encryptSecrets } from "@/lib/crypto";
|
||||
import { isPersonalOrgName } from "@/lib/personal-org";
|
||||
@@ -106,10 +111,24 @@ export async function GET(req: NextRequest) {
|
||||
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
// Slice 6: a `user`-role customer doesn't see in-flight requests
|
||||
// even within their own org — they can't act on them and showing
|
||||
// the row would be a permanent "pending" state with no exit. Owner
|
||||
// and platform skip this gate.
|
||||
if (!canSeeInflightRequests(user)) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let tenant: PiecedTenant | null = null;
|
||||
if (tr.tenantName) {
|
||||
tenant = (await getTenant(tr.tenantName)) ?? null;
|
||||
// If a request is already linked to a tenant CR and the caller
|
||||
// can't see that tenant (assignment scope), don't expose it via
|
||||
// the request endpoint either. canSeeInflightRequests above
|
||||
// already shortcuts this for `user`-role, but defense in depth.
|
||||
if (tenant && !(await canUserSeeTenant(user, tenant))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
request: publicRequestShape(tr),
|
||||
@@ -117,19 +136,21 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// List view: requests + tenants for this org
|
||||
// List view: requests + tenants for this org, filtered by visibility.
|
||||
// For owner/platform, this returns the same data as pre-Slice-6.
|
||||
// For user-role, requests is forced to [] and tenants is narrowed to
|
||||
// assignments.
|
||||
const [requests, allTenants] = await Promise.all([
|
||||
listActiveTenantRequestsByOrgId(user.orgId),
|
||||
listTenants(),
|
||||
]);
|
||||
|
||||
const orgTenants = allTenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
const visibleTenants = await listVisibleTenants(user, allTenants);
|
||||
const visibleRequests = canSeeInflightRequests(user) ? requests : [];
|
||||
|
||||
return NextResponse.json({
|
||||
requests: requests.map(publicRequestShape),
|
||||
tenants: orgTenants.map(publicTenantShape),
|
||||
requests: visibleRequests.map(publicRequestShape),
|
||||
tenants: visibleTenants.map(publicTenantShape),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,6 +178,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,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { safeError } from "@/lib/errors";
|
||||
@@ -22,11 +23,11 @@ export async function GET(
|
||||
if (!tenant)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
if (
|
||||
!user.isPlatform &&
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||
) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
// Slice 6: visibility now includes assignment-table check for
|
||||
// user-role members. We return 404 (not 403) to avoid leaking
|
||||
// tenant existence — same as cross-org reads.
|
||||
if (!(await canUserSeeTenant(user, tenant))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(tenant);
|
||||
@@ -46,7 +47,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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listVisibleTenants } from "@/lib/visibility";
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const tenants = await listTenants();
|
||||
|
||||
if (user.isPlatform) {
|
||||
return NextResponse.json(tenants);
|
||||
}
|
||||
|
||||
// Customers see only their own tenant
|
||||
const own = tenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
return NextResponse.json(own);
|
||||
const all = await listTenants();
|
||||
const visible = await listVisibleTenants(user, all);
|
||||
return NextResponse.json(visible);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listVisibleTenants } from "@/lib/visibility";
|
||||
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
@@ -36,12 +37,17 @@ export async function GET(req: NextRequest) {
|
||||
keyAlias = req.nextUrl.searchParams.get("keyAlias") ?? null;
|
||||
}
|
||||
|
||||
// For customers (or admins without explicit params): resolve from their tenant.
|
||||
// For customers (or admins without explicit params): resolve from
|
||||
// the user's *visible* tenants. With Slice 6, a `user`-role member
|
||||
// can only see usage for tenants they're assigned to — a non-assigned
|
||||
// user defaults to "no active tenant" (404).
|
||||
//
|
||||
// Owner and platform get the full org-scoped list and pick the first
|
||||
// tenant, matching the dashboard's "current instance" semantics.
|
||||
if (!teamId) {
|
||||
const tenants = await listTenants();
|
||||
const orgTenant = tenants.find(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
const allTenants = await listTenants();
|
||||
const visible = await listVisibleTenants(user, allTenants);
|
||||
const orgTenant = visible.find((t) => !!t.status?.litellmTeamId);
|
||||
|
||||
if (!orgTenant?.status?.litellmTeamId) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -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;
|
||||
|
||||
180
src/lib/db.ts
180
src/lib/db.ts
@@ -82,6 +82,39 @@ const MIGRATION_SQL = `
|
||||
content TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Slice 6: per-tenant user assignments
|
||||
-- ---------------------------------------------------------------------------
|
||||
--
|
||||
-- Each row grants ONE user visibility into ONE tenant within their own
|
||||
-- ZITADEL org. Used to narrow the customer 'user' role from "everything
|
||||
-- in the org" to "only the tenants I've been assigned to". Owners and
|
||||
-- platform users bypass this table entirely.
|
||||
--
|
||||
-- Composite PK is (tenant_name, zitadel_user_id) — a user is either
|
||||
-- assigned to a tenant or not, no degree.
|
||||
--
|
||||
-- The zitadel_org_id column is denormalised onto every row so cascade
|
||||
-- cleanups when a user leaves an org can be expressed as a single
|
||||
-- DELETE WHERE zitadel_org_id=$1 AND zitadel_user_id=$2 — without
|
||||
-- joining tenant_requests. The assigned_by column tracks which user
|
||||
-- (the owner usually) granted the assignment, for audit.
|
||||
--
|
||||
-- Cascade on tenant deletion is enforced in application code (the
|
||||
-- admin delete handler calls removeAllAssignmentsForTenant) rather
|
||||
-- than via FK — there's no FK target, since K8s CRs aren't a Postgres
|
||||
-- table.
|
||||
CREATE TABLE IF NOT EXISTS tenant_user_assignments (
|
||||
tenant_name TEXT NOT NULL,
|
||||
zitadel_org_id TEXT NOT NULL,
|
||||
zitadel_user_id TEXT NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
assigned_by TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_name, zitadel_user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tua_user ON tenant_user_assignments(zitadel_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tua_org ON tenant_user_assignments(zitadel_org_id);
|
||||
`;
|
||||
|
||||
let migrated = false;
|
||||
@@ -417,3 +450,150 @@ function mapRow(row: any): TenantRequest {
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slice 6: tenant ↔ user assignments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One assignment grants one user visibility into one tenant. Returned
|
||||
* shape is the camelCase mirror of the Postgres row.
|
||||
*/
|
||||
export interface TenantUserAssignment {
|
||||
tenantName: string;
|
||||
zitadelOrgId: string;
|
||||
zitadelUserId: string;
|
||||
assignedAt: string;
|
||||
assignedBy: string;
|
||||
}
|
||||
|
||||
function mapAssignmentRow(row: any): TenantUserAssignment {
|
||||
return {
|
||||
tenantName: row.tenant_name,
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
zitadelUserId: row.zitadel_user_id,
|
||||
assignedAt: row.assigned_at?.toISOString?.() ?? row.assigned_at,
|
||||
assignedBy: row.assigned_by,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of tenant CR names assigned to the given user.
|
||||
*
|
||||
* Hot path on every read for `user`-role customers, so it's intentionally
|
||||
* a single indexed lookup. The returned array is small (a handful of
|
||||
* tenants per user); callers usually wrap it in a Set.
|
||||
*
|
||||
* Note: this does NOT cross-check the org id — assignments are per-user,
|
||||
* and a user's org context comes from their JWT. If a user's
|
||||
* authorization is revoked at the ZITADEL level, their JWT ceases to
|
||||
* carry the customer role and they can't reach the dashboard at all;
|
||||
* the orphan rows are cleaned up the next time their org membership
|
||||
* is re-evaluated (Slice 7's removeAllAssignmentsForUser).
|
||||
*/
|
||||
export async function listTenantAssignmentsForUser(
|
||||
userId: string
|
||||
): Promise<string[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<{ tenant_name: string }>(
|
||||
"SELECT tenant_name FROM tenant_user_assignments WHERE zitadel_user_id = $1",
|
||||
[userId]
|
||||
);
|
||||
return result.rows.map((r) => r.tenant_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all assignments for a single tenant. Used by the team UI
|
||||
* (Slice 7) to render "who has access to this instance". Includes
|
||||
* `assignedBy` and `assignedAt` for audit display.
|
||||
*/
|
||||
export async function listAssignmentsForTenant(
|
||||
tenantName: string
|
||||
): Promise<TenantUserAssignment[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT * FROM tenant_user_assignments WHERE tenant_name = $1 ORDER BY assigned_at DESC",
|
||||
[tenantName]
|
||||
);
|
||||
return result.rows.map(mapAssignmentRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a user access to a tenant. Idempotent — a duplicate INSERT
|
||||
* is silently ignored via ON CONFLICT, and the existing
|
||||
* `assigned_at`/`assigned_by` are preserved (we don't update them on
|
||||
* re-assign).
|
||||
*
|
||||
* Caller is responsible for verifying:
|
||||
* - The actor (`assignedBy`) holds owner/platform role in `orgId`.
|
||||
* - The target user (`userId`) is actually a member of the same
|
||||
* ZITADEL org. We don't validate this here — the team UI fetches
|
||||
* the org's user list from ZITADEL and selects from it.
|
||||
* - The tenant CR exists and is labelled with the same `orgId`.
|
||||
*/
|
||||
export async function addTenantAssignment(params: {
|
||||
tenantName: string;
|
||||
orgId: string;
|
||||
userId: string;
|
||||
assignedBy: string;
|
||||
}): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
`INSERT INTO tenant_user_assignments
|
||||
(tenant_name, zitadel_org_id, zitadel_user_id, assigned_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (tenant_name, zitadel_user_id) DO NOTHING`,
|
||||
[params.tenantName, params.orgId, params.userId, params.assignedBy]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a user's access to a tenant. No-op if the row doesn't exist.
|
||||
*/
|
||||
export async function removeTenantAssignment(
|
||||
tenantName: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
"DELETE FROM tenant_user_assignments WHERE tenant_name = $1 AND zitadel_user_id = $2",
|
||||
[tenantName, userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cascade cleanup: drop ALL assignments for a tenant when the tenant
|
||||
* itself is deleted. Called from the admin delete handler.
|
||||
*
|
||||
* Without this, an orphan row would stick around forever — a future
|
||||
* tenant with the same name (won't happen given Slice 1's UUID-suffix
|
||||
* naming, but defense in depth) would inherit the old assignments.
|
||||
*/
|
||||
export async function removeAllAssignmentsForTenant(
|
||||
tenantName: string
|
||||
): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
"DELETE FROM tenant_user_assignments WHERE tenant_name = $1",
|
||||
[tenantName]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cascade cleanup: drop ALL assignments for a user within a specific
|
||||
* org. Used by Slice 7's "remove member" flow when an owner kicks a
|
||||
* user out of the org. Scoped by `orgId` so a user with assignments in
|
||||
* org A doesn't lose them when removed from org B (multi-org users
|
||||
* exist when a person registers personally and is also invited to a
|
||||
* company).
|
||||
*/
|
||||
export async function removeAllAssignmentsForUser(
|
||||
orgId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
"DELETE FROM tenant_user_assignments WHERE zitadel_org_id = $1 AND zitadel_user_id = $2",
|
||||
[orgId, userId]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
127
src/lib/visibility.ts
Normal file
127
src/lib/visibility.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tenant visibility scoping for the customer-facing portal.
|
||||
*
|
||||
* Centralised here so every endpoint that lists or fetches tenants
|
||||
* agrees on the same rules. A bug in any one of those — say, a stale
|
||||
* inline filter that returned org-wide results to a `user`-role member
|
||||
* — would leak siblings' workspace files and channel-user lists.
|
||||
* One source of truth makes the audit easy.
|
||||
*
|
||||
* Visibility model
|
||||
* ----------------
|
||||
* platform_admin / platform_operator → all tenants in the cluster.
|
||||
* owner (customer) → all tenants in their own org.
|
||||
* user (customer, no owner role) → only tenants they've been
|
||||
* assigned to via the
|
||||
* tenant_user_assignments table.
|
||||
*
|
||||
* The narrowing for `user` is what turns the customer role into a
|
||||
* meaningful access boundary. Without it, every member of an org
|
||||
* would see every tenant — fine for a one-team SaaS, broken for a
|
||||
* company with separate Production / Staging / Sales instances where
|
||||
* the Sales team shouldn't see the Production workspace files.
|
||||
*
|
||||
* Owners do NOT get filtered against the assignment table even if
|
||||
* they happen to have rows in it. The owner role beats user-level
|
||||
* scoping — that's the point of being an owner.
|
||||
*/
|
||||
|
||||
import type { SessionUser, PiecedTenant } from "@/types";
|
||||
import { listTenantAssignmentsForUser } from "./db";
|
||||
|
||||
/** Internal classifier — "what's this caller's visibility scope?". */
|
||||
type Scope = "all" | "org" | "assigned";
|
||||
|
||||
function scopeFor(user: SessionUser): Scope {
|
||||
if (user.isPlatform) return "all";
|
||||
if (user.roles.includes("owner")) return "org";
|
||||
return "assigned";
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a list of tenants down to what `user` is allowed to see.
|
||||
*
|
||||
* Performs at most one DB query (only when scope is "assigned") and
|
||||
* runs the K8s-side filter in memory. The K8s list is already small
|
||||
* (≤100 tenants at pilot scale) so this is fine; if it grew we'd
|
||||
* push the filter down to the K8s label selector instead.
|
||||
*/
|
||||
export async function listVisibleTenants(
|
||||
user: SessionUser,
|
||||
all: PiecedTenant[]
|
||||
): Promise<PiecedTenant[]> {
|
||||
const scope = scopeFor(user);
|
||||
|
||||
if (scope === "all") return all;
|
||||
|
||||
const orgScoped = all.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
|
||||
if (scope === "org") return orgScoped;
|
||||
|
||||
// scope === "assigned" — narrow to the user's assignment list
|
||||
const assigned = await listTenantAssignmentsForUser(user.id);
|
||||
if (assigned.length === 0) return [];
|
||||
|
||||
const allowed = new Set(assigned);
|
||||
return orgScoped.filter((t) => allowed.has(t.metadata.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-tenant predicate. Returns true when `user` may see (and read
|
||||
* from) `tenant`. Mutating endpoints additionally need
|
||||
* `canMutate(user)` from `lib/session.ts` — visibility ≠ permission to
|
||||
* change.
|
||||
*
|
||||
* Returns false (rather than throwing) so handlers can map to the
|
||||
* status code that fits their semantics — usually 404 for read paths
|
||||
* (don't leak existence) and 403 for mutation paths (caller already
|
||||
* knew the tenant existed).
|
||||
*/
|
||||
export async function canUserSeeTenant(
|
||||
user: SessionUser,
|
||||
tenant: PiecedTenant
|
||||
): Promise<boolean> {
|
||||
const scope = scopeFor(user);
|
||||
|
||||
if (scope === "all") return true;
|
||||
|
||||
// org scope and assigned scope both require the tenant to belong
|
||||
// to the user's org — different orgs are never visible.
|
||||
if (tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scope === "org") return true;
|
||||
|
||||
// scope === "assigned"
|
||||
const assigned = await listTenantAssignmentsForUser(user.id);
|
||||
return assigned.includes(tenant.metadata.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Should `user` see in-flight tenant requests on the dashboard?"
|
||||
*
|
||||
* Owners and platform users yes (they own the lifecycle); user-role
|
||||
* members no (they can't act on requests, and a request that isn't
|
||||
* yet a tenant has no assignment yet, so showing it would be a
|
||||
* permanent "pending" with no action they can take).
|
||||
*/
|
||||
export function canSeeInflightRequests(user: SessionUser): boolean {
|
||||
return scopeFor(user) !== "assigned";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience predicate used by client-side empty states. For
|
||||
* `user`-role members, the dashboard wants to distinguish between
|
||||
* "your org has no instances" (very rare; ask owner to set one up)
|
||||
* and "your org has instances but you're not assigned to any" (more
|
||||
* common; ask owner to grant access).
|
||||
*
|
||||
* Callers compute this off the difference between visible and
|
||||
* org-wide tenant lists; this helper just reifies the test.
|
||||
*/
|
||||
export function isUserScoped(user: SessionUser): boolean {
|
||||
return scopeFor(user) === "assigned";
|
||||
}
|
||||
@@ -103,7 +103,12 @@
|
||||
"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.",
|
||||
"noAssignmentsTitle": "Keine Instanzen zugewiesen",
|
||||
"noAssignmentsDescription": "Ihre Organisation verfügt über Instanzen, aber Sie haben keinen Zugriff darauf erhalten. Bitten Sie den Eigentümer Ihrer Organisation, Sie einer Instanz zuzuweisen.",
|
||||
"noInstancesYetTitle": "Noch keine Instanzen",
|
||||
"noInstancesYetDescription": "Ihre Organisation verfügt noch über keine Instanzen. Bitten Sie den Eigentümer Ihrer Organisation, eine einzurichten."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
@@ -179,7 +184,9 @@
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion."
|
||||
}
|
||||
},
|
||||
"statusEnabled": "aktiviert",
|
||||
"statusDisabled": "deaktiviert"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Plattform-Admin",
|
||||
|
||||
@@ -103,7 +103,12 @@
|
||||
"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.",
|
||||
"noAssignmentsTitle": "No instances assigned",
|
||||
"noAssignmentsDescription": "Your organization has instances, but you haven't been granted access to any of them. Please ask your organization owner to assign you to an instance.",
|
||||
"noInstancesYetTitle": "No instances yet",
|
||||
"noInstancesYetDescription": "Your organization doesn't have any instances yet. Please ask your organization owner to set one up."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
@@ -179,7 +184,9 @@
|
||||
},
|
||||
"documentProcessing": {
|
||||
"description": "Enable document parsing, summarization, and extraction."
|
||||
}
|
||||
},
|
||||
"statusEnabled": "enabled",
|
||||
"statusDisabled": "disabled"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Platform Admin",
|
||||
|
||||
@@ -103,7 +103,12 @@
|
||||
"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.",
|
||||
"noAssignmentsTitle": "Aucune instance attribuée",
|
||||
"noAssignmentsDescription": "Votre organisation possède des instances, mais aucun accès ne vous a été accordé. Demandez au propriétaire de votre organisation de vous attribuer une instance.",
|
||||
"noInstancesYetTitle": "Pas encore d'instances",
|
||||
"noInstancesYetDescription": "Votre organisation ne possède pas encore d'instances. Demandez au propriétaire de votre organisation d'en configurer une."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
@@ -179,7 +184,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,12 @@
|
||||
"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.",
|
||||
"noAssignmentsTitle": "Nessuna istanza assegnata",
|
||||
"noAssignmentsDescription": "La tua organizzazione ha delle istanze, ma non ti è stato concesso l'accesso a nessuna di esse. Chiedi al proprietario della tua organizzazione di assegnarti a un'istanza.",
|
||||
"noInstancesYetTitle": "Nessuna istanza ancora",
|
||||
"noInstancesYetDescription": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario della tua organizzazione di configurarne una."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agente",
|
||||
@@ -179,7 +184,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