Role split and owner gating
All checks were successful
Build and Push / build (push) Successful in 1m24s
All checks were successful
Build and Push / build (push) Successful in 1m24s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user