Use persisted self-hosted roles and the isolated admin origin so authorized staging accounts can discover the protected admin surface.
This commit is contained in:
@@ -1578,3 +1578,19 @@
|
|||||||
- 防复发:当前事件延续必须是服务端 opportunity 所有权规则,而不是 prompt 建议;模型输出即使结构合法,也必须经过 bounded tool budget、active-opportunity lookup、decision validation 和 completion payload hash 四层门控。
|
- 防复发:当前事件延续必须是服务端 opportunity 所有权规则,而不是 prompt 建议;模型输出即使结构合法,也必须经过 bounded tool budget、active-opportunity lookup、decision validation 和 completion payload hash 四层门控。
|
||||||
- 相关记录:BUG-075、BUG-085
|
- 相关记录:BUG-075、BUG-085
|
||||||
- 修复版本:本地 V5 重构,待提交与 staging 验收
|
- 修复版本:本地 V5 重构,待提交与 staging 验收
|
||||||
|
|
||||||
|
## BUG-087 | self-hosted staging 管理员看不到独立后台入口
|
||||||
|
|
||||||
|
- 状态:resolved
|
||||||
|
- 首次发现:2026-07-29
|
||||||
|
- 最近更新:2026-07-29
|
||||||
|
- 影响面:self-hosted staging 账户菜单、`GET /api/account`、独立后台入口;不影响后台独立登录与 `requireAdminSession`
|
||||||
|
- 用户现象:身份库已持久化 `admin` 或 `viewer` 角色的用户登录主站后,账户菜单不显示后台入口;即使显示旧入口,主站 `/admin` 路径也会返回 404。
|
||||||
|
- 触发条件:`AUTH_PROVIDER=self-hosted`,后台部署在与主站不同的 `AUTH_ADMIN_ORIGIN`,用户角色以逗号分隔形式持久化在 `identity.users.role`。
|
||||||
|
- 根因:主站 `isAdminUser` 对 self-hosted 模式直接返回 `false`,没有读取持久化角色;侧栏又把入口写死为主站相对路径 `/admin/codes`。既有后台鉴权已按持久化角色执行,但主站入口发现逻辑没有复用同一授权事实,独立域名部署合同也没有进入账户响应。
|
||||||
|
- 修复:self-hosted 分支通过现有 `ADMIN_DATABASE_URL` 管理只读连接查询当前用户的 `identity.users.role`,仅 `admin` 或 `viewer` 可见入口,且不使用 `ADMIN_EMAILS` 替代角色授权;`GET /api/account` 在服务端解析身份配置并返回 `AUTH_ADMIN_ORIGIN + /admin/codes`,Supabase 模式继续返回 `/admin/codes`;账户与侧栏类型透传该 URL,并将文案改为“后台管理”。后台独立登录和 `requireAdminSession` 保持不变。
|
||||||
|
- 验证:`frontend/tests/admin-contracts.test.ts`、`frontend/tests/admin-users-contract.test.ts`、`frontend/tests/account-api.test.ts`、`frontend/tests/sidebar-contract.test.ts` 锁定持久化角色、独立后台 URL、服务端环境边界和后台写权限门禁;目标 TypeScript、构建与 staging 登录态 smoke 结果另行记录。
|
||||||
|
- 防复发:self-hosted 主站入口发现必须以 `identity.users.role` 为授权事实,不能退回邮箱 allowlist;客户端不得读取后台 origin 环境变量或硬编码主站 `/admin` 路径;后台 API 必须继续独立执行 `requireAdminSession`,入口可见性不得被当作授权。
|
||||||
|
- 相关记录:BUG-010、BUG-083、BUG-084
|
||||||
|
- 复发自:BUG-010
|
||||||
|
- 修复版本:待提交(本地可测)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
isSupabaseConfigurationError,
|
isSupabaseConfigurationError,
|
||||||
} from "@/lib/supabase/config";
|
} from "@/lib/supabase/config";
|
||||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||||
|
import { readIdentityConfig } from "@/modules/identity/config";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -109,11 +110,19 @@ export async function GET() {
|
|||||||
profile,
|
profile,
|
||||||
Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [],
|
Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [],
|
||||||
);
|
);
|
||||||
|
const isAdmin = await isAdminUser(user);
|
||||||
|
const identityConfig = readIdentityConfig(process.env);
|
||||||
|
const adminUrl = isAdmin
|
||||||
|
? identityConfig.provider === "self-hosted"
|
||||||
|
? new URL("/admin/codes", identityConfig.adminOrigin).toString()
|
||||||
|
: "/admin/codes"
|
||||||
|
: null;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
user: { id: user.id, email: user.email ?? null },
|
user: { id: user.id, email: user.email ?? null },
|
||||||
credits: profile.credits,
|
credits: profile.credits,
|
||||||
isAdmin: await isAdminUser(user),
|
isAdmin,
|
||||||
|
adminUrl,
|
||||||
rectificationPriceCredits,
|
rectificationPriceCredits,
|
||||||
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
|
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
|
||||||
&& typeof profile.active_birth_time === "string",
|
&& typeof profile.active_birth_time === "string",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ type Account = {
|
|||||||
user: { id: string; email: string | null };
|
user: { id: string; email: string | null };
|
||||||
credits: number;
|
credits: number;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
adminUrl: string | null;
|
||||||
rectificationPriceCredits: number;
|
rectificationPriceCredits: number;
|
||||||
hasConfirmedBirthTime: boolean;
|
hasConfirmedBirthTime: boolean;
|
||||||
rectificationCase: AccountRectificationCaseState | null;
|
rectificationCase: AccountRectificationCaseState | null;
|
||||||
@@ -1243,6 +1244,7 @@ export default function Home() {
|
|||||||
user: { id: "preview-user", email: "preview@local.test" },
|
user: { id: "preview-user", email: "preview@local.test" },
|
||||||
credits: 8,
|
credits: 8,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
adminUrl: null,
|
||||||
rectificationPriceCredits: 1,
|
rectificationPriceCredits: 1,
|
||||||
hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed",
|
hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed",
|
||||||
rectificationCase: null,
|
rectificationCase: null,
|
||||||
@@ -2789,6 +2791,7 @@ export default function Home() {
|
|||||||
email: account.user.email || "尚未读取邮箱",
|
email: account.user.email || "尚未读取邮箱",
|
||||||
credits: account.credits,
|
credits: account.credits,
|
||||||
isAdmin: account.isAdmin,
|
isAdmin: account.isAdmin,
|
||||||
|
adminUrl: account.adminUrl,
|
||||||
initial: profile.name.trim().slice(0, 1)
|
initial: profile.name.trim().slice(0, 1)
|
||||||
|| account.user.email?.slice(0, 1).toUpperCase()
|
|| account.user.email?.slice(0, 1).toUpperCase()
|
||||||
|| "你",
|
|| "你",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type SidebarAccount = {
|
|||||||
email: string;
|
email: string;
|
||||||
credits: number;
|
credits: number;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
adminUrl: string | null;
|
||||||
initial: string;
|
initial: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,8 +215,8 @@ export function AppSidebar({
|
|||||||
<button className="account-menu-item" role="menuitem" type="button" onClick={() => handleAccountAction(onOpenRedeem)}>
|
<button className="account-menu-item" role="menuitem" type="button" onClick={() => handleAccountAction(onOpenRedeem)}>
|
||||||
<Gift aria-hidden="true" /><span>兑换点数</span><small>{account.credits} 点</small>
|
<Gift aria-hidden="true" /><span>兑换点数</span><small>{account.credits} 点</small>
|
||||||
</button>
|
</button>
|
||||||
{account.isAdmin && <Link className="account-menu-item" href="/admin/codes" role="menuitem" onClick={() => onAccountMenuOpenChange(false)}>
|
{account.isAdmin && account.adminUrl && <Link className="account-menu-item" href={account.adminUrl} role="menuitem" onClick={() => onAccountMenuOpenChange(false)}>
|
||||||
<KeyRound aria-hidden="true" /><span>管理兑换码</span><ChevronRight aria-hidden="true" />
|
<KeyRound aria-hidden="true" /><span>后台管理</span><ChevronRight aria-hidden="true" />
|
||||||
</Link>}
|
</Link>}
|
||||||
<div className="account-menu-separator" role="separator" />
|
<div className="account-menu-separator" role="separator" />
|
||||||
<button className="account-menu-item account-menu-danger" role="menuitem" type="button" onClick={() => handleAccountAction(onOpenLogout)}>
|
<button className="account-menu-item account-menu-danger" role="menuitem" type="button" onClick={() => handleAccountAction(onOpenLogout)}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
|
import { queryAdminRows } from "@/lib/admin/database";
|
||||||
import {
|
import {
|
||||||
createAdminSupabaseClient,
|
createAdminSupabaseClient,
|
||||||
isAdminEmail,
|
isAdminEmail,
|
||||||
@@ -8,8 +9,23 @@ import {
|
|||||||
export { createAdminSupabaseClient, isAdminEmail };
|
export { createAdminSupabaseClient, isAdminEmail };
|
||||||
|
|
||||||
export async function isAdminUser(user: { id?: string; email?: string | null }) {
|
export async function isAdminUser(user: { id?: string; email?: string | null }) {
|
||||||
|
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
|
||||||
|
if (!user.id) return false;
|
||||||
|
try {
|
||||||
|
const rows = await queryAdminRows<{ role: string }>(
|
||||||
|
"select role from identity.users where id = $1 limit 1",
|
||||||
|
[user.id],
|
||||||
|
);
|
||||||
|
return rows[0]?.role
|
||||||
|
.split(",")
|
||||||
|
.map((role) => role.trim())
|
||||||
|
.some((role) => role === "admin" || role === "viewer") ?? false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isAdminEmail(user.email)) return true;
|
if (isAdminEmail(user.email)) return true;
|
||||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted" || !user.id) return false;
|
if (!user.id) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const admin = createAdminSupabaseClient();
|
const admin = createAdminSupabaseClient();
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ test("account API reads and returns the server-configured rectification price",
|
|||||||
assert.doesNotMatch(source, /RECTIFICATION_PRICE_CREDITS[^\n]*\?\?\s*["']1["']/);
|
assert.doesNotMatch(source, /RECTIFICATION_PRICE_CREDITS[^\n]*\?\?\s*["']1["']/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("account API returns a server-resolved admin entry URL", () => {
|
||||||
|
assert.match(source, /readIdentityConfig\(process\.env\)/);
|
||||||
|
assert.match(source, /new URL\("\/admin\/codes", identityConfig\.adminOrigin\)\.toString\(\)/);
|
||||||
|
assert.match(source, /identityConfig\.provider === "self-hosted"/);
|
||||||
|
assert.match(source, /: "\/admin\/codes"/);
|
||||||
|
assert.match(source, /adminUrl,/);
|
||||||
|
assert.doesNotMatch(source, /NEXT_PUBLIC_ADMIN|process\.env\.AUTH_ADMIN_ORIGIN/);
|
||||||
|
});
|
||||||
|
|
||||||
test("account API projects only the minimum case state needed by the homepage", () => {
|
test("account API projects only the minimum case state needed by the homepage", () => {
|
||||||
const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(\d+\)/)?.[0] ?? "";
|
const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(\d+\)/)?.[0] ?? "";
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const migration = readFileSync(
|
|||||||
);
|
);
|
||||||
const auth = readFileSync(new URL("../src/lib/admin/auth.ts", import.meta.url), "utf8");
|
const auth = readFileSync(new URL("../src/lib/admin/auth.ts", import.meta.url), "utf8");
|
||||||
const authPolicy = readFileSync(new URL("../src/lib/admin/auth-policy.ts", import.meta.url), "utf8");
|
const authPolicy = readFileSync(new URL("../src/lib/admin/auth-policy.ts", import.meta.url), "utf8");
|
||||||
|
const adminUser = readFileSync(new URL("../src/lib/supabase/admin.ts", import.meta.url), "utf8");
|
||||||
const codesRoute = readFileSync(new URL("../src/app/api/admin/codes/route.ts", import.meta.url), "utf8");
|
const codesRoute = readFileSync(new URL("../src/app/api/admin/codes/route.ts", import.meta.url), "utf8");
|
||||||
const codeRoute = readFileSync(new URL("../src/app/api/admin/codes/[id]/route.ts", import.meta.url), "utf8");
|
const codeRoute = readFileSync(new URL("../src/app/api/admin/codes/[id]/route.ts", import.meta.url), "utf8");
|
||||||
const providers = readFileSync(new URL("../src/lib/admin/providers.ts", import.meta.url), "utf8");
|
const providers = readFileSync(new URL("../src/lib/admin/providers.ts", import.meta.url), "utf8");
|
||||||
@@ -27,6 +28,20 @@ test("admin APIs use persisted Better Auth roles with admin and viewer boundarie
|
|||||||
assert.match(codeRoute, /requireAdminSession\("write"\)/g);
|
assert.match(codeRoute, /requireAdminSession\("write"\)/g);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("self-hosted account entry checks persisted admin or viewer roles", () => {
|
||||||
|
const selfHostedBranch = adminUser.slice(
|
||||||
|
adminUser.indexOf('process.env.AUTH_PROVIDER?.trim() === "self-hosted"'),
|
||||||
|
adminUser.indexOf("if (isAdminEmail"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(selfHostedBranch, /queryAdminRows/);
|
||||||
|
assert.match(selfHostedBranch, /select role from identity\.users where id = \$1 limit 1/);
|
||||||
|
assert.match(selfHostedBranch, /role === "admin" \|\| role === "viewer"/);
|
||||||
|
assert.doesNotMatch(selfHostedBranch, /isAdminEmail|ADMIN_EMAILS/);
|
||||||
|
assert.match(auth, /getIdentityAuthServices\(\)\.admin\.api/);
|
||||||
|
assert.match(auth, /authorizeAdminAccess\(user, access\)/);
|
||||||
|
});
|
||||||
|
|
||||||
test("readonly resources cannot be mutated through Refine access control", () => {
|
test("readonly resources cannot be mutated through Refine access control", () => {
|
||||||
for (const resource of ["users", "credit-transactions", "consultations", "audit-logs"]) {
|
for (const resource of ["users", "credit-transactions", "consultations", "audit-logs"]) {
|
||||||
assert.match(providers, new RegExp(`"${resource}"`));
|
assert.match(providers, new RegExp(`"${resource}"`));
|
||||||
|
|||||||
@@ -4,25 +4,27 @@ import test from "node:test";
|
|||||||
|
|
||||||
const root = new URL("../", import.meta.url);
|
const root = new URL("../", import.meta.url);
|
||||||
const adminSource = readFileSync(new URL("src/lib/supabase/admin.ts", root), "utf8");
|
const adminSource = readFileSync(new URL("src/lib/supabase/admin.ts", root), "utf8");
|
||||||
const layoutSource = readFileSync(new URL("src/app/admin/layout.tsx", root), "utf8");
|
const adminCoreSource = readFileSync(new URL("src/lib/supabase/admin-client-core.ts", root), "utf8");
|
||||||
const codesSource = readFileSync(new URL("src/app/api/admin/codes/route.ts", root), "utf8");
|
const codesSource = readFileSync(new URL("src/app/api/admin/codes/route.ts", root), "utf8");
|
||||||
|
const sessionSource = readFileSync(new URL("src/app/api/admin/session/route.ts", root), "utf8");
|
||||||
const accountSource = readFileSync(new URL("src/app/api/account/route.ts", root), "utf8");
|
const accountSource = readFileSync(new URL("src/app/api/account/route.ts", root), "utf8");
|
||||||
const usersSource = readFileSync(new URL("src/app/api/admin/users/route.ts", root), "utf8");
|
const usersSource = readFileSync(new URL("src/app/api/admin/users/route.ts", root), "utf8");
|
||||||
const migration = readFileSync(new URL("supabase/migrations/20260727010000_admin_users.sql", root), "utf8");
|
const migration = readFileSync(new URL("supabase/migrations/20260727010000_admin_users.sql", root), "utf8");
|
||||||
|
|
||||||
test("ADMIN_EMAILS remains a case-insensitive comma-separated allowlist", () => {
|
test("ADMIN_EMAILS remains a case-insensitive comma-separated allowlist", () => {
|
||||||
assert.match(adminSource, /configured/);
|
assert.match(adminCoreSource, /configured/);
|
||||||
assert.match(adminSource, /split\(\",\"\)/);
|
assert.match(adminCoreSource, /split\(","\)/);
|
||||||
assert.match(adminSource, /toLowerCase/);
|
assert.match(adminCoreSource, /toLowerCase/);
|
||||||
assert.match(adminSource, /export function isAdminEmail/);
|
assert.match(adminCoreSource, /export function isAdminEmail/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin surfaces await database-backed administrator checks", () => {
|
test("admin surfaces await database-backed administrator checks", () => {
|
||||||
assert.match(adminSource, /export async function isAdminUser/);
|
assert.match(adminSource, /export async function isAdminUser/);
|
||||||
assert.match(adminSource, /from\("admin_users"\)/);
|
assert.match(adminSource, /from\("admin_users"\)/);
|
||||||
assert.match(layoutSource, /await isAdminUser\(user\)/);
|
assert.match(sessionSource, /await requireAdminSession\(\)/);
|
||||||
assert.match(codesSource, /await isAdminUser\(user\)/);
|
assert.match(codesSource, /await requireAdminSession\("write"\)/);
|
||||||
assert.match(accountSource, /isAdmin: await isAdminUser\(user\)/);
|
assert.match(accountSource, /const isAdmin = await isAdminUser\(user\)/);
|
||||||
|
assert.match(accountSource, /isAdmin,/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin_users migration is service-role-only and auditable", () => {
|
test("admin_users migration is service-role-only and auditable", () => {
|
||||||
@@ -36,12 +38,10 @@ test("admin_users migration is service-role-only and auditable", () => {
|
|||||||
assert.match(migration, /grant select, insert, update on table public\.admin_users to service_role/);
|
assert.match(migration, /grant select, insert, update on table public\.admin_users to service_role/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin users route exposes guarded list, add, and soft revoke contracts", () => {
|
test("admin users route exposes a guarded list and rejects mutations", () => {
|
||||||
assert.match(usersSource, /export async function GET/);
|
assert.match(usersSource, /export async function GET/);
|
||||||
assert.match(usersSource, /export async function POST/);
|
assert.match(usersSource, /await requireAdminSession\(\)/);
|
||||||
assert.match(usersSource, /export async function DELETE/);
|
assert.match(usersSource, /export const POST = readonlyAdminMutation/);
|
||||||
assert.match(usersSource, /auth\.admin\.listUsers/);
|
assert.match(usersSource, /export const PATCH = readonlyAdminMutation/);
|
||||||
assert.match(usersSource, /upsert\(\{ user_id: target\.id, created_by: auth\.user\.id/);
|
assert.match(usersSource, /export const DELETE = readonlyAdminMutation/);
|
||||||
assert.match(usersSource, /revoked_at: new Date\(\)\.toISOString\(\)/);
|
|
||||||
assert.match(usersSource, /环境配置管理员不可撤销/);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -154,6 +154,18 @@ test("keeps app sidebar props as product data and callbacks", () => {
|
|||||||
assert.doesNotMatch(appSidebar, /supabase|fetch\(|\/api\//i);
|
assert.doesNotMatch(appSidebar, /supabase|fetch\(|\/api\//i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses the server-provided independent admin entry URL", () => {
|
||||||
|
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
|
||||||
|
const page = readProjectFile("src/app/page.tsx");
|
||||||
|
|
||||||
|
assert.match(appSidebar, /adminUrl: string \| null/);
|
||||||
|
assert.match(appSidebar, /account\.isAdmin && account\.adminUrl/);
|
||||||
|
assert.match(appSidebar, /href=\{account\.adminUrl\}/);
|
||||||
|
assert.match(appSidebar, />后台管理</);
|
||||||
|
assert.doesNotMatch(appSidebar, /href="\/admin\/codes"/);
|
||||||
|
assert.match(page, /adminUrl: account\.adminUrl/);
|
||||||
|
});
|
||||||
|
|
||||||
test("composes the chat page with the app sidebar shell", () => {
|
test("composes the chat page with the app sidebar shell", () => {
|
||||||
const page = readProjectFile("src/app/page.tsx");
|
const page = readProjectFile("src/app/page.tsx");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user