feat(groups): add fail-closed admission management
This commit is contained in:
@@ -111,6 +111,10 @@ export function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function enabledAccessGroup<T extends { accessEnabled: boolean }>(assignedGroups: T[]) {
|
||||
return assignedGroups.find((group) => group.accessEnabled) ?? null;
|
||||
}
|
||||
|
||||
export function verifyHashedToken(providedToken: string, expectedHash: string) {
|
||||
const provided = Buffer.from(hashToken(providedToken), "utf8");
|
||||
const expected = Buffer.from(expectedHash, "utf8");
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { enabledAccessGroup } from "../src/index";
|
||||
|
||||
describe("group-based admission", () => {
|
||||
it("denies default-off users and allows access when any assigned group is enabled", () => {
|
||||
expect(enabledAccessGroup([{ name: "everyone", accessEnabled: false }])).toBeNull();
|
||||
expect(enabledAccessGroup([
|
||||
{ name: "everyone", accessEnabled: false },
|
||||
{ name: "ops", accessEnabled: true },
|
||||
])).toEqual({ name: "ops", accessEnabled: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "groups" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(50) NOT NULL,
|
||||
"slug" varchar(50) NOT NULL,
|
||||
"description" text,
|
||||
"access_enabled" boolean DEFAULT false NOT NULL,
|
||||
"is_default" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_group_memberships" (
|
||||
"user_id" uuid NOT NULL,
|
||||
"group_id" uuid NOT NULL,
|
||||
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_group_memberships" ADD CONSTRAINT "user_group_memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_group_memberships" ADD CONSTRAINT "user_group_memberships_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "groups_slug_uidx" ON "groups" USING btree (lower("slug"));--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "groups_one_default_uidx" ON "groups" USING btree ("is_default") WHERE "groups"."is_default" = true;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "user_group_memberships_user_group_uidx" ON "user_group_memberships" USING btree ("user_id","group_id");--> statement-breakpoint
|
||||
CREATE INDEX "user_group_memberships_group_idx" ON "user_group_memberships" USING btree ("group_id");--> statement-breakpoint
|
||||
INSERT INTO "groups" ("name", "slug", "description", "access_enabled", "is_default")
|
||||
VALUES ('everyone', 'everyone', 'Default group containing every registered user.', false, true);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1785605590058,
|
||||
"tag": "0001_silent_ultragirl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1785623198008,
|
||||
"tag": "0002_simple_queen_noir",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -113,6 +113,7 @@ export async function findUserBySessionToken(db: Database, tokenHash: string, no
|
||||
id: users.id,
|
||||
discordUserId: users.discordUserId,
|
||||
discordUsername: users.discordUsername,
|
||||
discordGlobalName: users.discordGlobalName,
|
||||
firstName: users.firstName,
|
||||
onboardingCompletedAt: users.onboardingCompletedAt,
|
||||
sessionExpiresAt: sessions.expiresAt,
|
||||
|
||||
@@ -62,6 +62,40 @@ export const users = pgTable(
|
||||
(table) => [uniqueIndex("users_discord_user_id_uidx").on(table.discordUserId)],
|
||||
);
|
||||
|
||||
export const groups = pgTable(
|
||||
"groups",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: varchar("name", { length: 50 }).notNull(),
|
||||
slug: varchar("slug", { length: 50 }).notNull(),
|
||||
description: text("description"),
|
||||
accessEnabled: boolean("access_enabled").notNull().default(false),
|
||||
isDefault: boolean("is_default").notNull().default(false),
|
||||
...timestamps(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("groups_slug_uidx").on(sql`lower(${table.slug})`),
|
||||
uniqueIndex("groups_one_default_uidx").on(table.isDefault).where(sql`${table.isDefault} = true`),
|
||||
],
|
||||
);
|
||||
|
||||
export const userGroupMemberships = pgTable(
|
||||
"user_group_memberships",
|
||||
{
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
groupId: uuid("group_id")
|
||||
.notNull()
|
||||
.references(() => groups.id, { onDelete: "cascade" }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("user_group_memberships_user_group_uidx").on(table.userId, table.groupId),
|
||||
index("user_group_memberships_group_idx").on(table.groupId),
|
||||
],
|
||||
);
|
||||
|
||||
export const minecraftAccounts = pgTable(
|
||||
"minecraft_accounts",
|
||||
{
|
||||
|
||||
@@ -46,6 +46,46 @@ export function formatManagedDiscordNickname(
|
||||
return [...firstName.trim()].slice(0, DISCORD_NICKNAME_LIMIT).join("").trimEnd();
|
||||
}
|
||||
|
||||
export interface DiscordGuildIdentity {
|
||||
id: string;
|
||||
username: string;
|
||||
globalName: string | null;
|
||||
nickname: string | null;
|
||||
}
|
||||
|
||||
export async function getGuildMemberIdentity(
|
||||
input: { guildId: string; discordUserId: string; botToken: string },
|
||||
request: typeof fetch = fetch,
|
||||
): Promise<DiscordGuildIdentity> {
|
||||
const response = await request(
|
||||
`https://discord.com/api/v10/guilds/${input.guildId}/members/${input.discordUserId}`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bot ${input.botToken}`,
|
||||
accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`Discord guild member lookup failed (${response.status})`);
|
||||
|
||||
const payload: unknown = await response.json();
|
||||
if (!payload || typeof payload !== "object") throw new Error("Discord guild member lookup returned invalid data");
|
||||
const member = payload as { nick?: unknown; user?: unknown };
|
||||
if (!member.user || typeof member.user !== "object") throw new Error("Discord guild member lookup omitted user data");
|
||||
const user = member.user as { id?: unknown; username?: unknown; global_name?: unknown };
|
||||
if (typeof user.id !== "string" || typeof user.username !== "string") {
|
||||
throw new Error("Discord guild member lookup returned invalid user data");
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
globalName: typeof user.global_name === "string" ? user.global_name : null,
|
||||
nickname: typeof member.nick === "string" ? member.nick : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateGuildNickname(
|
||||
input: {
|
||||
guildId: string;
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { updateGuildNickname } from "../src/index";
|
||||
import { getGuildMemberIdentity, updateGuildNickname } from "../src/index";
|
||||
|
||||
describe("Discord guild identity", () => {
|
||||
it("reads the member username, global name, nickname, and immutable ID", async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
|
||||
nick: "Sam (Notch)",
|
||||
user: { id: "987654321098765432", username: "samcraft", global_name: "Sam" },
|
||||
}), { status: 200, headers: { "content-type": "application/json" } }));
|
||||
|
||||
await expect(getGuildMemberIdentity({
|
||||
guildId: "123456789012345678",
|
||||
discordUserId: "987654321098765432",
|
||||
botToken: "secret",
|
||||
}, request)).resolves.toEqual({
|
||||
id: "987654321098765432",
|
||||
username: "samcraft",
|
||||
globalName: "Sam",
|
||||
nickname: "Sam (Notch)",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Discord nickname updates", () => {
|
||||
it("updates a member in the configured guild using bot authentication", async () => {
|
||||
|
||||
Reference in New Issue
Block a user