P1-P3 admin cleanup: rank guard, SSRF guard, count caching, dead code removal, pagination rewrite
- P1: new src/lib/admin/rank-guard.ts with tryGuardRank/guardRank + RankGuardTarget/Result; extracted from users.ts; applied to admin-bans (create/lift + rank redirect) and admin-badges (caps + rank guard + error surfacing); import-url-safety.ts SSRF guard wired into clone save+icon fetch; logAudit await in help-tickets; antiddos mutations SETTINGS_EDIT; markAllAlertsRead NOTIFICATIONS_EDIT; rankError/saveError in all 25 message files; tests rew/new for bans/badges/rank-guard/import-url-safety - P2: ticket-inbox merged pagination reworked to single UNION ALL SQL with LIMIT/OFFSET (avoids O(offset) materialization); getCachedAdminCount with cacheFiltered option applied to devops/errors/photos/items/guilds/help-tickets/moderation pages; admin-theme catches→?error=1+banner+25 locales; logger in silent catches (rare-values/writeable-boxes); catalog-items-loader CAST→plain equality; count caching helper extended with opt-in cacheFiltered - P3: removed dead modules is-staff/notice/recent-furni-resync/log-filters plus load-user-sanctions/user-sanction-timeline; removed orphaned CATALOG_PAGE_COLUMNS re-export; removed getFurniIconUrl from studio-furni-icons Build: lint 0, typecheck 0, 3480 tests pass, i18n:check exit 0, colors exit 0. Circular chunk warning (9452/10535/66463) is webpack code-splitting artifact; build succeeds.
This commit is contained in:
1 parent
57529fd1ea
commit
7b249a7222
71 files changed
+1031
-1078
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { getHighestRank } from "@/lib/permissions";
|
||||
import { ActionError } from "@/lib/safe-action-shared";
|
||||
|
||||
export interface RankGuardTarget {
|
||||
username: string;
|
||||
rank: number;
|
||||
mail: string | null;
|
||||
}
|
||||
|
||||
export type RankGuardResult =
|
||||
| { ok: true; target: RankGuardTarget }
|
||||
| { ok: false; reason: "not_found" | "rank" };
|
||||
|
||||
/**
|
||||
* Refuse to act on a target whose rank is equal to or higher than the actor's,
|
||||
* unless the actor is the dynamic super admin (the account holding the hotel's
|
||||
* current highest rank). This is the single source of truth for the
|
||||
* "cannot modify equal/higher rank" invariant; every admin action that mutates
|
||||
* a specific user must go through it.
|
||||
*
|
||||
* Returns a discriminated result instead of throwing so callers can choose
|
||||
* between "block the request" and "tolerate a deleted user" (e.g. lifting a
|
||||
* ban for an account that no longer exists).
|
||||
*/
|
||||
export async function tryGuardRank(
|
||||
targetUserId: number,
|
||||
sessionRank: number,
|
||||
): Promise<RankGuardResult> {
|
||||
const [target] = await db
|
||||
.select({
|
||||
username: User.username,
|
||||
rank: User.rank,
|
||||
mail: User.mail,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.id, targetUserId))
|
||||
.limit(1);
|
||||
if (!target) return { ok: false, reason: "not_found" };
|
||||
const highestRank = await getHighestRank();
|
||||
if (
|
||||
target.rank >= sessionRank &&
|
||||
!isDynamicSuperAdmin(sessionRank, highestRank)
|
||||
) {
|
||||
return { ok: false, reason: "rank" };
|
||||
}
|
||||
return { ok: true, target };
|
||||
}
|
||||
|
||||
export async function guardRank(
|
||||
targetUserId: number,
|
||||
sessionRank: number,
|
||||
): Promise<RankGuardTarget> {
|
||||
const result = await tryGuardRank(targetUserId, sessionRank);
|
||||
if (!result.ok) {
|
||||
throw new ActionError(
|
||||
result.reason === "not_found"
|
||||
? "User not found"
|
||||
: "Cannot modify user with equal or higher rank",
|
||||
);
|
||||
}
|
||||
return result.target;
|
||||
}
|
||||
Reference in new issue
Block a user