fix(auth): align console admin gates and oauth exchange
This commit is contained in:
parent
71b36a628d
commit
d101676c13
@ -78,12 +78,9 @@ export default function LoginContent({
|
||||
const googleAuthUrl = `${accountServiceBaseUrl}/api/auth/oauth/login/google`;
|
||||
|
||||
useEffect(() => {
|
||||
const publicToken = searchParams.get("public_token");
|
||||
const userId = searchParams.get("userId");
|
||||
const email = searchParams.get("email");
|
||||
const role = searchParams.get("role");
|
||||
const exchangeCode = searchParams.get("exchange_code");
|
||||
|
||||
if (!publicToken || !userId || !email) {
|
||||
if (!exchangeCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -101,10 +98,7 @@ export default function LoginContent({
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
publicToken,
|
||||
userId,
|
||||
email,
|
||||
role: role || "user",
|
||||
exchangeCode,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -3,20 +3,18 @@ export const dynamic = 'force-dynamic'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
|
||||
const WRITE_PERMISSIONS = ['admin.settings.write']
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
function isAllowedRootEmail(email?: string): boolean {
|
||||
return email?.trim().toLowerCase() === 'admin@svc.plus'
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getAccountSession(request)
|
||||
const user = session.user
|
||||
@ -25,12 +23,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, REQUIRED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!isAllowedRootEmail(user.email)) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: WRITE_PERMISSIONS,
|
||||
rootOnly: true,
|
||||
})
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const headers = new Headers({
|
||||
|
||||
@ -3,20 +3,18 @@ export const dynamic = 'force-dynamic'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
|
||||
const READ_PERMISSIONS = ['admin.settings.read']
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
function isAllowedRootEmail(email?: string): boolean {
|
||||
return email?.trim().toLowerCase() === 'admin@svc.plus'
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getAccountSession(request)
|
||||
const user = session.user
|
||||
@ -25,12 +23,13 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, REQUIRED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!isAllowedRootEmail(user.email)) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: READ_PERMISSIONS,
|
||||
rootOnly: true,
|
||||
})
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
|
||||
const WRITE_PERMISSIONS = ['admin.users.pause.write']
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, REQUIRED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: WRITE_PERMISSIONS,
|
||||
})
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { userId: userIdParam } = await params
|
||||
|
||||
@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
|
||||
const WRITE_PERMISSIONS = ['admin.users.renew_uuid.write']
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, REQUIRED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: WRITE_PERMISSIONS,
|
||||
})
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { userId: userIdParam } = await params
|
||||
|
||||
@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
|
||||
const WRITE_PERMISSIONS = ['admin.users.resume.write']
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, REQUIRED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: WRITE_PERMISSIONS,
|
||||
})
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { userId: userIdParam } = await params
|
||||
|
||||
@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
|
||||
const WRITE_PERMISSIONS = ['admin.users.role.write']
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
@ -35,8 +37,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, REQUIRED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: WRITE_PERMISSIONS,
|
||||
})
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { userId: userIdParam } = await params
|
||||
@ -78,8 +84,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, REQUIRED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: WRITE_PERMISSIONS,
|
||||
})
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { userId: userIdParam } = await params
|
||||
|
||||
@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin', 'operator']
|
||||
const DELETE_PERMISSIONS = ['admin.users.delete.write']
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
@ -35,8 +37,12 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, REQUIRED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: DELETE_PERMISSIONS,
|
||||
})
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { userId: userIdParam } = await params
|
||||
|
||||
@ -3,11 +3,13 @@ export const dynamic = 'force-dynamic'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
|
||||
const WRITE_PERMISSIONS = ['admin.users.role.write']
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
@ -48,10 +50,6 @@ function normalizeGroups(value: unknown): string[] | null {
|
||||
return Array.from(new Set(result))
|
||||
}
|
||||
|
||||
function isAllowedRootEmail(email?: string): boolean {
|
||||
return email?.trim().toLowerCase() === 'admin@svc.plus'
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getAccountSession(request)
|
||||
const user = session.user
|
||||
@ -60,12 +58,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, REQUIRED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!isAllowedRootEmail(user.email)) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: WRITE_PERMISSIONS,
|
||||
rootOnly: true,
|
||||
})
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as CreateUserBody | null
|
||||
|
||||
@ -7,9 +7,9 @@ const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const payload = await request.json()
|
||||
const { publicToken, userId, email, role } = payload
|
||||
const { exchangeCode } = payload
|
||||
|
||||
if (!publicToken || !userId || !email) {
|
||||
if (!exchangeCode || typeof exchangeCode !== 'string') {
|
||||
return NextResponse.json({ success: false, error: 'invalid_request' }, { status: 400 })
|
||||
}
|
||||
|
||||
@ -19,10 +19,7 @@ export async function POST(request: NextRequest) {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
public_token: publicToken,
|
||||
user_id: userId,
|
||||
email,
|
||||
roles: role,
|
||||
exchange_code: exchangeCode,
|
||||
}),
|
||||
cache: 'no-store',
|
||||
})
|
||||
@ -33,12 +30,20 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const { access_token, expires_in } = data
|
||||
const sessionToken = typeof data.token === 'string' && data.token.trim().length > 0
|
||||
? data.token.trim()
|
||||
: typeof data.access_token === 'string' && data.access_token.trim().length > 0
|
||||
? data.access_token.trim()
|
||||
: ''
|
||||
|
||||
if (!sessionToken) {
|
||||
return NextResponse.json({ success: false, error: 'invalid_response' }, { status: 502 })
|
||||
}
|
||||
|
||||
const result = NextResponse.json({ success: true })
|
||||
// If backend returns expires_in (seconds), use it; otherwise derive from expiresAt if it exists
|
||||
const maxAge = typeof expires_in === 'number' ? expires_in : deriveMaxAgeFromExpires(data.expiresAt)
|
||||
applySessionCookie(result, access_token, maxAge)
|
||||
const maxAge =
|
||||
typeof data.expires_in === 'number' ? data.expires_in : deriveMaxAgeFromExpires(data.expiresAt)
|
||||
applySessionCookie(result, sessionToken, maxAge)
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
|
||||
@ -3,12 +3,14 @@ export const dynamic = 'force-dynamic'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { applySessionCookie, deriveMaxAgeFromExpires } from '@lib/authGateway'
|
||||
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
|
||||
const WRITE_PERMISSIONS = ['admin.settings.write']
|
||||
|
||||
const ROOT_BACKUP_COOKIE = 'xc_session_root'
|
||||
const SANDBOX_EMAIL = 'sandbox@svc.plus'
|
||||
@ -17,10 +19,6 @@ type ErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
function isAllowedRootEmail(email?: string): boolean {
|
||||
return email?.trim().toLowerCase() === 'admin@svc.plus'
|
||||
}
|
||||
|
||||
function secureCookies(): boolean {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return true
|
||||
@ -37,12 +35,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, REQUIRED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!isAllowedRootEmail(user.email)) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
|
||||
const access = await evaluateAccountAdminAccess(user, {
|
||||
roles: REQUIRED_ROLES,
|
||||
permissions: WRITE_PERMISSIONS,
|
||||
rootOnly: true,
|
||||
})
|
||||
if (!access.allowed) {
|
||||
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
@ -96,4 +95,3 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
61
src/server/account/adminAccess.test.ts
Normal file
61
src/server/account/adminAccess.test.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
evaluateAccountAdminAccess,
|
||||
isPlatformRootEmail,
|
||||
} from "@server/account/adminAccess";
|
||||
import type { AccountSessionUser } from "@server/account/session";
|
||||
|
||||
function buildUser(overrides: Partial<AccountSessionUser> = {}): AccountSessionUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
uuid: "user-1",
|
||||
email: "user@example.com",
|
||||
role: "user",
|
||||
groups: [],
|
||||
permissions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("adminAccess", () => {
|
||||
it("allows platform admin roles without an explicit permission", async () => {
|
||||
const decision = await evaluateAccountAdminAccess(buildUser({ role: "admin" }), {
|
||||
roles: ["admin", "operator"],
|
||||
permissions: ["admin.users.list.read"],
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("allows permission-scoped operators without requiring the admin role", async () => {
|
||||
const decision = await evaluateAccountAdminAccess(buildUser({
|
||||
role: "operator",
|
||||
permissions: ["admin.users.pause.write"],
|
||||
}), {
|
||||
roles: ["admin", "operator"],
|
||||
permissions: ["admin.users.pause.write"],
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("enforces root-only routes after role and permission checks pass", async () => {
|
||||
const decision = await evaluateAccountAdminAccess(buildUser({
|
||||
role: "admin",
|
||||
permissions: ["admin.settings.write"],
|
||||
}), {
|
||||
roles: ["admin"],
|
||||
permissions: ["admin.settings.write"],
|
||||
rootOnly: true,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ allowed: false, reason: "root_only" });
|
||||
});
|
||||
|
||||
it("recognizes the shared platform root email", () => {
|
||||
expect(isPlatformRootEmail("admin@svc.plus")).toBe(true);
|
||||
expect(isPlatformRootEmail("ADMIN@svc.plus")).toBe(true);
|
||||
expect(isPlatformRootEmail("user@example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
77
src/server/account/adminAccess.ts
Normal file
77
src/server/account/adminAccess.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import type {
|
||||
AccountSessionUser,
|
||||
AccountUserRole,
|
||||
} from "@server/account/session";
|
||||
|
||||
export const PLATFORM_ROOT_EMAIL = "admin@svc.plus";
|
||||
|
||||
type AccountAdminAccessRule = {
|
||||
roles?: AccountUserRole[];
|
||||
permissions?: string[];
|
||||
rootOnly?: boolean;
|
||||
};
|
||||
|
||||
type AccountAdminAccessDecision = {
|
||||
allowed: boolean;
|
||||
reason?: "forbidden" | "root_only";
|
||||
};
|
||||
|
||||
export function isPlatformRootEmail(email?: string): boolean {
|
||||
return email?.trim().toLowerCase() === PLATFORM_ROOT_EMAIL;
|
||||
}
|
||||
|
||||
function hasRole(
|
||||
user: AccountSessionUser,
|
||||
roles: AccountUserRole[],
|
||||
): boolean {
|
||||
return roles.includes(user.role);
|
||||
}
|
||||
|
||||
function hasPermission(
|
||||
user: AccountSessionUser,
|
||||
permissions: string[],
|
||||
): boolean {
|
||||
if (permissions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const normalizedPermissions = new Set(
|
||||
user.permissions.map((permission) => permission.trim()),
|
||||
);
|
||||
if (normalizedPermissions.has("*")) {
|
||||
return true;
|
||||
}
|
||||
return permissions.every((permission) =>
|
||||
normalizedPermissions.has(permission.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
export async function evaluateAccountAdminAccess(
|
||||
user: AccountSessionUser | null,
|
||||
rule: AccountAdminAccessRule,
|
||||
): Promise<AccountAdminAccessDecision> {
|
||||
if (!user) {
|
||||
return { allowed: false, reason: "forbidden" };
|
||||
}
|
||||
|
||||
const roles = rule.roles ?? [];
|
||||
const permissions = rule.permissions ?? [];
|
||||
|
||||
let allowed = false;
|
||||
if (roles.length > 0 && permissions.length > 0) {
|
||||
allowed = hasRole(user, roles) || hasPermission(user, permissions);
|
||||
} else if (roles.length > 0) {
|
||||
allowed = hasRole(user, roles);
|
||||
} else if (permissions.length > 0) {
|
||||
allowed = hasPermission(user, permissions);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return { allowed: false, reason: "forbidden" };
|
||||
}
|
||||
|
||||
if (rule.rootOnly && !isPlatformRootEmail(user.email)) {
|
||||
return { allowed: false, reason: "root_only" };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user