Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common';

import { BootstrapSsoAdminCommand } from 'src/database/commands/bootstrap-sso-admin.command';

type ConfigKey = 'SMB_NAME';
type ConfigKey = 'SMB_NAME' | 'SMB_DEFAULT_WORKSPACE_NAME';

const buildCommand = (overrides?: {
existingUser?: unknown;
Expand Down Expand Up @@ -38,12 +38,19 @@ const buildCommand = (overrides?: {
overrides?.helperResult ?? { wasExistingMember: false },
),
};
const configuredSubdomain = overrides?.configuredSubdomain ?? 'askii';
const twentyConfigService = {
get: jest.fn((key: ConfigKey) =>
key === 'SMB_NAME'
? (overrides?.configuredSubdomain ?? 'askii')
: undefined,
),
get: jest.fn((key: ConfigKey) => {
if (key === 'SMB_DEFAULT_WORKSPACE_NAME') {
return configuredSubdomain;
}

if (key === 'SMB_NAME') {
return 'portal-slug';
}

return undefined;
}),
};

const command = new BootstrapSsoAdminCommand(
Expand Down Expand Up @@ -77,11 +84,20 @@ describe('BootstrapSsoAdminCommand', () => {
logSpy.mockRestore();
});

it('throws when SMB_NAME is not configured', async () => {
const { command } = buildCommand({ configuredSubdomain: '' });
it('throws when SMB workspace subdomain is not configured', async () => {
const twentyConfigService = {
get: jest.fn(() => ''),
};
const command = new BootstrapSsoAdminCommand(
{ findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as any,
{ findOne: jest.fn() } as any,
{ findOne: jest.fn() } as any,
{ addUserToWorkspaceOrEnsureRole: jest.fn() } as any,
twentyConfigService as any,
);

await expect(command.run([], { email: 'admin@askii.ai' })).rejects.toThrow(
'SMB_NAME is not configured',
'SMB_DEFAULT_WORKSPACE_NAME (or SMB_NAME) is not configured',
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Repository } from 'typeorm';

import { hashPassword } from 'src/engine/core-modules/auth/auth.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { getSmbWorkspaceSubdomain } from 'src/engine/core-modules/twenty-config/utils/get-smb-workspace-subdomain.util';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
Expand All @@ -18,20 +19,20 @@ type BootstrapSsoAdminOptions = {
email: string;
};

// Pre-seed one Admin user for the SMB_NAME workspace so SSO sign-in lands a
// Pre-seed one Admin user for the SMB workspace so SSO sign-in lands a
// useful role on first hit. Mirrors `provision-plane.sh` (ADMIN_EMAIL →
// InstanceAdmin). Idempotent: subsequent runs ensure the Admin role on the
// existing userWorkspace, even if the user signed in via SSO first and was
// auto-provisioned as Member through the workspace's defaultRoleId.
//
// Wired into the foss-server-bundle-devstack provisioning pipeline at
// `provision/provision-twenty.sh`, which runs `workspace:seed:dev --light`
// (creates the SMB_NAME workspace + standard roles) and then this command.
// (creates the SMB workspace + standard roles) and then this command.
// Both steps are idempotent on re-runs.
@Command({
name: 'workspace:bootstrap-sso-admin',
description:
'Pre-seed a Cognito-known email as the Admin user of the SMB_NAME workspace. Run once after `workspace:seed:dev --light` (see foss-server-bundle-devstack/provision/provision-twenty.sh).',
'Pre-seed a Cognito-known email as the Admin user of the SMB workspace. Run once after `workspace:seed:dev --light` (see foss-server-bundle-devstack/provision/provision-twenty.sh).',
})
export class BootstrapSsoAdminCommand extends CommandRunner {
private readonly logger = new Logger(BootstrapSsoAdminCommand.name);
Expand Down Expand Up @@ -62,10 +63,12 @@ export class BootstrapSsoAdminCommand extends CommandRunner {
_passedParams: string[],
options: BootstrapSsoAdminOptions,
): Promise<void> {
const subdomain = this.twentyConfigService.get('SMB_NAME');
const subdomain = getSmbWorkspaceSubdomain(this.twentyConfigService);

if (!subdomain) {
throw new Error('SMB_NAME is not configured — refusing to bootstrap.');
throw new Error(
'SMB_DEFAULT_WORKSPACE_NAME (or SMB_NAME) is not configured — refusing to bootstrap.',
);
}

const normalizedEmail = options.email.trim().toLowerCase();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { SsoUserProvisioningService } from 'src/engine/core-modules/auth/services/sso-user-provisioning.service';

type ConfigKey = 'SMB_NAME';
type ConfigKey = 'SMB_NAME' | 'SMB_DEFAULT_WORKSPACE_NAME';

const buildService = (overrides?: {
existingUser?: unknown;
Expand All @@ -24,12 +24,19 @@ const buildService = (overrides?: {
const userWorkspaceService = {
addUserToWorkspaceIfUserNotInWorkspace: jest.fn(),
};
const configuredSubdomain = overrides?.configuredSubdomain ?? 'askii';
const twentyConfigService = {
get: jest.fn((key: ConfigKey) =>
key === 'SMB_NAME'
? (overrides?.configuredSubdomain ?? 'askii')
: undefined,
),
get: jest.fn((key: ConfigKey) => {
if (key === 'SMB_DEFAULT_WORKSPACE_NAME') {
return configuredSubdomain;
}

if (key === 'SMB_NAME') {
return 'portal-slug';
}

return undefined;
}),
};

const service = new SsoUserProvisioningService(
Expand Down Expand Up @@ -63,11 +70,19 @@ describe('SsoUserProvisioningService', () => {
await expect(service.findOrProvision(' ')).rejects.toThrow(AuthException);
});

it('should throw when SMB_NAME is not configured', async () => {
const { service } = buildService({ configuredSubdomain: '' });
it('should throw when SMB workspace subdomain is not configured', async () => {
const twentyConfigService = {
get: jest.fn(() => ''),
};
const service = new SsoUserProvisioningService(
{ findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as any,
{ findOne: jest.fn() } as any,
{ addUserToWorkspaceIfUserNotInWorkspace: jest.fn() } as any,
twentyConfigService as any,
);

await expect(service.findOrProvision('user@askii.ai')).rejects.toThrow(
'SMB_NAME not configured',
'SMB_DEFAULT_WORKSPACE_NAME (or SMB_NAME) not configured',
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { hashPassword } from 'src/engine/core-modules/auth/auth.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { getSmbWorkspaceSubdomain } from 'src/engine/core-modules/twenty-config/utils/get-smb-workspace-subdomain.util';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
Expand Down Expand Up @@ -47,11 +48,11 @@ export class SsoUserProvisioningService {
);
}

const subdomain = this.twentyConfigService.get('SMB_NAME');
const subdomain = getSmbWorkspaceSubdomain(this.twentyConfigService);

if (!subdomain) {
throw new AuthException(
'SMB_NAME not configured',
'SMB_DEFAULT_WORKSPACE_NAME (or SMB_NAME) not configured',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
Expand All @@ -62,7 +63,7 @@ export class SsoUserProvisioningService {

if (!workspace) {
this.logger.error(
`SSO landing workspace (subdomain="${subdomain}") missing — the seeder should have created it from SMB_NAME on first init.`,
`SSO landing workspace (subdomain="${subdomain}") missing — the seeder should have created it on first init.`,
);
throw new AuthException(
'SSO workspace not provisioned',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1715,11 +1715,20 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
description:
'Per-deployment tenant identifier wired into every foss-server-bundle-devstack app. Twenty uses it both as the seed workspace subdomain (see seeder-workspaces.constant.ts) and as the workspace SSO users join on first login. Required when AUTH_TYPE=SSO.',
'Per-deployment tenant identifier (portal hostname segment) for the foss-server-bundle-devstack. Used by the frontend runtime config when AUTH_TYPE=SSO.',
type: ConfigVariableType.STRING,
})
@IsOptional()
SMB_NAME = '';
Comment on lines 1715 to 1722

@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
description:
'SMB workspace subdomain for seeding and SSO auto-join (same as Plane/Outline/SurfSense). Falls back to SMB_NAME when unset.',
type: ConfigVariableType.STRING,
})
@IsOptional()
SMB_DEFAULT_WORKSPACE_NAME = '';
Comment on lines 1715 to +1731
}

export const validate = (config: Record<string, unknown>): ConfigVariables => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
getSmbWorkspaceSubdomain,
getSmbWorkspaceSubdomainFromProcessEnv,
isSmbBundleDeployment,
} from 'src/engine/core-modules/twenty-config/utils/get-smb-workspace-subdomain.util';

describe('getSmbWorkspaceSubdomain utils', () => {
const buildConfig = (values: Record<string, string | undefined>) =>
({
get: jest.fn((key: string) => values[key]),
}) as unknown as TwentyConfigService;

const originalEnv = process.env;

beforeEach(() => {
process.env = { ...originalEnv };
});

afterAll(() => {
process.env = originalEnv;
});

it('prefers SMB_DEFAULT_WORKSPACE_NAME from process env', () => {
process.env.SMB_DEFAULT_WORKSPACE_NAME = 'acme-team';
process.env.SMB_NAME = 'acme';

expect(getSmbWorkspaceSubdomainFromProcessEnv()).toBe('acme-team');
});

it('prefers SMB_DEFAULT_WORKSPACE_NAME from config', () => {
const config = buildConfig({
SMB_DEFAULT_WORKSPACE_NAME: 'acme-team',
SMB_NAME: 'acme',
});

expect(getSmbWorkspaceSubdomain(config)).toBe('acme-team');
expect(isSmbBundleDeployment(config)).toBe(true);
});

it('falls back to SMB_NAME when default workspace name is unset', () => {
const config = buildConfig({
SMB_DEFAULT_WORKSPACE_NAME: '',
SMB_NAME: 'acme',
});

expect(getSmbWorkspaceSubdomain(config)).toBe('acme');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';

export const getSmbWorkspaceSubdomainFromProcessEnv = (): string => {
const fromDefault = process.env.SMB_DEFAULT_WORKSPACE_NAME?.trim();

if (fromDefault) {
return fromDefault;
}

return process.env.SMB_NAME?.trim() ?? '';
};

export const getSmbWorkspaceSubdomain = (
config: TwentyConfigService,
): string => {
const fromDefault = config.get('SMB_DEFAULT_WORKSPACE_NAME');

if (typeof fromDefault === 'string' && fromDefault.trim()) {
return fromDefault.trim();
}

const fromName = config.get('SMB_NAME');

if (typeof fromName === 'string' && fromName.trim()) {
return fromName.trim();
}

return '';
};

export const isSmbBundleDeployment = (config: TwentyConfigService): boolean =>
getSmbWorkspaceSubdomain(config).length > 0;
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';

import { getSmbWorkspaceSubdomainFromProcessEnv } from 'src/engine/core-modules/twenty-config/utils/get-smb-workspace-subdomain.util';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';

export const WORKSPACE_FIELDS_TO_SEED = [
Expand Down Expand Up @@ -34,12 +35,12 @@ export type SeededEmptyWorkspacesIds =

// The "apple" seed slot is repurposed as the SSO landing workspace in the
// foss-server-bundle-devstack: ForwardAuth + Traefik route
// foss-twenty.<DOMAIN> to the workspace whose subdomain matches the
// bundle's SMB_NAME (the canonical per-deployment tenant identifier wired
// into every app via docker-compose). Fall back to the upstream "apple"
// identity when SMB_NAME is unset so dev/test runs that don't set it stay
// twenty.<DOMAIN> to the workspace whose subdomain matches
// SMB_DEFAULT_WORKSPACE_NAME (or SMB_NAME when unset). Fall back to the
// upstream "apple" identity when neither is set so dev/test runs stay
// byte-for-byte compatible with twentyhq.
const SEED_WORKSPACE_SUBDOMAIN = process.env.SMB_NAME ?? 'apple';
const SEED_WORKSPACE_SUBDOMAIN =
getSmbWorkspaceSubdomainFromProcessEnv() || 'apple';
const SEED_WORKSPACE_DISPLAY_NAME =
Comment on lines +42 to 44
SEED_WORKSPACE_SUBDOMAIN.charAt(0).toUpperCase() +
SEED_WORKSPACE_SUBDOMAIN.slice(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ApplicationRegistrationService } from 'src/engine/core-modules/applicat
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { isSmbBundleDeployment } from 'src/engine/core-modules/twenty-config/utils/get-smb-workspace-subdomain.util';
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
import { type UpgradeMigrationStatus } from 'src/engine/core-modules/upgrade/upgrade-migration.entity';
Expand Down Expand Up @@ -144,12 +145,13 @@ export class DevSeederService {
});

// initPermissions assigns admin/limited/guest/member roles to the demo
// userWorkspace rows (Tim, Jane, Jony, Phil + ~200 random). When SMB_NAME
// is set those userWorkspace rows weren't seeded above, so fall back to
// the minimal init path (member role + workspace activation, no
// user-specific assignments). The first SSO user provisioned via
// ForwardAuth picks up the member role from defaultRoleId on sign-in.
if (this.twentyConfigService.get('SMB_NAME')) {
// userWorkspace rows (Tim, Jane, Jony, Phil + ~200 random). In SMB bundle
// deployments (SMB_DEFAULT_WORKSPACE_NAME or SMB_NAME set) those
// userWorkspace rows weren't seeded above, so fall back to the minimal init
// path (member role + workspace activation, no user-specific assignments).
// The first SSO user provisioned via ForwardAuth picks up the member role
// from defaultRoleId on sign-in.
if (isSmbBundleDeployment(this.twentyConfigService)) {
await this.devSeederPermissionsService.initMinimalPermissionsAndActivateWorkspace(
{
workspaceId,
Expand Down Expand Up @@ -196,10 +198,10 @@ export class DevSeederService {
// devSeederDataService.seed populates the workspace schema with demo CRM
// data (companies, people, opportunities, workspace members). Workspace
// member rows reference core."user" via userId FK, which we didn't seed
// when SMB_NAME is set, so skip the entire data fixture in that mode and
// leave the workspace clean. SSO users provisioned via ForwardAuth get
// their workspaceMember rows created at sign-in time.
if (!this.twentyConfigService.get('SMB_NAME')) {
// in SMB bundle deployments, so skip the entire data fixture in that mode
// and leave the workspace clean. SSO users provisioned via ForwardAuth
// get their workspaceMember rows created at sign-in time.
if (!isSmbBundleDeployment(this.twentyConfigService)) {
await this.devSeederDataService.seed({
schemaName,
workspaceId,
Expand Down Expand Up @@ -337,8 +339,9 @@ export class DevSeederService {
// downstream FK reference (seedAgents / seedMetadataEntities) would
// break against missing userWorkspace rows. Same single switch as the
// workspace subdomain/displayName override in seeder-workspaces.constant.ts.
const seedDemoUsersAndDependents =
!this.twentyConfigService.get('SMB_NAME');
const seedDemoUsersAndDependents = !isSmbBundleDeployment(
this.twentyConfigService,
Comment on lines 339 to +343
);

await seedServerId({ queryRunner, schemaName });
if (seedDemoUsersAndDependents) {
Expand Down
Loading