feat(admission): add scheduled group access
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { groupAccessWindows, groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
@@ -6,16 +6,18 @@ import { notFound } from "next/navigation";
|
||||
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||
import { AdminUserTable } from "@/components/admin-user-table";
|
||||
import { GroupPolicyControl } from "@/components/group-policy-control";
|
||||
import { GroupScheduleEditor, GroupScheduleSummary } from "@/components/group-schedule-editor";
|
||||
import { db } from "@/lib/database";
|
||||
import { isEffectiveGroupMember } from "@/lib/group-management";
|
||||
import { assignUserGroupFromRegistry } from "../../users/actions";
|
||||
import { deleteGroup, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
|
||||
import { deleteGroup, replaceGroupSchedule, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
|
||||
|
||||
const savedMessages: Record<string, string> = {
|
||||
created: "Group created.",
|
||||
details: "Group details updated.",
|
||||
access: "Minecraft access policy updated.",
|
||||
"network-access": "VPN, proxy, and Tor policy updated.",
|
||||
schedule: "Weekly access schedule updated.",
|
||||
group: "Member group updated.",
|
||||
};
|
||||
|
||||
@@ -23,6 +25,7 @@ const errorMessages: Record<string, string> = {
|
||||
"invalid-group": "Enter a valid name and a description of no more than 500 characters.",
|
||||
"duplicate-group": "A group with that name already exists.",
|
||||
"invalid-group-assignment": "The user or destination group no longer exists. No membership change was applied.",
|
||||
"invalid-schedule": "Use valid, non-overlapping weekly access windows. Start and end cannot be identical.",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -39,7 +42,7 @@ export default async function GroupPage({
|
||||
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||
if (!group) notFound();
|
||||
|
||||
const [allUsers, allGroups, memberships] = await Promise.all([
|
||||
const [allUsers, allGroups, memberships, accessWindows] = await Promise.all([
|
||||
db.select({
|
||||
id: users.id,
|
||||
firstName: users.firstName,
|
||||
@@ -65,6 +68,11 @@ export default async function GroupPage({
|
||||
.from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
||||
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
|
||||
.from(userGroupMemberships),
|
||||
db.select({
|
||||
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
|
||||
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
|
||||
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id))
|
||||
.orderBy(groupAccessWindows.startMinuteOfWeek),
|
||||
]);
|
||||
const assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
|
||||
const memberUsers = allUsers.filter((user) => isEffectiveGroupMember(user.id, assignmentByUser, group));
|
||||
@@ -101,8 +109,17 @@ export default async function GroupPage({
|
||||
<section aria-labelledby="group-policy-heading" className="mt-10 border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<div className="border-b border-line pb-4"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Admission controls</p><h2 className="mt-2 font-display text-3xl font-black uppercase" id="group-policy-heading">Group policies</h2></div>
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
<PolicyDetail description="Controls whether members can connect to Minecraft." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail>
|
||||
<PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections." label="VPN / proxy / Tor"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="VPN / proxy / Tor" returnLocation="detail" /></PolicyDetail>
|
||||
<PolicyDetail description="Controls whether members can connect to Minecraft. Disabled access always overrides the schedule." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail>
|
||||
<PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections after access and schedule checks pass." label="VPN / proxy / Tor"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="VPN / proxy / Tor" returnLocation="detail" /></PolicyDetail>
|
||||
</div>
|
||||
<div className="mt-7 border-t border-line pt-6">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="max-w-2xl"><h3 className="font-mono text-xs font-bold uppercase">Weekly access schedule</h3><p className="mt-2 text-xs leading-5 text-muted">When Minecraft access is enabled, members may log in only during these recurring UTC windows. Existing sessions are not disconnected when a window ends.</p><div className="mt-4"><GroupScheduleSummary windows={accessWindows} /></div></div>
|
||||
<AdminModalForm action={replaceGroupSchedule} description={`Replace the complete weekly access schedule for ${group.name}. Minecraft access must still be enabled.`} submitLabel="Save schedule" title={`Schedule ${group.name}`} triggerClassName="shrink-0 border border-ink px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-wider" triggerLabel="Edit schedule">
|
||||
<input name="groupId" type="hidden" value={group.id} />
|
||||
<GroupScheduleEditor windows={accessWindows} />
|
||||
</AdminModalForm>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const actionState = vi.hoisted(() => ({
|
||||
selected: [] as unknown[][],
|
||||
inserted: [] as unknown[],
|
||||
deleted: 0,
|
||||
authorized: 0,
|
||||
failAudit: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/require-admin", () => ({
|
||||
requireAdminSession: async () => {
|
||||
actionState.authorized += 1;
|
||||
return { email: "admin@example.test", name: "Admin" };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (path: string) => {
|
||||
throw new Error(`REDIRECT:${path}`);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/database", () => {
|
||||
function selection(response: unknown[]) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const method of ["from", "where", "orderBy"]) chain[method] = () => chain;
|
||||
chain.limit = () => Promise.resolve(response);
|
||||
chain.then = (resolve: (value: unknown[]) => unknown, reject: (reason: unknown) => unknown) =>
|
||||
Promise.resolve(response).then(resolve, reject);
|
||||
return chain;
|
||||
}
|
||||
const tx = {
|
||||
execute: async () => undefined,
|
||||
select: () => selection(actionState.selected.shift() ?? []),
|
||||
delete: () => ({ where: async () => { actionState.deleted += 1; } }),
|
||||
insert: () => ({
|
||||
values: async (value: unknown) => {
|
||||
if (actionState.failAudit && !Array.isArray(value)) throw new Error("audit unavailable");
|
||||
actionState.inserted.push(value);
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { db: { transaction: async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx) } };
|
||||
});
|
||||
|
||||
import { replaceGroupSchedule } from "./actions";
|
||||
|
||||
function scheduleForm() {
|
||||
const formData = new FormData();
|
||||
formData.set("groupId", "11111111-1111-4111-8111-111111111111");
|
||||
formData.append("startMinuteOfWeek", "6960");
|
||||
formData.append("endMinuteOfWeek", "7200");
|
||||
return formData;
|
||||
}
|
||||
|
||||
describe("replaceGroupSchedule", () => {
|
||||
beforeEach(() => {
|
||||
actionState.selected = [
|
||||
[{ id: "11111111-1111-4111-8111-111111111111", name: "Friday friends" }],
|
||||
[{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 }],
|
||||
];
|
||||
actionState.inserted = [];
|
||||
actionState.deleted = 0;
|
||||
actionState.authorized = 0;
|
||||
actionState.failAudit = false;
|
||||
});
|
||||
|
||||
it("reauthorizes and replaces all windows with an audit in one transaction", async () => {
|
||||
await expect(replaceGroupSchedule(scheduleForm())).rejects.toThrow("REDIRECT:/admin/groups/11111111-1111-4111-8111-111111111111?saved=schedule");
|
||||
expect(actionState.authorized).toBe(1);
|
||||
expect(actionState.deleted).toBe(1);
|
||||
expect(actionState.inserted[0]).toEqual([{
|
||||
groupId: "11111111-1111-4111-8111-111111111111",
|
||||
startMinuteOfWeek: 6960,
|
||||
endMinuteOfWeek: 7200,
|
||||
}]);
|
||||
expect(actionState.inserted[1]).toEqual(expect.objectContaining({
|
||||
type: "games.minecraft.account-manager.group.schedule-updated",
|
||||
data: expect.objectContaining({
|
||||
previousWindows: [{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 }],
|
||||
windows: [{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7200 }],
|
||||
adminEmail: "admin@example.test",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not report success when the atomic audit write fails", async () => {
|
||||
actionState.failAudit = true;
|
||||
await expect(replaceGroupSchedule(scheduleForm())).rejects.toThrow("audit unavailable");
|
||||
expect(actionState.inserted).toEqual([[{
|
||||
groupId: "11111111-1111-4111-8111-111111111111",
|
||||
startMinuteOfWeek: 6960,
|
||||
endMinuteOfWeek: 7200,
|
||||
}]]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { events, groups, userGroupMemberships } from "@minecraft-account-manager/database";
|
||||
import { events, groupAccessWindows, groups, userGroupMemberships } from "@minecraft-account-manager/database";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
@@ -9,6 +9,7 @@ import { redirect } from "next/navigation";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
import { db } from "@/lib/database";
|
||||
import { editableGroupName, groupSlug, validateGroupDetails } from "@/lib/group-management";
|
||||
import { parseScheduleWindows } from "@/lib/group-schedule";
|
||||
|
||||
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;
|
||||
|
||||
@@ -175,6 +176,41 @@ export async function setGroupAnonymizedNetworkAccess(formData: FormData) {
|
||||
return updateGroupPolicy(formData, "anonymized-networks");
|
||||
}
|
||||
|
||||
export async function replaceGroupSchedule(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const windows = parseScheduleWindows(formData);
|
||||
if (!UUID_PATTERN.test(groupId) || !windows) redirect(groupPath(groupId, "error=invalid-schedule"));
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||
const [group] = await tx.select({ id: groups.id, name: groups.name }).from(groups)
|
||||
.where(eq(groups.id, groupId)).limit(1);
|
||||
if (!group) return null;
|
||||
const previous = await tx.select({
|
||||
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
|
||||
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
|
||||
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id));
|
||||
await tx.delete(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id));
|
||||
if (windows.length) {
|
||||
await tx.insert(groupAccessWindows).values(windows.map((window) => ({ ...window, groupId: group.id })));
|
||||
}
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.group.schedule-updated",
|
||||
subject: `group/${group.id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, { name: group.name, previousWindows: previous, windows }),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return group;
|
||||
});
|
||||
if (!updated) redirect("/admin/groups?error=unknown-group");
|
||||
redirect(groupPath(updated.id, "saved=schedule"));
|
||||
}
|
||||
|
||||
export async function deleteGroup(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
|
||||
@@ -17,6 +17,7 @@ export default async function SettingsPage({
|
||||
registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage,
|
||||
groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage,
|
||||
vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage,
|
||||
scheduledAccessDeniedMessage: settings?.scheduledAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.scheduledAccessDeniedMessage,
|
||||
};
|
||||
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
|
||||
@@ -40,9 +41,10 @@ export default async function SettingsPage({
|
||||
|
||||
<fieldset className="space-y-7">
|
||||
<legend className="font-display text-2xl font-black uppercase">Minecraft denial messages</legend>
|
||||
<p className="text-sm leading-6 text-muted">Each plain-text message is returned for one admission outcome. Messages must be between 10 and 500 characters.</p>
|
||||
<AdmissionMessageField description="Shown when the Minecraft identity is not registered." label="Registration required" name="registrationMessage" value={messages.registrationMessage} />
|
||||
<p className="text-sm leading-6 text-muted">Each plain-text template is returned for one admission outcome. Messages must be between 10 and 500 characters. Registration, group, and network templates support <code>{"{player}"}</code> and <code>{"{group}"}</code>.</p>
|
||||
<AdmissionMessageField description="Shown when the Minecraft identity is not registered. The unresolved group is everyone." label="Registration required" name="registrationMessage" value={messages.registrationMessage} />
|
||||
<AdmissionMessageField description="Shown when the effective group has Minecraft access disabled." label="Group access disabled" name="groupAccessDeniedMessage" value={messages.groupAccessDeniedMessage} />
|
||||
<AdmissionMessageField description="Shown outside a scheduled access window. Also supports {next_start} and {next_end}; generated times explicitly use UTC." label="Scheduled access denied" name="scheduledAccessDeniedMessage" value={messages.scheduledAccessDeniedMessage} />
|
||||
<AdmissionMessageField description="Shown for VPN, proxy, or Tor connections when the effective group has no exception." label="VPN, proxy, or Tor denied" name="vpnDeniedMessage" value={messages.vpnDeniedMessage} />
|
||||
</fieldset>
|
||||
<button className="mt-8 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas hover:bg-accent" type="submit">Save configuration</button>
|
||||
@@ -60,7 +62,7 @@ function AdmissionMessageField({
|
||||
}: {
|
||||
description: string;
|
||||
label: string;
|
||||
name: "registrationMessage" | "groupAccessDeniedMessage" | "vpnDeniedMessage";
|
||||
name: "registrationMessage" | "groupAccessDeniedMessage" | "scheduledAccessDeniedMessage" | "vpnDeniedMessage";
|
||||
value: string;
|
||||
}) {
|
||||
const descriptionId = `${name}-description`;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { hashToken } from "@minecraft-account-manager/auth";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const databaseState = vi.hoisted(() => ({
|
||||
responses: [] as unknown[][],
|
||||
inserted: [] as Array<Record<string, unknown>>,
|
||||
isolationLevel: "",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/database", () => {
|
||||
function selection(response: unknown[]) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const method of ["from", "where", "innerJoin", "leftJoin", "orderBy"]) {
|
||||
chain[method] = () => chain;
|
||||
}
|
||||
chain.limit = () => Promise.resolve(response);
|
||||
chain.then = (resolve: (value: unknown[]) => unknown, reject: (reason: unknown) => unknown) =>
|
||||
Promise.resolve(response).then(resolve, reject);
|
||||
return chain;
|
||||
}
|
||||
const tx = {
|
||||
select: () => selection(databaseState.responses.shift() ?? []),
|
||||
insert: () => ({
|
||||
values: (value: Record<string, unknown>) => {
|
||||
databaseState.inserted.push(value);
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
delete: () => ({ where: () => Promise.resolve() }),
|
||||
update: () => ({ set: () => ({ where: () => Promise.resolve() }) }),
|
||||
};
|
||||
return {
|
||||
db: {
|
||||
select: () => selection(databaseState.responses.shift() ?? []),
|
||||
transaction: async (callback: (transaction: typeof tx) => Promise<unknown>, options: { isolationLevel?: string }) => {
|
||||
databaseState.isolationLevel = options?.isolationLevel ?? "";
|
||||
return callback(tx);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/ip-intelligence", () => ({
|
||||
getIpIntelligence: async () => ({ classification: "clear" }),
|
||||
toAuditIpData: () => ({ classification: "clear" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
const messages = {
|
||||
registrationMessage: "Register {player} in {group}.",
|
||||
groupAccessDeniedMessage: "Disabled {player} in {group}.",
|
||||
vpnDeniedMessage: "Network denied for {player} in {group}.",
|
||||
scheduledAccessDeniedMessage: "Scheduled {player} in {group}: {next_start} / {next_end}.",
|
||||
};
|
||||
|
||||
function utcMinuteOfWeek(value: Date) {
|
||||
return ((value.getUTCDay() + 6) % 7) * 1440 + value.getUTCHours() * 60 + value.getUTCMinutes();
|
||||
}
|
||||
|
||||
function normalized(value: number) {
|
||||
return (value + 10080) % 10080;
|
||||
}
|
||||
|
||||
function request() {
|
||||
return new Request("http://localhost/api/velocity/access", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer route-secret", "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
requestId: "11111111-1111-4111-8111-111111111111",
|
||||
serverId: "velocity-main",
|
||||
minecraftUuid: "0123456789abcdef0123456789abcdef",
|
||||
username: "AlexMC",
|
||||
ipAddress: "203.0.113.10",
|
||||
occurredAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function arrange(group: { accessEnabled: boolean; anonymizedNetworksAllowed: boolean }, windows: Array<{ startMinuteOfWeek: number; endMinuteOfWeek: number }>) {
|
||||
databaseState.responses = [
|
||||
[{ secretHash: hashToken("route-secret") }],
|
||||
[messages],
|
||||
[{ id: "account-id", userId: "user-id", minecraftUuid: "0123456789abcdef0123456789abcdef", username: "AlexMC" }],
|
||||
[{ id: "group-id", name: "Friday friends", ...group }],
|
||||
[{ id: "everyone-id", name: "everyone", accessEnabled: false, anonymizedNetworksAllowed: false }],
|
||||
windows,
|
||||
];
|
||||
}
|
||||
|
||||
describe("Velocity scheduled admission integration", () => {
|
||||
beforeEach(() => {
|
||||
databaseState.responses = [];
|
||||
databaseState.inserted = [];
|
||||
databaseState.isolationLevel = "";
|
||||
});
|
||||
|
||||
it("loads effective-group windows and returns a rendered schedule denial", async () => {
|
||||
const minute = utcMinuteOfWeek(new Date());
|
||||
arrange(
|
||||
{ accessEnabled: true, anonymizedNetworksAllowed: false },
|
||||
[{ startMinuteOfWeek: normalized(minute + 60), endMinuteOfWeek: normalized(minute + 120) }],
|
||||
);
|
||||
|
||||
const response = await POST(request());
|
||||
const body = await response.json();
|
||||
expect(body.allowed).toBe(false);
|
||||
expect(body.message).toMatch(/^Scheduled AlexMC in Friday friends: .* UTC \/ .* UTC\.$/);
|
||||
expect(databaseState.isolationLevel).toBe("repeatable read");
|
||||
expect(databaseState.inserted).toContainEqual(expect.objectContaining({
|
||||
type: "games.minecraft.account-manager.game.login.denied",
|
||||
data: expect.objectContaining({ reason: "schedule_disallowed", accessGroup: "Friday friends" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("fails closed for malformed persisted windows while disabled access retains precedence", async () => {
|
||||
const malformed = [
|
||||
{ startMinuteOfWeek: 100, endMinuteOfWeek: 200 },
|
||||
{ startMinuteOfWeek: 150, endMinuteOfWeek: 250 },
|
||||
];
|
||||
arrange({ accessEnabled: true, anonymizedNetworksAllowed: true }, malformed);
|
||||
expect(await (await POST(request())).json()).toMatchObject({ allowed: false, message: expect.stringContaining("unavailable") });
|
||||
expect(databaseState.inserted).toContainEqual(expect.objectContaining({
|
||||
data: expect.objectContaining({ reason: "schedule_disallowed" }),
|
||||
}));
|
||||
|
||||
databaseState.inserted = [];
|
||||
arrange({ accessEnabled: false, anonymizedNetworksAllowed: true }, malformed);
|
||||
expect(await (await POST(request())).json()).toEqual({ allowed: false, message: "Disabled AlexMC in Friday friends." });
|
||||
expect(databaseState.inserted).toContainEqual(expect.objectContaining({
|
||||
data: expect.objectContaining({ reason: "group_access_disabled" }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { gameAdmissionDenialReason, isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
|
||||
import { isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
|
||||
import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
|
||||
import {
|
||||
appSettings,
|
||||
events,
|
||||
groupAccessWindows,
|
||||
groups,
|
||||
ipObservations,
|
||||
minecraftAccounts,
|
||||
@@ -13,9 +14,10 @@ import {
|
||||
} from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES } from "@/lib/admission-settings";
|
||||
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, renderAdmissionMessage } from "@/lib/admission-settings";
|
||||
import { db } from "@/lib/database";
|
||||
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
||||
import { evaluateRegisteredPlayerAdmission } from "@/lib/game-admission-policy";
|
||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||
@@ -115,10 +117,12 @@ async function handleVelocityAccess(request: Request) {
|
||||
registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage,
|
||||
groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage,
|
||||
vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage,
|
||||
scheduledAccessDeniedMessage: settings?.scheduledAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.scheduledAccessDeniedMessage,
|
||||
};
|
||||
|
||||
const intelligence = await getIpIntelligence(input.ipAddress);
|
||||
const auditIpData = toAuditIpData(intelligence);
|
||||
const decisionAt = new Date();
|
||||
|
||||
const decision = await db.transaction(async (tx) => {
|
||||
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
|
||||
@@ -187,7 +191,13 @@ async function handleVelocityAccess(request: Request) {
|
||||
classification: intelligence.classification,
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
return { allowed: false as const, message: admissionDenialMessage("not_registered", admissionMessages) };
|
||||
return {
|
||||
allowed: false as const,
|
||||
message: renderAdmissionMessage(admissionDenialMessage("not_registered", admissionMessages), {
|
||||
player: input.username,
|
||||
group: "everyone",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const [explicitGroup] = await tx
|
||||
@@ -202,9 +212,22 @@ async function handleVelocityAccess(request: Request) {
|
||||
.where(eq(groups.isDefault, true))
|
||||
.limit(1);
|
||||
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
|
||||
|
||||
const denialReason = gameAdmissionDenialReason(effectiveGroup ?? null, intelligence.classification);
|
||||
if (denialReason) {
|
||||
const accessWindows = effectiveGroup
|
||||
? await tx.select({
|
||||
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
|
||||
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
|
||||
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, effectiveGroup.id))
|
||||
: [];
|
||||
const policyDecision = evaluateRegisteredPlayerAdmission({
|
||||
group: effectiveGroup ?? null,
|
||||
windows: accessWindows,
|
||||
classification: intelligence.classification,
|
||||
now: decisionAt,
|
||||
player: input.username,
|
||||
messages: admissionMessages,
|
||||
});
|
||||
if (!policyDecision.allowed) {
|
||||
const denialReason = policyDecision.reason;
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: `/velocity/${input.serverId}`,
|
||||
@@ -217,6 +240,10 @@ async function handleVelocityAccess(request: Request) {
|
||||
reason: denialReason,
|
||||
accessGroup: effectiveGroup?.name ?? null,
|
||||
accessGroupId: effectiveGroup?.id ?? null,
|
||||
nextScheduleWindow: policyDecision.nextWindow ? {
|
||||
start: policyDecision.nextWindow.start.toISOString(),
|
||||
end: policyDecision.nextWindow.end.toISOString(),
|
||||
} : null,
|
||||
ipIntelligence: auditIpData,
|
||||
},
|
||||
ipAddress: input.ipAddress,
|
||||
@@ -234,7 +261,7 @@ async function handleVelocityAccess(request: Request) {
|
||||
});
|
||||
return {
|
||||
allowed: false as const,
|
||||
message: admissionDenialMessage(denialReason, admissionMessages),
|
||||
message: policyDecision.message,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -297,7 +324,7 @@ async function handleVelocityAccess(request: Request) {
|
||||
});
|
||||
|
||||
return { allowed: true as const, message: "Account approved." };
|
||||
});
|
||||
}, { isolationLevel: "repeatable read" });
|
||||
|
||||
return NextResponse.json(decision);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user