diff --git a/README.md b/README.md index 6df62ce..ec308cf 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,11 @@ The token is displayed once and stored only as a SHA-256 hash. - PostgreSQL and Drizzle ORM - Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role - Admin user search, account management, event exploration, DAU and confirmed-connection metrics, toggleable Natural Earth/OpenStreetMap user-location views, and automatic Discord nickname synchronization -- Exclusive group admission: unassigned users fall back to protected `everyone`, and only the effective group's access setting applies +- Exclusive group admission: unassigned users fall back to protected `everyone`, and only the effective group's access and VPN/proxy/Tor exception settings apply - Deployment-managed Discord guild ID and invite URL - discord.js bot with `/register` and `/account` - Java Edition online-mode accounts only - Velocity admission checks are fail closed -- ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache +- ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache and group-scoped game-connection exceptions See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, [`docs/api-errors.md`](docs/api-errors.md) for the RFC 9457 API error contract, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements, and [`docs/accessibility.md`](docs/accessibility.md) for the WCAG-oriented interface review. diff --git a/apps/web/src/app/account/page.tsx b/apps/web/src/app/account/page.tsx index 1b2856e..b541205 100644 --- a/apps/web/src/app/account/page.tsx +++ b/apps/web/src/app/account/page.tsx @@ -63,7 +63,7 @@ export default async function AccountPage({ .orderBy(desc(ipObservations.observedAt)) .limit(100), discordIdentity(user), - db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, isDefault: groups.isDefault }) + db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed, isDefault: groups.isDefault }) .from(groups) .leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id)) .where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id))) @@ -192,8 +192,8 @@ export default async function AccountPage({

Access groups

- {effectiveGroup ?
{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}{effectiveGroup.accessEnabled ? "Access on" : "Access off"}
:

No default access group is configured.

} -

Your effective group alone determines Minecraft access.

+ {effectiveGroup ?
{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}
{effectiveGroup.accessEnabled ? "Access on" : "Access off"}VPN {effectiveGroup.anonymizedNetworksAllowed ? "allowed" : "denied"}
:

No default access group is configured.

} +

Your effective group alone determines Minecraft and VPN/proxy/Tor access.

diff --git a/apps/web/src/app/admin/(console)/actions.ts b/apps/web/src/app/admin/(console)/actions.ts index fa07a07..4d1f873 100644 --- a/apps/web/src/app/admin/(console)/actions.ts +++ b/apps/web/src/app/admin/(console)/actions.ts @@ -1,32 +1,44 @@ "use server"; -import { appSettings } from "@minecraft-account-manager/database"; +import { randomUUID } from "node:crypto"; +import { appSettings, events } from "@minecraft-account-manager/database"; +import { getClientIp } from "@minecraft-account-manager/network"; +import { eq } from "drizzle-orm"; +import { headers } from "next/headers"; import { redirect } from "next/navigation"; +import { parseAdmissionMessages } from "@/lib/admission-settings"; import { requireAdminSession } from "@/lib/auth/require-admin"; import { db } from "@/lib/database"; -export async function saveDiscordSettings(formData: FormData) { - await requireAdminSession(); +export async function saveAdmissionSettings(formData: FormData) { + const admin = await requireAdminSession(); + const messages = parseAdmissionMessages(formData); + if (!messages) redirect("/admin/settings?error=invalid-message"); - const registrationMessage = String(formData.get("registrationMessage") ?? "").trim(); - - if (registrationMessage.length < 10 || registrationMessage.length > 500) { - redirect("/admin/settings?error=invalid-message"); - } - - await db - .insert(appSettings) - .values({ - id: "default", - registrationMessage, - }) - .onConflictDoUpdate({ - target: appSettings.id, - set: { - registrationMessage, - updatedAt: new Date(), - }, - }); + const requestHeaders = await headers(); + const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true"); + await db.transaction(async (tx) => { + const [previous] = await tx.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1); + const changedFields = (Object.keys(messages) as Array) + .filter((field) => previous?.[field] !== messages[field]); + await tx.insert(appSettings) + .values({ id: "default", ...messages }) + .onConflictDoUpdate({ + target: appSettings.id, + set: { ...messages, updatedAt: new Date() }, + }); + if (changedFields.length) { + await tx.insert(events).values({ + id: randomUUID(), + source: "/web/admin", + type: "games.minecraft.account-manager.settings.admission-messages-updated", + subject: "settings/default", + time: new Date(), + data: { changedFields, adminEmail: admin.email, adminName: admin.name }, + ipAddress: ipAddress ?? null, + }); + } + }); redirect("/admin/settings?saved=1"); } diff --git a/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx b/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx index 5e3aaff..61dfc33 100644 --- a/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx +++ b/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx @@ -3,11 +3,12 @@ import { asc, eq } from "drizzle-orm"; import Link from "next/link"; import { notFound } from "next/navigation"; import { db } from "@/lib/database"; -import { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess } from "../actions"; +import { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess, setGroupAnonymizedNetworkAccess } from "../actions"; const savedMessages: Record = { created: "Group created with access disabled.", access: "Group access policy updated.", + "network-access": "Group VPN, proxy, and Tor policy updated.", "member-added": "User assigned to the group.", "member-removed": "User returned to the default group.", }; @@ -54,13 +55,22 @@ export default async function GroupPage({

{group.name}

{group.isDefault && Default}

{group.description ?? "No description."}

-
- - -

Minecraft admission

-

{group.accessEnabled ? "Allowed" : "Denied"}

- -
+
+
+ + +

Minecraft admission

+

{group.accessEnabled ? "Allowed" : "Denied"}

+ +
+
+ + +

VPN / proxy / Tor

+

{group.anonymizedNetworksAllowed ? "Allowed" : "Denied"}

+ +
+
{query.saved &&

{savedMessages[query.saved] ?? "Group updated."}

} diff --git a/apps/web/src/app/admin/(console)/groups/actions.ts b/apps/web/src/app/admin/(console)/groups/actions.ts index ca0a82a..95a2a6d 100644 --- a/apps/web/src/app/admin/(console)/groups/actions.ts +++ b/apps/web/src/app/admin/(console)/groups/actions.ts @@ -33,6 +33,7 @@ export async function createGroup(formData: FormData) { slug, description: description || null, accessEnabled: false, + anonymizedNetworksAllowed: false, isDefault: false, }).returning({ id: groups.id }); } catch { @@ -44,6 +45,7 @@ export async function createGroup(formData: FormData) { name, slug, accessEnabled: false, + anonymizedNetworksAllowed: false, }); redirect(groupPath(group.id, "saved=created")); } @@ -65,6 +67,33 @@ export async function setGroupAccess(formData: FormData) { redirect(groupPath(group.id, "saved=access")); } +export async function setGroupAnonymizedNetworkAccess(formData: FormData) { + const admin = await requireAdminSession(); + const groupId = String(formData.get("groupId") ?? ""); + const anonymizedNetworksAllowed = formData.get("anonymizedNetworksAllowed") === "yes"; + if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group"); + const requestHeaders = await headers(); + const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true"); + + const group = await db.transaction(async (tx) => { + const [updated] = await tx.update(groups).set({ anonymizedNetworksAllowed, updatedAt: new Date() }) + .where(eq(groups.id, groupId)).returning({ id: groups.id, name: groups.name }); + if (!updated) return null; + await tx.insert(events).values({ + id: randomUUID(), + source: "/web/admin", + type: "games.minecraft.account-manager.group.anonymized-network-access-updated", + subject: `group/${updated.id}`, + time: new Date(), + data: { name: updated.name, anonymizedNetworksAllowed, adminEmail: admin.email, adminName: admin.name }, + ipAddress: ipAddress ?? null, + }); + return updated; + }); + if (!group) redirect("/admin/groups?error=unknown-group"); + redirect(groupPath(group.id, "saved=network-access")); +} + export async function addGroupMember(formData: FormData) { const admin = await requireAdminSession(); const groupId = String(formData.get("groupId") ?? ""); diff --git a/apps/web/src/app/admin/(console)/groups/page.tsx b/apps/web/src/app/admin/(console)/groups/page.tsx index 8213bbd..6db4d07 100644 --- a/apps/web/src/app/admin/(console)/groups/page.tsx +++ b/apps/web/src/app/admin/(console)/groups/page.tsx @@ -53,7 +53,7 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi

{group.slug} · {memberCount} members

- {group.accessEnabled ? "Access on" : "Access off"} +
{group.accessEnabled ? "Access on" : "Access off"}VPN {group.anonymizedNetworksAllowed ? "allowed" : "denied"}

{group.description ?? "No description."}

@@ -76,7 +76,7 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi