Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions apps/api/src/db/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
2 changes: 2 additions & 0 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>().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(),
});
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/routes/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
39 changes: 38 additions & 1 deletion apps/api/src/services/workspace-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -58,6 +58,8 @@ export async function updateWorkspace(
slug?: string;
description?: string | null;
allowDockerInDocker?: boolean;
allowedDomains?: string[];
autoAssignEnabled?: boolean;
},
): Promise<Workspace | null> {
const updates: Record<string, unknown> = { updatedAt: new Date() };
Expand All @@ -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;
Expand Down Expand Up @@ -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<Workspace[]> {
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.
Expand All @@ -186,6 +207,22 @@ export async function ensureUserHasWorkspace(userId: string): Promise<string> {
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" },
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`}
</div>
)}

Expand Down
3 changes: 3 additions & 0 deletions helm/optio/templates/secrets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
3 changes: 3 additions & 0 deletions helm/optio/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/types/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface Workspace {
description?: string | null;
createdBy?: string | null;
allowDockerInDocker: boolean;
allowedDomains: string[];
autoAssignEnabled: boolean;
createdAt: Date;
updatedAt: Date;
}
Expand Down
Loading