From b88097c15a527f526f147f03004bf788567f051e Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 1 Aug 2026 18:36:45 -0400 Subject: [PATCH] feat(groups): add fail-closed admission management --- apps/web/src/app/account/page.tsx | 26 +- .../admin/(console)/groups/[groupId]/page.tsx | 92 ++ .../src/app/admin/(console)/groups/actions.ts | 104 ++ .../src/app/admin/(console)/groups/page.tsx | 78 + apps/web/src/app/admin/(console)/layout.tsx | 3 +- apps/web/src/app/admin/(console)/page.tsx | 8 +- .../admin/(console)/users/[userId]/page.tsx | 25 +- .../src/app/admin/(console)/users/page.tsx | 4 +- apps/web/src/app/api/velocity/access/route.ts | 43 +- apps/web/src/app/icon.svg | 7 + apps/web/src/app/layout.tsx | 2 +- apps/web/src/app/page.tsx | 4 +- apps/web/src/lib/audit.ts | 15 +- apps/web/src/lib/discord-identity.ts | 33 + design/index.md | 1 + design/log.md | 1 + design/us-001-discord-entry.md | 4 +- design/us-005-user-dashboard.md | 4 +- design/us-009-velocity-admission.md | 6 +- design/us-012-admin-operations.md | 4 +- design/us-013-admin-user-management.md | 5 +- design/us-017-group-access.md | 44 + docs/security-review.md | 4 +- packages/auth/src/index.ts | 4 + packages/auth/test/group-access.test.ts | 12 + .../drizzle/0002_simple_queen_noir.sql | 25 + .../database/drizzle/meta/0002_snapshot.json | 1277 +++++++++++++++++ packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/auth-repository.ts | 1 + packages/database/src/schema.ts | 34 + packages/minecraft/src/index.ts | 40 + packages/minecraft/test/discord.test.ts | 22 +- 32 files changed, 1910 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx create mode 100644 apps/web/src/app/admin/(console)/groups/actions.ts create mode 100644 apps/web/src/app/admin/(console)/groups/page.tsx create mode 100644 apps/web/src/app/icon.svg create mode 100644 apps/web/src/lib/discord-identity.ts create mode 100644 design/us-017-group-access.md create mode 100644 packages/auth/test/group-access.test.ts create mode 100644 packages/database/drizzle/0002_simple_queen_noir.sql create mode 100644 packages/database/drizzle/meta/0002_snapshot.json diff --git a/apps/web/src/app/account/page.tsx b/apps/web/src/app/account/page.tsx index 71775a1..a7a164e 100644 --- a/apps/web/src/app/account/page.tsx +++ b/apps/web/src/app/account/page.tsx @@ -1,10 +1,11 @@ import { formatDiscordNickname } from "@minecraft-account-manager/minecraft"; -import { ipIntelligence, ipObservations, minecraftAccounts } from "@minecraft-account-manager/database"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships } from "@minecraft-account-manager/database"; +import { and, desc, eq, isNull, or } from "drizzle-orm"; import { logout } from "@/app/auth/actions"; import { db } from "@/lib/database"; import { requireCurrentUser } from "@/lib/auth/user-session"; import { groupAccessAddresses } from "@/lib/access-address-groups"; +import { discordIdentity } from "@/lib/discord-identity"; import { intelligenceSummary } from "@/lib/event-ip-summary"; import { addMinecraftAccount, @@ -38,7 +39,7 @@ export default async function AccountPage({ const query = await searchParams; const error = queryValue(query.error); const unverified = queryValue(query.unverified); - const [accounts, observations] = await Promise.all([ + const [accounts, observations, discord, accessGroups] = await Promise.all([ db .select() .from(minecraftAccounts) @@ -58,6 +59,12 @@ export default async function AccountPage({ .where(eq(ipObservations.userId, user.id)) .orderBy(desc(ipObservations.observedAt)) .limit(100), + discordIdentity(user), + db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, isDefault: groups.isDefault }) + .from(groups) + .leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id)) + .where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id))) + .orderBy(desc(groups.isDefault), groups.name), ]); const addressGroups = groupAccessAddresses(observations); const primary = accounts.find((account) => account.isPrimary); @@ -209,12 +216,25 @@ export default async function AccountPage({ diff --git a/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx b/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx new file mode 100644 index 0000000..80e97fc --- /dev/null +++ b/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx @@ -0,0 +1,92 @@ +import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database"; +import { asc, eq } from "drizzle-orm"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { db } from "@/lib/database"; +import { addGroupMember, removeGroupMember, setGroupAccess } from "../actions"; + +const savedMessages: Record = { + created: "Group created with access disabled.", + access: "Group access policy updated.", + "member-added": "User added to the group.", + "member-removed": "User removed from the group.", +}; + +export default async function GroupPage({ + params, + searchParams, +}: { + params: Promise<{ groupId: string }>; + searchParams: Promise<{ saved?: string }>; +}) { + const { groupId } = await params; + const query = await searchParams; + const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1); + if (!group) notFound(); + + const [allUsers, memberships] = await Promise.all([ + db.select({ + id: users.id, + firstName: users.firstName, + discordUsername: users.discordUsername, + discordGlobalName: users.discordGlobalName, + discordUserId: users.discordUserId, + }).from(users).orderBy(asc(users.discordUsername)), + db.select({ userId: userGroupMemberships.userId }).from(userGroupMemberships) + .where(eq(userGroupMemberships.groupId, group.id)), + ]); + const memberIds = new Set(memberships.map((membership) => membership.userId)); + + return ( +
+ ← All groups +
+
+

Access group

+

{group.name}

{group.isDefault && Default}
+

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

+
+
+ + +

Minecraft admission

+

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

+ +
+
+ + {query.saved &&

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

} + +
+
+

Membership

Registered users

+ {group.isDefault ? allUsers.length : memberIds.size} members +
+ {group.isDefault &&

Membership in everyone is automatic and cannot be removed.

} +
+ {allUsers.map((user) => { + const isMember = group.isDefault || memberIds.has(user.id); + return ( +
+
+ {user.firstName ?? user.discordGlobalName ?? user.discordUsername} +

@{user.discordUsername} · {user.discordUserId}

+
+ {group.isDefault ? ( + Automatic member + ) : ( +
+ + + +
+ )} +
+ ); + })} + {!allUsers.length &&

No registered users yet.

} +
+
+
+ ); +} diff --git a/apps/web/src/app/admin/(console)/groups/actions.ts b/apps/web/src/app/admin/(console)/groups/actions.ts new file mode 100644 index 0000000..5a6067a --- /dev/null +++ b/apps/web/src/app/admin/(console)/groups/actions.ts @@ -0,0 +1,104 @@ +"use server"; + +import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database"; +import { and, eq } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import { recordAdminSubjectEvent } from "@/lib/audit"; +import { requireAdminSession } from "@/lib/auth/require-admin"; +import { db } from "@/lib/database"; + +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; +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +function groupPath(groupId: string, query?: string) { + return `/admin/groups/${encodeURIComponent(groupId)}${query ? `?${query}` : ""}`; +} + +export async function createGroup(formData: FormData) { + const admin = await requireAdminSession(); + const name = String(formData.get("name") ?? "").trim(); + const slug = String(formData.get("slug") ?? "").trim().toLowerCase(); + const description = String(formData.get("description") ?? "").trim(); + if (name.length < 1 || name.length > 50 || !SLUG_PATTERN.test(slug) || slug.length > 50 || description.length > 500) { + redirect("/admin/groups?error=invalid-group"); + } + + let group: { id: string } | undefined; + try { + [group] = await db.insert(groups).values({ + name, + slug, + description: description || null, + accessEnabled: false, + isDefault: false, + }).returning({ id: groups.id }); + } catch { + redirect("/admin/groups?error=duplicate-group"); + } + if (!group) redirect("/admin/groups?error=create-failed"); + + await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.created", { + name, + slug, + accessEnabled: false, + }); + redirect(groupPath(group.id, "saved=created")); +} + +export async function setGroupAccess(formData: FormData) { + const admin = await requireAdminSession(); + const groupId = String(formData.get("groupId") ?? ""); + const accessEnabled = formData.get("accessEnabled") === "yes"; + if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group"); + + const [group] = await db.update(groups).set({ accessEnabled, updatedAt: new Date() }) + .where(eq(groups.id, groupId)).returning({ id: groups.id, name: groups.name }); + if (!group) redirect("/admin/groups?error=unknown-group"); + + await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.access-updated", { + name: group.name, + accessEnabled, + }); + redirect(groupPath(group.id, "saved=access")); +} + +export async function addGroupMember(formData: FormData) { + const admin = await requireAdminSession(); + const groupId = String(formData.get("groupId") ?? ""); + const userId = String(formData.get("userId") ?? ""); + if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership"); + + const [[group], [user]] = await Promise.all([ + db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1), + db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1), + ]); + if (!group || !user || group.isDefault) redirect("/admin/groups?error=invalid-membership"); + + await db.insert(userGroupMemberships).values({ groupId: group.id, userId: user.id }).onConflictDoNothing(); + await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.member-added", { + groupId: group.id, + groupName: group.name, + }); + redirect(groupPath(group.id, "saved=member-added")); +} + +export async function removeGroupMember(formData: FormData) { + const admin = await requireAdminSession(); + const groupId = String(formData.get("groupId") ?? ""); + const userId = String(formData.get("userId") ?? ""); + if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership"); + + const [group] = await db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }) + .from(groups).where(eq(groups.id, groupId)).limit(1); + if (!group || group.isDefault) redirect("/admin/groups?error=invalid-membership"); + + await db.delete(userGroupMemberships).where(and( + eq(userGroupMemberships.groupId, group.id), + eq(userGroupMemberships.userId, userId), + )); + await recordAdminSubjectEvent(admin, `user/${userId}`, "games.minecraft.account-manager.group.member-removed", { + groupId: group.id, + groupName: group.name, + }); + redirect(groupPath(group.id, "saved=member-removed")); +} diff --git a/apps/web/src/app/admin/(console)/groups/page.tsx b/apps/web/src/app/admin/(console)/groups/page.tsx new file mode 100644 index 0000000..a859b2d --- /dev/null +++ b/apps/web/src/app/admin/(console)/groups/page.tsx @@ -0,0 +1,78 @@ +import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database"; +import { asc, desc } from "drizzle-orm"; +import Link from "next/link"; +import { db } from "@/lib/database"; +import { createGroup, setGroupAccess } from "./actions"; + +const errors: Record = { + "invalid-group": "Enter a name and a lowercase slug containing letters, numbers, or hyphens.", + "duplicate-group": "That group slug already exists.", + "create-failed": "The group could not be created.", + "unknown-group": "That group no longer exists.", + "invalid-membership": "That membership change was invalid.", +}; + +export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) { + const query = await searchParams; + const [allGroups, memberships, registeredUsers] = await Promise.all([ + db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)), + db.select({ groupId: userGroupMemberships.groupId }).from(userGroupMemberships), + db.select({ id: users.id }).from(users), + ]); + const membershipCounts = new Map(); + for (const membership of memberships) { + membershipCounts.set(membership.groupId, (membershipCounts.get(membership.groupId) ?? 0) + 1); + } + + return ( +
+
+

Admission policy

+

Access groups

+

Every registered user belongs to everyone. A player can join only when at least one of their groups has access enabled.

+
+ + {query.error &&

{errors[query.error] ?? "The group operation failed."}

} + +
+ {allGroups.map((group) => { + const memberCount = group.isDefault ? registeredUsers.length : membershipCounts.get(group.id) ?? 0; + return ( +
+
+
+
+

{group.name}

+ {group.isDefault && Default} +
+

{group.slug} · {memberCount} members

+
+ {group.accessEnabled ? "Access on" : "Access off"} +
+

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

+
+ Manage members +
+ + + +
+
+
+ ); + })} +
+ +
+

Create a group

+
+ + +
+