fix: restrict platform maintenance and backup visibility to Owner

This commit is contained in:
Lemon-miaow committed 2026-10-07 16:47:57 +08:00
1 parent 285c56955d
commit 07973a4bbd
9 files changed
+68 -55

No files matched your search

+8 -8
View File
@@ -1073,8 +1073,8 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
sendJSON(ctx.res, 200, { sources: [{ tag: "mojang", prefix: "", lookup_available: true }, ...ctx.state.authSources.sources.filter((source) => source.enabled).map((source) => ({ tag: source.tag, prefix: source.prefix, lookup_available: !!source.api_url || source.url.endsWith("/sessionserver/session/minecraft/hasJoined") }))] });
return true;
case "GET platform/db-backup": {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
if (ctx.account.role !== "owner") {
sendError(ctx.res, 403, "forbidden", "owner account required");
return true;
}
// Yesterday's daily run: fresh, so the card shows its healthy state.
@@ -1097,8 +1097,8 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
return true;
}
case "GET updates/report": {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
if (ctx.account.role !== "owner") {
sendError(ctx.res, 403, "forbidden", "owner account required");
return true;
}
// Last night's timer run: one update waiting, one feed unreachable, one
@@ -1122,15 +1122,15 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
return true;
}
case "GET updates/window":
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
if (ctx.account.role !== "owner") {
sendError(ctx.res, 403, "forbidden", "owner account required");
return true;
}
sendJSON(ctx.res, 200, ctx.state.updateWindow);
return true;
case "PUT updates/window": {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
if (ctx.account.role !== "owner") {
sendError(ctx.res, 403, "forbidden", "owner account required");
return true;
}
const body = await readJSON<{ start: string | null; end: string | null }>(ctx.req);
+16
View File
@@ -115,3 +115,19 @@ test("Owner executes node management and receives stage, failure logs and retry"
await expect(page.getByText(t("admin:node_control_state_running"), { exact: true })).toBeVisible();
await expectFitsScreen(page);
});
test("platform backup and version maintenance are Owner-only", async ({ page, signIn }) => {
await signIn("owner");
expect((await page.request.patch("/api/v1/users/user", { data: { role: "admin" } })).ok()).toBe(true);
await signIn("user");
await page.goto("/admin/updates");
await expect(page.getByRole("navigation").getByRole("link", { name: t("navigation:admin_updates"), exact: true })).toHaveCount(0);
await expect(page.getByRole("heading", { name: t("admin:updates_title"), exact: true })).toHaveCount(0);
for (const path of ["updates/window", "updates/report", "platform/db-backup"]) {
expect((await page.request.get(`/api/v1/${path}`)).status()).toBe(403);
}
await signIn("owner");
await page.goto("/admin/updates");
await expect(page.getByRole("heading", { name: t("admin:updates_title"), exact: true })).toBeVisible();
await expect(page.getByRole("navigation").getByRole("link", { name: t("navigation:admin_updates"), exact: true })).toBeVisible();
});
+1 -1
View File
@@ -130,9 +130,9 @@ export default function App() {
<Route path="images" element={<ImageAdmin />} />
<Route path="builds" element={<ImageBuildPage />} />
<Route path="submissions" element={<SubmissionsPage />} />
<Route path="updates" element={<UpdatesPage />} />
{/* Owner-gated platform settings and user management. */}
<Route element={<RequireOwner />}>
<Route path="updates" element={<UpdatesPage />} />
<Route path="platform" element={<PlatformSettingsPage />} />
<Route path="auth-sources" element={<AuthSourcesPage />} />
<Route path="users" element={<UsersPage />} />
+7
View File
@@ -24,6 +24,13 @@ describe("visibleSections", () => {
expect(ids).toEqual(["user", "admin"]);
});
it("places platform maintenance exclusively in Owner navigation, before platform settings", () => {
expect(visibleSections(true, false).flatMap((section) => section.items).some((item) => item.to === "/admin/updates")).toBe(false);
const owner = visibleSections(true, true).find((section) => section.id === "owner");
expect(owner?.items.map((item) => item.to)).toContain("/admin/updates");
expect(owner?.items.at(-1)?.to).toBe("/admin/platform");
});
it("keeps the User-Side section ungated so it survives both branches", () => {
const user = NAV_SECTIONS.find((s) => s.id === "user");
expect(user?.adminOnly).toBe(false);
+2 -3
View File
@@ -18,8 +18,7 @@ import {
// identity see" decision is a pure function (visibleSections) that vitest can pin
// without rendering React. The three sections map onto DESIGN-WEB-3SIDES §2:
// User-Side is always present (app-tier); Admin-Side and SysAdmin-Side are a
// navigational separation of *concern* over the SAME admin tier — both gated by
// the one `is_admin` flag, surfaced as two sections only for admins.
// separation between server administration and Owner-only platform operations.
//
// Hiding a section is UX convenience, NOT a security control: every /admin and
// /ops data call is independently 403-gated server-side (the RequireAdmin route
@@ -68,7 +67,6 @@ export const NAV_SECTIONS: NavSection[] = [
{ to: "/admin/images", key: "admin_images", icon: Boxes },
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
{ to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck },
{ to: "/admin/updates", key: "admin_updates", icon: Clock },
],
},
{
@@ -79,6 +77,7 @@ export const NAV_SECTIONS: NavSection[] = [
items: [
{ to: "/admin/users", key: "admin_users", icon: Users },
{ to: "/admin/auth-sources", key: "auth_sources", icon: ShieldCheck },
{ to: "/admin/updates", key: "admin_updates", icon: Clock },
{ to: "/admin/platform", key: "platform_settings", icon: Settings },
],
},
+4 -4
View File
@@ -1404,12 +1404,12 @@ export interface paths {
cookie?: never;
};
/**
* Read the SysAdmin-set auto-update maintenance window (admin).
* Read the Owner-set auto-update maintenance window (Owner).
* @description Felis applies no update on its own. `felis update` checks versions and prints an explicit apply command. `felis update --apply` reads this platform-wide [start,end) window before backup and before installation, refusing outside it unless `--now` explicitly starts manual maintenance. An unreadable window is always a refusal, including with `--now` or `--force`. An unset window reads back as {start:null,end:null}.
*/
get: operations["getUpdateWindow"];
/**
* Set or clear the SysAdmin auto-update maintenance window (admin).
* Set or clear the SysAdmin auto-update maintenance window (Owner).
* @description Persist the maintenance window as an absolute [start,end) interval. Both ends must be set with end strictly after start, or both null to clear the window to unset. A half-set (exactly one end) or inverted/empty (end not after start) body is rejected 400, mirroring the decision core's fail-closed Window so a malformed schedule can never be stored. No forced auto-update: setting a window only permits an apply inside it; outside, a Scheduled component degrades to notify.
*/
put: operations["setUpdateWindow"];
@@ -1428,7 +1428,7 @@ export interface paths {
cookie?: never;
};
/**
* Freshness of the newest control-plane database backup (admin).
* Freshness of the newest control-plane database backup (Owner).
* @description What the host's felis-db-backup.timer (or a manual `felis db backup`) last recorded in platform_settings. last is null before the first backup; stale is true then, and whenever the newest daily backup (last.daily_at) is missing or older than max_age_seconds. Read-only: backups run on the host, never through the API.
*/
get: operations["getDBBackup"];
@@ -1448,7 +1448,7 @@ export interface paths {
cookie?: never;
};
/**
* The newest recorded version check of every tracked component (admin).
* The newest recorded version check of every tracked component (Owner).
* @description What `felis update --record` last stored in platform_settings; the installer's felis-update-check.timer runs it daily on the host, where the installed versions are readable. report is null before the first check; stale is true then, and whenever the check is older than max_age_seconds. Read-only: Felis applies no update on its own.
*/
get: operations["getUpdateReport"];