Skip to content

Commit 3854bc1

Browse files
ilberttclaude
andauthored
feat: validate sign-in emails with BRAIN_ALLOWED_EMAILS_REGEX (#183)
Replaces `WORKSPACE_DOMAIN` (a single-domain suffix check) with `BRAIN_ALLOWED_EMAILS_REGEX`, a regex matched case-insensitively against the account email in the better-auth sign-in hook. This allows wildcarding a whole workspace (`.*@onfabric\.io$`) or listing a fixed set of emails even across domains (`^(alice@gmail\.com|bob@outlook\.com)$`). When unset, any email may sign in. The old var was never wired into deployments (prod relied on the backend's hardcoded `onfabric.io` default, now removed), so this also plumbs the value through both deploy paths — the CI environment contract and the interactive CLI deploy — into the on-box `.env`. The `dev` CI environment is set to `.*@onfabric\.io$` to preserve the existing restriction. The AWS config field was renamed `workspaceDomain` → optional `allowedEmailsRegex`; existing `.company-brain.aws.json` files still parse (the old key is dropped), so a re-deploy from an old config falls back to "allow any" on the interactive path until `deploy setup` is re-run. Validated with `bun check:all` and the full test suite. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b9aba9d commit 3854bc1

18 files changed

Lines changed: 221 additions & 59 deletions

.env.example

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ BETTER_AUTH_SECRET=local-dev-better-auth-secret-change-me-0000
2828
# Authorized redirect URI: ${BRAIN_PUBLIC_URL}/api/auth/callback/google
2929
GOOGLE_CLIENT_ID=local-fake-google-client-id.apps.googleusercontent.com
3030
GOOGLE_CLIENT_SECRET=local-fake-google-client-secret
31-
# Workspace the brain restricts sign-in to (Google hosted-domain + hard check).
32-
WORKSPACE_DOMAIN=example.com
31+
# Regex matched (case-insensitively) against the email of accounts allowed to
32+
# sign in. Use a wildcard to allow a whole workspace (`.*@example\.com$`) or an
33+
# alternation to allow a fixed set of emails, even across domains
34+
# (`^(alice@gmail\.com|bob@outlook\.com)$`). Leave unset to allow any email.
35+
ALLOWED_DASHBOARD_ACCOUNTS_EMAILS_REGEX=.*@example\.com$
3336

3437
# --- Database ---
3538
# Host (`postgres-db`) and port (5432) are fixed by the compose topology and

backend/src/lib/auth/better-auth.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ export const OAUTH_SCOPES = ['openid', 'profile', 'email', 'offline_access', MCP
2121

2222
const logger = createLogger('better-auth');
2323

24-
function isWorkspaceEmail(email: string): boolean {
25-
return email.toLowerCase().endsWith(`@${env.workspaceDomain}`);
24+
function isAllowedEmail(email: string): boolean {
25+
return (
26+
env.allowedDashboardAccountsEmailsRegex === null ||
27+
env.allowedDashboardAccountsEmailsRegex.test(email.toLowerCase())
28+
);
2629
}
2730

2831
export const auth = betterAuth({
@@ -55,9 +58,9 @@ export const auth = betterAuth({
5558
user: {
5659
create: {
5760
before: (user) => {
58-
if (!isWorkspaceEmail(user.email)) {
61+
if (!isAllowedEmail(user.email)) {
5962
throw new APIError('FORBIDDEN', {
60-
message: `Sign-in is restricted to @${env.workspaceDomain} accounts.`,
63+
message: 'Sign-in is restricted to allowed accounts.',
6164
});
6265
}
6366
return Promise.resolve();

backend/src/lib/env.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,13 @@ declare global {
88
readonly BETTER_AUTH_SECRET?: string;
99
readonly GOOGLE_CLIENT_ID?: string;
1010
readonly GOOGLE_CLIENT_SECRET?: string;
11-
readonly WORKSPACE_DOMAIN?: string;
11+
readonly ALLOWED_DASHBOARD_ACCOUNTS_EMAILS_REGEX?: string;
1212
}
1313
}
1414
}
1515

1616
const DEFAULT_PORT = '3010';
1717
const DEFAULT_PUBLIC_URL = `http://localhost:${DEFAULT_PORT}`;
18-
const DEFAULT_WORKSPACE_DOMAIN = 'onfabric.io';
1918

2019
type Env = {
2120
databaseUrl: string;
@@ -36,9 +35,7 @@ type Env = {
3635
betterAuthSecret: string;
3736
googleClientId: string;
3837
googleClientSecret: string;
39-
// The workspace the brain restricts sign-in to: a Google hosted-domain hint
40-
// and the hard check enforced in the auth database hook.
41-
workspaceDomain: string;
38+
allowedDashboardAccountsEmailsRegex: RegExp | null;
4239
};
4340

4441
function required(name: keyof NodeJS.ProcessEnv): string {
@@ -66,7 +63,9 @@ function loadEnv(): Env {
6663
betterAuthSecret: required('BETTER_AUTH_SECRET'),
6764
googleClientId: required('GOOGLE_CLIENT_ID'),
6865
googleClientSecret: required('GOOGLE_CLIENT_SECRET'),
69-
workspaceDomain: optional('WORKSPACE_DOMAIN', DEFAULT_WORKSPACE_DOMAIN),
66+
allowedDashboardAccountsEmailsRegex: process.env.ALLOWED_DASHBOARD_ACCOUNTS_EMAILS_REGEX
67+
? new RegExp(process.env.ALLOWED_DASHBOARD_ACCOUNTS_EMAILS_REGEX)
68+
: null,
7069
};
7170
}
7271

cli/src/commands/setup.ts

Lines changed: 25 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { intro, isCancel, note, outro, text } from '@clack/prompts';
22
import { defineCommand } from '@parshjs/core';
33
import { z } from 'zod';
4+
import {
5+
ALLOWED_EMAILS_PLACEHOLDER,
6+
allowedEmailsToRegex,
7+
validateAllowedEmailsInput,
8+
} from '../lib/allowed-emails.ts';
49
import { readAwsConfig, writeAwsConfig } from '../lib/aws-config.ts';
510
import { withAwsCredentials } from '../lib/aws-credentials.ts';
611
import { continueAwsDeployment, provisionAwsInfrastructure } from '../lib/aws-deployment.ts';
@@ -25,9 +30,10 @@ export const command = defineCommand('setup', {
2530
schema: z.boolean().optional(),
2631
description: 'Only write local configuration without starting Docker Compose.',
2732
},
28-
'workspace-domain': {
33+
'allowed-emails': {
2934
schema: z.string().optional(),
30-
description: 'Google Workspace domain allowed to sign in to the brain.',
35+
description:
36+
'Comma-separated emails allowed to sign in; use *@domain for a whole workspace. Empty allows any.',
3137
},
3238
yes: {
3339
schema: z.boolean().optional(),
@@ -40,14 +46,14 @@ export const command = defineCommand('setup', {
4046
rejectOptionsForTarget(target, options, {
4147
yes: 'cloud',
4248
'skip-start': 'local',
43-
'workspace-domain': 'local',
49+
'allowed-emails': 'local',
4450
});
4551

4652
if (target === 'local') {
4753
await setupLocal({
4854
force: options.force,
4955
skipStart: options['skip-start'],
50-
workspaceDomain: options['workspace-domain'],
56+
allowedEmails: options['allowed-emails'],
5157
nonInteractive,
5258
verbose: Boolean(rootOptions.verbose),
5359
print,
@@ -67,16 +73,20 @@ export const command = defineCommand('setup', {
6773
async function setupLocal(options: {
6874
force?: boolean;
6975
skipStart?: boolean;
70-
workspaceDomain?: string;
76+
allowedEmails?: string;
7177
nonInteractive: boolean;
7278
verbose: boolean;
7379
print: { success: (message: string) => void; warn: (message: string) => void };
7480
}): Promise<void> {
7581
intro('Company Brain local setup');
7682

77-
const workspaceDomain =
78-
options.workspaceDomain ?? (await promptWorkspaceDomainIfMissing(options.nonInteractive));
79-
await ensureRootEnv({ force: options.force, workspaceDomain });
83+
const allowedEmails =
84+
options.allowedEmails ?? (await promptAllowedEmailsIfMissing(options.nonInteractive));
85+
await ensureRootEnv({
86+
force: options.force,
87+
allowedDashboardAccountsEmailsRegex:
88+
allowedEmails === undefined ? undefined : allowedEmailsToRegex(allowedEmails),
89+
});
8090
await ensureNangoEnvBase(options.force);
8191

8292
options.print.success('Local env files are ready.');
@@ -150,45 +160,27 @@ async function setupCloud(options: {
150160
outro('Cloud setup flow finished.');
151161
}
152162

153-
async function promptWorkspaceDomainIfMissing(
154-
nonInteractive: boolean,
155-
): Promise<string | undefined> {
163+
async function promptAllowedEmailsIfMissing(nonInteractive: boolean): Promise<string | undefined> {
156164
const existing = await readRootEnv();
157-
if (existing.WORKSPACE_DOMAIN) {
165+
if (existing.ALLOWED_DASHBOARD_ACCOUNTS_EMAILS_REGEX) {
158166
return undefined;
159167
}
160168

161-
return await promptWorkspaceDomain('example.com', nonInteractive);
162-
}
163-
164-
async function promptWorkspaceDomain(
165-
defaultValue: string,
166-
nonInteractive: boolean,
167-
): Promise<string> {
168169
if (nonInteractive) {
169-
return defaultValue;
170+
return '';
170171
}
171172

172173
const answer = await text({
173-
message: 'Workspace domain for local Google sign-in checks',
174-
placeholder: defaultValue,
175-
defaultValue,
176-
validate: validateRequired,
174+
message: 'Emails allowed to sign in (comma-separated, *@domain for a whole workspace)',
175+
placeholder: `${ALLOWED_EMAILS_PLACEHOLDER} — leave empty to allow any`,
176+
validate: validateAllowedEmailsInput,
177177
});
178178

179179
if (isCancel(answer)) {
180180
throw new Error('Setup cancelled.');
181181
}
182182

183-
return answer;
184-
}
185-
186-
function validateRequired(value: string | undefined): string | undefined {
187-
if (!value || value.trim().length === 0) {
188-
return 'Required';
189-
}
190-
191-
return undefined;
183+
return answer ?? '';
192184
}
193185

194186
function formatCredentialSource(prerequisites: AwsPrerequisites): string {

cli/src/lib/allowed-emails.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { allowedEmailsToRegex, validateAllowedEmailsInput } from './allowed-emails.ts';
3+
4+
describe('allowedEmailsToRegex', () => {
5+
it('returns undefined for empty input', () => {
6+
expect(allowedEmailsToRegex('')).toBeUndefined();
7+
expect(allowedEmailsToRegex(' , ')).toBeUndefined();
8+
});
9+
10+
it('builds an anchored alternation for a fixed set across domains', () => {
11+
expect(allowedEmailsToRegex('alice@gmail.com, bob@outlook.com')).toBe(
12+
'^(alice@gmail\\.com|bob@outlook\\.com)$',
13+
);
14+
});
15+
16+
it('expands a wildcard local part to any user at the domain', () => {
17+
expect(allowedEmailsToRegex('*@example.com')).toBe('^.*@example\\.com$');
18+
});
19+
20+
it('lowercases and escapes regex metacharacters', () => {
21+
expect(allowedEmailsToRegex('Alice+Tag@Example.com')).toBe('^alice\\+tag@example\\.com$');
22+
});
23+
24+
it('matches the emails it was built from and rejects look-alikes', () => {
25+
const regex = new RegExp(allowedEmailsToRegex('*@example.com') as string);
26+
expect(regex.test('anyone@example.com')).toBe(true);
27+
expect(regex.test('anyone@example.com.evil.com')).toBe(false);
28+
expect(regex.test('anyone@notexample.com')).toBe(false);
29+
});
30+
});
31+
32+
describe('validateAllowedEmailsInput', () => {
33+
it('accepts empty input (allow any)', () => {
34+
expect(validateAllowedEmailsInput('')).toBeUndefined();
35+
expect(validateAllowedEmailsInput(undefined)).toBeUndefined();
36+
});
37+
38+
it('accepts emails and wildcard domains', () => {
39+
expect(validateAllowedEmailsInput('alice@example.com, *@example.com')).toBeUndefined();
40+
});
41+
42+
it('rejects entries that are not an email or wildcard domain', () => {
43+
expect(validateAllowedEmailsInput('not-an-email')).toContain('not-an-email');
44+
expect(validateAllowedEmailsInput('alice@localhost')).toContain('alice@localhost');
45+
});
46+
});

cli/src/lib/allowed-emails.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
export const ALLOWED_EMAILS_PLACEHOLDER = 'alice@example.com, *@example.com';
2+
3+
export function allowedEmailsToRegex(input: string): string | undefined {
4+
const fragments = splitEntries(input).map(entryToFragment);
5+
if (fragments.length === 0) {
6+
return undefined;
7+
}
8+
9+
const body = fragments.length === 1 ? fragments[0] : `(${fragments.join('|')})`;
10+
return `^${body}$`;
11+
}
12+
13+
export function validateAllowedEmailsInput(value: string | undefined): string | undefined {
14+
if (!value || value.trim().length === 0) {
15+
return undefined;
16+
}
17+
18+
for (const entry of splitEntries(value)) {
19+
if (!isValidEntry(entry)) {
20+
return `Invalid entry "${entry}". Use email@domain or *@domain, comma-separated.`;
21+
}
22+
}
23+
24+
return undefined;
25+
}
26+
27+
function splitEntries(input: string): string[] {
28+
return input
29+
.split(',')
30+
.map((entry) => entry.trim().toLowerCase())
31+
.filter((entry) => entry.length > 0);
32+
}
33+
34+
function entryToFragment(entry: string): string {
35+
const at = entry.lastIndexOf('@');
36+
if (at === -1) {
37+
return escapeRegex(entry);
38+
}
39+
40+
const local = entry.slice(0, at);
41+
const domain = entry.slice(at + 1);
42+
const localPattern = local === '*' ? '.*' : escapeRegex(local);
43+
return `${localPattern}@${escapeRegex(domain)}`;
44+
}
45+
46+
function isValidEntry(entry: string): boolean {
47+
return /^(\*|[^@\s]+)@[^@\s]+\.[^@\s]+$/.test(entry);
48+
}
49+
50+
function escapeRegex(value: string): string {
51+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52+
}

cli/src/lib/aws-config.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ function config(): AwsConfig {
5757
brainHostname: 'brain.example.com',
5858
dozzleHostname: 'logs.example.com',
5959
acmeEmail: 'ops@example.com',
60-
workspaceDomain: 'example.com',
60+
allowedDashboardAccountsEmailsRegex: '.*@example\\.com$',
6161
googleClientId: 'client-id',
6262
dozzleUsername: 'admin',
6363
dozzleEmail: 'ops@example.com',

cli/src/lib/aws-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const AwsConfigSchema = z.object({
5858
brainHostname: z.string(),
5959
dozzleHostname: z.string(),
6060
acmeEmail: z.string(),
61-
workspaceDomain: z.string(),
61+
allowedDashboardAccountsEmailsRegex: z.string().optional(),
6262
googleClientId: z.string(),
6363
dozzleUsername: z.string(),
6464
dozzleEmail: z.string(),

cli/src/lib/aws-destroy.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ function config(): AwsConfig {
8484
brainHostname: 'brain.example.com',
8585
dozzleHostname: 'logs.example.com',
8686
acmeEmail: 'ops@example.com',
87-
workspaceDomain: 'example.com',
87+
allowedDashboardAccountsEmailsRegex: '.*@example\\.com$',
8888
googleClientId: 'client-id',
8989
dozzleUsername: 'admin',
9090
dozzleEmail: 'ops@example.com',

cli/src/lib/aws-dns.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ function config(): AwsConfig {
8080
brainHostname: 'brain.example.com',
8181
dozzleHostname: 'logs.example.com',
8282
acmeEmail: 'ops@example.com',
83-
workspaceDomain: 'example.com',
83+
allowedDashboardAccountsEmailsRegex: '.*@example\\.com$',
8484
googleClientId: 'client-id',
8585
dozzleUsername: 'admin',
8686
dozzleEmail: 'ops@example.com',

0 commit comments

Comments
 (0)