diff --git a/.env.example b/.env.example index 6464724..bb0f0ea 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,10 @@ CONVEX_DEPLOY_KEY= AUTH_GOOGLE_ID= AUTH_GOOGLE_SECRET= +# Comma-separated Google account emails allowed to access this private deployment. +# Example: NUDGRA_ALLOWED_EMAILS=operator@example.com,backup@example.com +NUDGRA_ALLOWED_EMAILS= + # Instagram / Meta. # Set these on the Convex deployment for production. META_APP_ID= diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index 2b2823d..dc630bf 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -1,7 +1,8 @@ "use client"; import Link from "next/link"; -import { useConvexAuth, useMutation } from "convex/react"; +import { useAuthActions } from "@convex-dev/auth/react"; +import { useConvexAuth, useMutation, useQuery } from "convex/react"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { Menu } from "lucide-react"; @@ -15,9 +16,16 @@ export default function DashboardLayout({ children: React.ReactNode; }) { const { isAuthenticated, isLoading } = useConvexAuth(); + const { signOut } = useAuthActions(); const router = useRouter(); const ensureWorkspace = useMutation(api.workspaces.ensureCurrentWorkspace); + const accessStatus = useQuery( + api.workspaces.getOperatorAccessStatus, + isAuthenticated ? {} : "skip", + ); const [sidebarOpen, setSidebarOpen] = useState(false); + const isAccessLoading = isAuthenticated && accessStatus === undefined; + const isAllowed = accessStatus?.isAllowed === true; useEffect(() => { if (!isLoading && !isAuthenticated) { @@ -26,12 +34,12 @@ export default function DashboardLayout({ }, [isAuthenticated, isLoading, router]); useEffect(() => { - if (!isLoading && isAuthenticated) { + if (!isLoading && isAuthenticated && isAllowed) { void ensureWorkspace({}); } - }, [ensureWorkspace, isAuthenticated, isLoading]); + }, [ensureWorkspace, isAllowed, isAuthenticated, isLoading]); - if (isLoading || !isAuthenticated) { + if (isLoading || !isAuthenticated || isAccessLoading) { return (
@@ -47,6 +55,31 @@ export default function DashboardLayout({ ); } + if (!isAllowed) { + return ( +
+
+ +

+ Access denied +

+

+ {accessStatus?.email + ? `${accessStatus.email} is not allowed to access this Nudgra deployment.` + : "This Google account is not allowed to access this Nudgra deployment."} +

+ +
+
+ ); + } + return (
{ + await requireAllowedOperatorUserId(ctx, userId); + }, + }, }); diff --git a/convex/lib/auth.ts b/convex/lib/auth.ts index ab4f120..ee87273 100644 --- a/convex/lib/auth.ts +++ b/convex/lib/auth.ts @@ -1,6 +1,7 @@ import { getAuthUserId } from "@convex-dev/auth/server"; import { Doc, Id } from "../_generated/dataModel"; import { MutationCtx, QueryCtx } from "../_generated/server"; +import { requireAllowedOperatorUserId } from "./operatorAccess"; type DbCtx = QueryCtx | MutationCtx; @@ -53,6 +54,7 @@ export async function requireCurrentUserId(ctx: DbCtx): Promise> { if (userId === null) { throw new Error("You must be signed in to access this workspace."); } + await requireAllowedOperatorUserId(ctx, userId); return userId; } @@ -63,6 +65,7 @@ export async function getCurrentWorkspace( if (userId === null) { return null; } + await requireAllowedOperatorUserId(ctx, userId); return await ctx.db .query("workspaces") .withIndex("by_owner_user_id", (q) => q.eq("ownerUserId", userId)) @@ -156,6 +159,7 @@ export async function getSelectedWorkspaceInstagramAccount( if (userId === null) { return null; } + await requireAllowedOperatorUserId(ctx, userId); const [preference, accounts] = await Promise.all([ getWorkspaceUserPreference(ctx, workspaceId, userId), diff --git a/convex/lib/operatorAccess.ts b/convex/lib/operatorAccess.ts new file mode 100644 index 0000000..2569a01 --- /dev/null +++ b/convex/lib/operatorAccess.ts @@ -0,0 +1,51 @@ +import { Id } from "../_generated/dataModel"; +import { MutationCtx, QueryCtx } from "../_generated/server"; + +type DbCtx = QueryCtx | MutationCtx; + +export const OPERATOR_ACCESS_DENIED_MESSAGE = + "This Google account is not allowed to access this Nudgra deployment."; + +export function normalizeOperatorEmail(email: string) { + return email.trim().toLowerCase(); +} + +export function getAllowedOperatorEmails() { + return new Set( + (process.env.NUDGRA_ALLOWED_EMAILS ?? "") + .split(",") + .map(normalizeOperatorEmail) + .filter((email) => email.length > 0), + ); +} + +export function isOperatorEmailAllowed(email: string | null | undefined) { + if (!email) { + return false; + } + + return getAllowedOperatorEmails().has(normalizeOperatorEmail(email)); +} + +export async function getOperatorAccessForUserId( + ctx: DbCtx, + userId: Id<"users">, +) { + const user = await ctx.db.get(userId); + const email = typeof user?.email === "string" ? user.email : null; + + return { + allowed: isOperatorEmailAllowed(email), + email, + }; +} + +export async function requireAllowedOperatorUserId( + ctx: DbCtx, + userId: Id<"users">, +) { + const access = await getOperatorAccessForUserId(ctx, userId); + if (!access.allowed) { + throw new Error(OPERATOR_ACCESS_DENIED_MESSAGE); + } +} diff --git a/convex/test.setup.ts b/convex/test.setup.ts index 9bc36e5..b002670 100644 --- a/convex/test.setup.ts +++ b/convex/test.setup.ts @@ -1,5 +1,7 @@ /// +process.env.NUDGRA_ALLOWED_EMAILS ??= "operator@example.com,test@example.com"; + export const modules = import.meta.glob([ "./**/*.ts", "!./test.setup.ts", diff --git a/convex/workspaces.ts b/convex/workspaces.ts index b406e57..e5d7d3f 100644 --- a/convex/workspaces.ts +++ b/convex/workspaces.ts @@ -4,6 +4,8 @@ import { getWorkspaceUserPreference, requireCurrentUserId, } from "./lib/auth"; +import { getOperatorAccessForUserId } from "./lib/operatorAccess"; +import { getAuthUserId } from "@convex-dev/auth/server"; const DEFAULT_TAGS = [ { label: "new", color: "slate" }, @@ -97,3 +99,24 @@ export const getCurrentWorkspaceSummary = query({ }; }, }); + +export const getOperatorAccessStatus = query({ + args: {}, + handler: async (ctx) => { + const userId = await getAuthUserId(ctx); + if (userId === null) { + return { + isAuthenticated: false, + isAllowed: false, + email: null, + }; + } + + const access = await getOperatorAccessForUserId(ctx, userId); + return { + isAuthenticated: true, + isAllowed: access.allowed, + email: access.email, + }; + }, +}); diff --git a/tests/operator-access.test.ts b/tests/operator-access.test.ts new file mode 100644 index 0000000..0d75021 --- /dev/null +++ b/tests/operator-access.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { convexTest } from "convex-test"; +import { api } from "@/convex/_generated/api"; +import type { Id } from "@/convex/_generated/dataModel"; +import schema from "@/convex/schema"; +import { modules } from "@/convex/test.setup"; + +const DEFAULT_ALLOWED_EMAIL = "operator@example.com"; +const DEFAULT_ALLOWED_EMAILS = "operator@example.com,test@example.com"; + +async function seedUser( + t: ReturnType, + args: { email: string; name?: string }, +) { + return await t.run(async (ctx) => { + return await ctx.db.insert("users", { + name: args.name ?? "Operator", + email: args.email, + }); + }); +} + +async function seedWorkspaceWithRule( + t: ReturnType, + userId: Id<"users">, +) { + return await t.run(async (ctx) => { + const workspaceId = await ctx.db.insert("workspaces", { + ownerUserId: userId, + name: "Workspace", + timezone: "UTC", + }); + const accountId = await ctx.db.insert("instagramAccounts", { + workspaceId, + instagramAccountId: "ig_operator_access", + metaUserId: null, + username: "operator", + name: "Operator", + profilePictureUrl: null, + accountType: "business", + status: "connected", + graphAccessToken: "token", + tokenExpiresAt: Date.now() + 60 * 24 * 60 * 60 * 1000, + scopes: [], + webhookSubscriptionStatus: "active", + lastWebhookAt: null, + lastError: null, + connectedAt: Date.now(), + disconnectedAt: null, + graphApiVersion: "v23.0", + }); + const ruleId = await ctx.db.insert("automationRules", { + workspaceId, + instagramAccountId: accountId, + name: "Operator access rule", + triggerType: "keyword", + matchType: "contains", + keywords: ["guide"], + replyText: "Here is the guide.", + linkDmText: "Here is the guide.", + linkButtons: [], + followGateEnabled: false, + followGateText: "", + emailCollectionEnabled: false, + emailCollectionText: "", + followUpEnabled: false, + followUpText: "", + isActive: true, + tagIds: [], + sequenceDefinitionId: null, + createdByUserId: userId, + triggerCount: 0, + lastTriggeredAt: null, + }); + + return { workspaceId, accountId, ruleId }; + }); +} + +describe("operator access allowlist", () => { + afterEach(() => { + process.env.NUDGRA_ALLOWED_EMAILS = DEFAULT_ALLOWED_EMAILS; + }); + + it("allows an allowlisted Google email to create and read the current workspace", async () => { + process.env.NUDGRA_ALLOWED_EMAILS = "operator@example.com, backup@example.com"; + const t = convexTest({ schema, modules }); + const userId = await seedUser(t, { email: DEFAULT_ALLOWED_EMAIL }); + const authT = t.withIdentity({ subject: userId }); + + const result = await authT.mutation(api.workspaces.ensureCurrentWorkspace, {}); + const summary = await authT.query(api.workspaces.getCurrentWorkspaceSummary, {}); + + expect(result.created).toBe(true); + expect(summary).toMatchObject({ + id: result.workspaceId, + name: "Operator", + timezone: "UTC", + }); + }); + + it("rejects a signed-in Google account that is not in the allowlist", async () => { + process.env.NUDGRA_ALLOWED_EMAILS = DEFAULT_ALLOWED_EMAIL; + const t = convexTest({ schema, modules }); + const userId = await seedUser(t, { email: "intruder@example.com" }); + const authT = t.withIdentity({ subject: userId }); + + await expect( + authT.mutation(api.workspaces.ensureCurrentWorkspace, {}), + ).rejects.toThrow("not allowed"); + + const access = await authT.query(api.workspaces.getOperatorAccessStatus, {}); + expect(access).toEqual({ + isAuthenticated: true, + isAllowed: false, + email: "intruder@example.com", + }); + }); + + it("blocks existing unauthorized sessions from reading, mutating, and deleting workspace resources", async () => { + process.env.NUDGRA_ALLOWED_EMAILS = DEFAULT_ALLOWED_EMAIL; + const t = convexTest({ schema, modules }); + const userId = await seedUser(t, { email: "former-operator@example.com" }); + const fixture = await seedWorkspaceWithRule(t, userId); + const authT = t.withIdentity({ subject: userId }); + + await expect( + authT.query(api.workspaces.getCurrentWorkspaceSummary, {}), + ).rejects.toThrow("not allowed"); + await expect( + authT.mutation(api.workspaces.ensureCurrentWorkspace, {}), + ).rejects.toThrow("not allowed"); + await expect( + authT.mutation(api.automations.rules.deleteRule, { + accountId: fixture.accountId, + ruleId: fixture.ruleId, + }), + ).rejects.toThrow("not allowed"); + + const storedRule = await t.run((ctx) => ctx.db.get(fixture.ruleId)); + expect(storedRule).not.toBeNull(); + }); + + it("fails closed when the allowlist is empty or missing", async () => { + const t = convexTest({ schema, modules }); + const userId = await seedUser(t, { email: DEFAULT_ALLOWED_EMAIL }); + const authT = t.withIdentity({ subject: userId }); + + process.env.NUDGRA_ALLOWED_EMAILS = ""; + await expect( + authT.mutation(api.workspaces.ensureCurrentWorkspace, {}), + ).rejects.toThrow("not allowed"); + + delete process.env.NUDGRA_ALLOWED_EMAILS; + await expect( + authT.mutation(api.workspaces.ensureCurrentWorkspace, {}), + ).rejects.toThrow("not allowed"); + }); +});