From 7d789d2ca6abd2b0fca5aab430e88cf9c1480878 Mon Sep 17 00:00:00 2001 From: Daniel Genis Date: Mon, 20 Apr 2026 15:13:39 +0200 Subject: [PATCH] feat: add domain-based login restriction and workspace auto-assignment Adds two features for company SSO deployments: 1. Global login domain restriction via ALLOWED_LOGIN_DOMAINS env var - Rejects OAuth logins from domains not in the allowlist - Check happens in OAuth callback before user is created 2. Workspace auto-assignment based on email domain - Workspaces can configure allowedDomains and autoAssignEnabled - New users with matching email domain auto-join the workspace - Falls back to creating personal workspace if no match --- .env.example | 4 ++ ...1776690248_workspace_domain_autoassign.sql | 3 ++ apps/api/src/db/migrations/meta/_journal.json | 7 ++++ apps/api/src/db/schema.ts | 2 + apps/api/src/routes/auth.ts | 14 +++++++ apps/api/src/routes/workspaces.ts | 5 +++ apps/api/src/services/workspace-service.ts | 39 ++++++++++++++++++- apps/web/src/app/login/page.tsx | 4 +- helm/optio/templates/secrets.yaml | 3 ++ helm/optio/values.yaml | 3 ++ packages/shared/src/types/workspace.ts | 2 + 11 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/db/migrations/1776690248_workspace_domain_autoassign.sql diff --git a/.env.example b/.env.example index 344cde91..7bd1ef86 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,10 @@ OPTIO_AUTH_DISABLED=true # GITLAB_OAUTH_CLIENT_ID= # GITLAB_OAUTH_CLIENT_SECRET= +# Restrict OAuth logins to specific email domains (comma-separated) +# Leave empty to allow all domains +# ALLOWED_LOGIN_DOMAINS=gynzy.com,gynzy.nl + # GitHub webhook signature validation (generate with: openssl rand -hex 32) # Must match the secret configured in GitHub's webhook settings # GITHUB_WEBHOOK_SECRET= diff --git a/apps/api/src/db/migrations/1776690248_workspace_domain_autoassign.sql b/apps/api/src/db/migrations/1776690248_workspace_domain_autoassign.sql new file mode 100644 index 00000000..5a7a73b8 --- /dev/null +++ b/apps/api/src/db/migrations/1776690248_workspace_domain_autoassign.sql @@ -0,0 +1,3 @@ +-- Workspace domain-based auto-assignment +ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "allowed_domains" jsonb DEFAULT '[]'::jsonb; +ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "auto_assign_enabled" boolean NOT NULL DEFAULT false; diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index 979eefaa..7549ee48 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -435,6 +435,13 @@ "when": 1776050234000, "tag": "1776050234_auth_events_source", "breakpoints": true + }, + { + "idx": 62, + "version": "7", + "when": 1776690248000, + "tag": "1776690248_workspace_domain_autoassign", + "breakpoints": true } ] } diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index b64dc3f5..a4edc765 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -41,6 +41,8 @@ export const workspaces = pgTable("workspaces", { description: text("description"), createdBy: uuid("created_by"), allowDockerInDocker: boolean("allow_docker_in_docker").notNull().default(false), + allowedDomains: jsonb("allowed_domains").$type().default([]), + autoAssignEnabled: boolean("auto_assign_enabled").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index b11cb6ce..c79a40d8 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -448,6 +448,20 @@ export async function authRoutes(rawApp: FastifyInstance) { try { const tokens = await provider.exchangeCode(code); const profile = await provider.fetchUser(tokens.accessToken); + + // Check domain restriction + const allowedDomains = (process.env.ALLOWED_LOGIN_DOMAINS ?? "") + .split(",") + .map((d) => d.trim().toLowerCase()) + .filter(Boolean); + + if (allowedDomains.length > 0) { + const emailDomain = profile.email?.split("@")[1]?.toLowerCase(); + if (!emailDomain || !allowedDomains.includes(emailDomain)) { + return reply.redirect(`${WEB_URL}/login?error=domain_not_allowed`); + } + } + const session = await createSession(providerName, profile); // Store GitHub App user tokens for git/API operations diff --git a/apps/api/src/routes/workspaces.ts b/apps/api/src/routes/workspaces.ts index c131fa30..616b3304 100644 --- a/apps/api/src/routes/workspaces.ts +++ b/apps/api/src/routes/workspaces.ts @@ -29,6 +29,11 @@ const updateWorkspaceSchema = z .optional(), description: z.string().max(500).nullable().optional(), allowDockerInDocker: z.boolean().optional(), + allowedDomains: z + .array(z.string().regex(/^[a-z0-9]+([-\.][a-z0-9]+)*\.[a-z]{2,}$/i, "Invalid domain format")) + .max(50) + .optional(), + autoAssignEnabled: z.boolean().optional(), }) .describe("Partial update to a workspace"); diff --git a/apps/api/src/services/workspace-service.ts b/apps/api/src/services/workspace-service.ts index 6dcdfe97..6147afa0 100644 --- a/apps/api/src/services/workspace-service.ts +++ b/apps/api/src/services/workspace-service.ts @@ -1,4 +1,4 @@ -import { eq, and } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; import { db } from "../db/client.js"; import { workspaces, workspaceMembers, users } from "../db/schema.js"; import { revokeAllUserSessions } from "./session-service.js"; @@ -58,6 +58,8 @@ export async function updateWorkspace( slug?: string; description?: string | null; allowDockerInDocker?: boolean; + allowedDomains?: string[]; + autoAssignEnabled?: boolean; }, ): Promise { const updates: Record = { updatedAt: new Date() }; @@ -66,6 +68,10 @@ export async function updateWorkspace( if (data.description !== undefined) updates.description = data.description; if (data.allowDockerInDocker !== undefined) updates.allowDockerInDocker = data.allowDockerInDocker; + if (data.allowedDomains !== undefined) { + updates.allowedDomains = data.allowedDomains.map((d) => d.toLowerCase().trim()); + } + if (data.autoAssignEnabled !== undefined) updates.autoAssignEnabled = data.autoAssignEnabled; const [ws] = await db.update(workspaces).set(updates).where(eq(workspaces.id, id)).returning(); return (ws as Workspace) ?? null; @@ -165,6 +171,21 @@ export async function removeMember(workspaceId: string, userId: string): Promise await revokeAllUserSessions(userId); } +/** Find all workspaces that allow auto-assignment for a given email domain. */ +export async function findWorkspacesByDomain(emailDomain: string): Promise { + const rows = await db + .select() + .from(workspaces) + .where( + and( + eq(workspaces.autoAssignEnabled, true), + sql`${workspaces.allowedDomains} ? ${emailDomain.toLowerCase()}`, + ), + ) + .orderBy(workspaces.name); + return rows as Workspace[]; +} + /** * Ensure a user has at least one workspace. If not, create a default one. * Returns the user's default workspace ID. @@ -186,6 +207,22 @@ export async function ensureUserHasWorkspace(userId: string): Promise { return memberships[0].id; } + // Domain-based auto-assignment + const emailDomain = user?.email?.split("@")[1]?.toLowerCase(); + if (emailDomain) { + const matchingWorkspaces = await findWorkspacesByDomain(emailDomain); + if (matchingWorkspaces.length > 0) { + for (const ws of matchingWorkspaces) { + await addMember(ws.id, userId, "member"); + } + await db + .update(users) + .set({ defaultWorkspaceId: matchingWorkspaces[0].id }) + .where(eq(users.id, userId)); + return matchingWorkspaces[0].id; + } + } + // Create a default workspace const ws = await createWorkspace( { name: "Default", slug: `ws-${userId.slice(0, 8)}`, description: "Default workspace" }, diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index 6763a224..d6ffe8cc 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -90,7 +90,9 @@ export default function LoginPage() { ? "Login session expired. Please try again." : error === "missing_params" ? "Missing authorization parameters." - : `Authentication error: ${error}`} + : error === "domain_not_allowed" + ? "Your email domain is not allowed to access this instance." + : `Authentication error: ${error}`} )} diff --git a/helm/optio/templates/secrets.yaml b/helm/optio/templates/secrets.yaml index 4f35791d..2999ad5c 100644 --- a/helm/optio/templates/secrets.yaml +++ b/helm/optio/templates/secrets.yaml @@ -24,6 +24,9 @@ stringData: OPTIO_IMAGE_PULL_POLICY: {{ .Values.agent.imagePullPolicy | quote }} OPTIO_RUNTIME: "kubernetes" OPTIO_AUTH_DISABLED: {{ .Values.auth.disabled | quote }} + {{- if .Values.auth.allowedLoginDomains }} + ALLOWED_LOGIN_DOMAINS: {{ .Values.auth.allowedLoginDomains | quote }} + {{- end }} {{- if .Values.publicUrl }} PUBLIC_URL: {{ .Values.publicUrl | quote }} {{- end }} diff --git a/helm/optio/values.yaml b/helm/optio/values.yaml index 1d72f1cd..80a5a831 100644 --- a/helm/optio/values.yaml +++ b/helm/optio/values.yaml @@ -298,6 +298,9 @@ publicUrl: "" # e.g. https://optio.example.com auth: # Set to true to disable authentication (NOT recommended for production). disabled: false + # Restrict OAuth logins to specific email domains (comma-separated). + # Leave empty to allow all domains. + allowedLoginDomains: "" # e.g., "gynzy.com,gynzy.nl" # OAuth provider credentials — configure at least one when auth is enabled. github: clientId: "" diff --git a/packages/shared/src/types/workspace.ts b/packages/shared/src/types/workspace.ts index 3491b93d..32c92034 100644 --- a/packages/shared/src/types/workspace.ts +++ b/packages/shared/src/types/workspace.ts @@ -7,6 +7,8 @@ export interface Workspace { description?: string | null; createdBy?: string | null; allowDockerInDocker: boolean; + allowedDomains: string[]; + autoAssignEnabled: boolean; createdAt: Date; updatedAt: Date; }