1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-04 00:18:01 +01:00

chore: initial admin email (#7795)

https://linear.app/unleash/issue/2-2518/figure-out-how-to-create-the-initial-admin-user-in-unleash

The logic around `initAdminUser` that was introduced in
https://github.com/Unleash/unleash/pull/4927 confused me a bit. I wrote
new tests with what I assume are our expectations for this feature and
refactored the code accordingly, but would like someone to confirm that
it makes sense to them as well.

The logic was split into 2 different methods: one to get the initial
invite link, and another to send a welcome email. Now these two methods
are more granular than the previous alternative and can be used
independently of creating a new user.

---------

Co-authored-by: Gastón Fournier <gaston@getunleash.io>
This commit is contained in:
Nuno Góis 2024-08-14 09:05:11 +01:00 committed by GitHub
parent 764d03767b
commit 585eb30730
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 233 additions and 231 deletions

View File

@ -20,10 +20,7 @@ exports[`should create default config 1`] = `
"demoAllowAdminLogin": false, "demoAllowAdminLogin": false,
"enableApiToken": true, "enableApiToken": true,
"initApiTokens": [], "initApiTokens": [],
"initialAdminUser": { "initialAdminUser": undefined,
"password": "unleash4all",
"username": "admin",
},
"type": "open-source", "type": "open-source",
}, },
"clientFeatureCaching": { "clientFeatureCaching": {

View File

@ -21,6 +21,7 @@ import {
type IUnleashOptions, type IUnleashOptions,
type IVersionOption, type IVersionOption,
type ISSLOption, type ISSLOption,
type UsernameAdminUser,
} from './types/option'; } from './types/option';
import { getDefaultLogProvider, LogLevel, validateLogProvider } from './logger'; import { getDefaultLogProvider, LogLevel, validateLogProvider } from './logger';
import { defaultCustomAuthDenyAll } from './default-custom-auth-deny-all'; import { defaultCustomAuthDenyAll } from './default-custom-auth-deny-all';
@ -315,6 +316,12 @@ const defaultVersionOption: IVersionOption = {
enable: parseEnvVarBoolean(process.env.CHECK_VERSION, true), enable: parseEnvVarBoolean(process.env.CHECK_VERSION, true),
}; };
const parseEnvVarInitialAdminUser = (): UsernameAdminUser | undefined => {
const username = process.env.INITIAL_ADMIN_USER_USERNAME;
const password = process.env.INITIAL_ADMIN_USER_PASSWORD;
return username && password ? { username, password } : undefined;
};
const defaultAuthentication: IAuthOption = { const defaultAuthentication: IAuthOption = {
demoAllowAdminLogin: parseEnvVarBoolean( demoAllowAdminLogin: parseEnvVarBoolean(
process.env.AUTH_DEMO_ALLOW_ADMIN_LOGIN, process.env.AUTH_DEMO_ALLOW_ADMIN_LOGIN,
@ -324,10 +331,7 @@ const defaultAuthentication: IAuthOption = {
type: authTypeFromString(process.env.AUTH_TYPE), type: authTypeFromString(process.env.AUTH_TYPE),
customAuthHandler: defaultCustomAuthDenyAll, customAuthHandler: defaultCustomAuthDenyAll,
createAdminUser: true, createAdminUser: true,
initialAdminUser: { initialAdminUser: parseEnvVarInitialAdminUser(),
username: process.env.UNLEASH_DEFAULT_ADMIN_USERNAME ?? 'admin',
password: process.env.UNLEASH_DEFAULT_ADMIN_PASSWORD ?? 'unleash4all',
},
initApiTokens: [], initApiTokens: [],
}; };

View File

@ -6,12 +6,10 @@ import type { AccountService } from '../../services/account-service';
import type { AccessService } from '../../services/access-service'; import type { AccessService } from '../../services/access-service';
import type { Logger } from '../../logger'; import type { Logger } from '../../logger';
import type { IUnleashConfig, IUnleashServices, RoleName } from '../../types'; import type { IUnleashConfig, IUnleashServices, RoleName } from '../../types';
import type { EmailService } from '../../services/email-service';
import type ResetTokenService from '../../services/reset-token-service'; import type ResetTokenService from '../../services/reset-token-service';
import type { IAuthRequest } from '../unleash-types'; import type { IAuthRequest } from '../unleash-types';
import type SettingService from '../../services/setting-service'; import type SettingService from '../../services/setting-service';
import type { IUser, SimpleAuthSettings } from '../../server-impl'; import type { IUser } from '../../server-impl';
import { simpleAuthSettingsKey } from '../../types/settings/simple-auth-settings';
import { anonymise } from '../../util/anonymise'; import { anonymise } from '../../util/anonymise';
import type { OpenApiService } from '../../services/openapi-service'; import type { OpenApiService } from '../../services/openapi-service';
import { createRequestSchema } from '../../openapi/util/create-request-schema'; import { createRequestSchema } from '../../openapi/util/create-request-schema';
@ -70,8 +68,6 @@ export default class UserAdminController extends Controller {
private readonly logger: Logger; private readonly logger: Logger;
private emailService: EmailService;
private resetTokenService: ResetTokenService; private resetTokenService: ResetTokenService;
private settingService: SettingService; private settingService: SettingService;
@ -80,8 +76,6 @@ export default class UserAdminController extends Controller {
private groupService: GroupService; private groupService: GroupService;
readonly unleashUrl: string;
readonly isEnterprise: boolean; readonly isEnterprise: boolean;
constructor( constructor(
@ -90,7 +84,6 @@ export default class UserAdminController extends Controller {
userService, userService,
accountService, accountService,
accessService, accessService,
emailService,
resetTokenService, resetTokenService,
settingService, settingService,
openApiService, openApiService,
@ -111,13 +104,11 @@ export default class UserAdminController extends Controller {
this.userService = userService; this.userService = userService;
this.accountService = accountService; this.accountService = accountService;
this.accessService = accessService; this.accessService = accessService;
this.emailService = emailService;
this.resetTokenService = resetTokenService; this.resetTokenService = resetTokenService;
this.settingService = settingService; this.settingService = settingService;
this.openApiService = openApiService; this.openApiService = openApiService;
this.groupService = groupService; this.groupService = groupService;
this.logger = config.getLogger('routes/user-controller.ts'); this.logger = config.getLogger('routes/user-controller.ts');
this.unleashUrl = config.server.unleashUrl;
this.flagResolver = config.flagResolver; this.flagResolver = config.flagResolver;
this.isEnterprise = config.isEnterprise; this.isEnterprise = config.isEnterprise;
@ -525,12 +516,12 @@ export default class UserAdminController extends Controller {
): Promise<void> { ): Promise<void> {
const { username, email, name, rootRole, sendEmail, password } = const { username, email, name, rootRole, sendEmail, password } =
req.body; req.body;
const { user } = req;
const normalizedRootRole = Number.isInteger(Number(rootRole)) const normalizedRootRole = Number.isInteger(Number(rootRole))
? Number(rootRole) ? Number(rootRole)
: (rootRole as RoleName); : (rootRole as RoleName);
const createdUser = await this.userService.createUser( const { createdUser, inviteLink, emailSent } =
await this.userService.createUserWithEmail(
{ {
username, username,
email, email,
@ -538,52 +529,13 @@ export default class UserAdminController extends Controller {
password, password,
rootRole: normalizedRootRole, rootRole: normalizedRootRole,
}, },
sendEmail,
req.audit, req.audit,
); );
const passwordAuthSettings =
await this.settingService.get<SimpleAuthSettings>(
simpleAuthSettingsKey,
);
let inviteLink: string;
if (!passwordAuthSettings?.disabled) {
const inviteUrl = await this.resetTokenService.createNewUserUrl(
createdUser.id,
user.email,
);
inviteLink = inviteUrl.toString();
}
let emailSent = false;
const emailConfigured = this.emailService.configured();
const reallySendEmail =
emailConfigured && (sendEmail !== undefined ? sendEmail : true);
if (reallySendEmail) {
try {
await this.emailService.sendGettingStartedMail(
createdUser.name,
createdUser.email,
this.unleashUrl,
inviteLink,
);
emailSent = true;
} catch (e) {
this.logger.warn(
'email was configured, but sending failed due to: ',
e,
);
}
} else {
this.logger.warn(
'email was not sent to the user because email configuration is lacking',
);
}
const responseData: CreateUserResponseSchema = { const responseData: CreateUserResponseSchema = {
...serializeDates(createdUser), ...serializeDates(createdUser),
inviteLink: inviteLink || this.unleashUrl, inviteLink: inviteLink,
emailSent, emailSent,
rootRole: normalizedRootRole, rootRole: normalizedRootRole,
}; };

View File

@ -70,7 +70,93 @@ test('Should create new user', async () => {
expect(storedUser.username).toBe('test'); expect(storedUser.username).toBe('test');
}); });
test('Should create default user - with defaults', async () => { describe('Default admin initialization', () => {
const DEFAULT_ADMIN_USERNAME = 'admin';
const DEFAULT_ADMIN_PASSWORD = 'unleash4all';
const CUSTOM_ADMIN_USERNAME = 'custom-admin';
const CUSTOM_ADMIN_PASSWORD = 'custom-password';
const CUSTOM_ADMIN_NAME = 'Custom Admin';
const CUSTOM_ADMIN_EMAIL = 'custom-admin@getunleash.io';
let userService: UserService;
const sendGettingStartedMailMock = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
const userStore = new UserStoreMock();
const eventStore = new EventStoreMock();
const accessService = new AccessServiceMock();
const resetTokenStore = new FakeResetTokenStore();
const resetTokenService = new ResetTokenService(
{ resetTokenStore },
config,
);
const emailService = new EmailService(config);
emailService.configured = jest.fn(() => true);
emailService.sendGettingStartedMail = sendGettingStartedMailMock;
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
{ eventStore, featureTagStore: new FakeFeatureTagStore() },
config,
);
const settingService = new SettingService(
{
settingStore: new FakeSettingStore(),
},
config,
eventService,
);
userService = new UserService({ userStore }, config, {
accessService,
resetTokenService,
emailService,
eventService,
sessionService,
settingService,
});
});
test('Should create default admin user if `createAdminUser` is true and `initialAdminUser` is not set', async () => {
await userService.initAdminUser({ createAdminUser: true });
const user = await userService.loginUser(
DEFAULT_ADMIN_USERNAME,
DEFAULT_ADMIN_PASSWORD,
);
expect(user.username).toBe(DEFAULT_ADMIN_USERNAME);
});
test('Should create custom default admin user if `createAdminUser` is true and `initialAdminUser` is set', async () => {
await userService.initAdminUser({
createAdminUser: true,
initialAdminUser: {
username: CUSTOM_ADMIN_USERNAME,
password: CUSTOM_ADMIN_PASSWORD,
},
});
await expect(
userService.loginUser(
DEFAULT_ADMIN_USERNAME,
DEFAULT_ADMIN_PASSWORD,
),
).rejects.toThrow(
'The combination of password and username you provided is invalid',
);
const user = await userService.loginUser(
CUSTOM_ADMIN_USERNAME,
CUSTOM_ADMIN_PASSWORD,
);
expect(user.username).toBe(CUSTOM_ADMIN_USERNAME);
});
test('Should not create any default admin user if `createAdminUser` is not true and `initialAdminUser` is not set', async () => {
const userStore = new UserStoreMock(); const userStore = new UserStoreMock();
const eventStore = new EventStoreMock(); const eventStore = new EventStoreMock();
const accessService = new AccessServiceMock(); const accessService = new AccessServiceMock();
@ -105,100 +191,10 @@ test('Should create default user - with defaults', async () => {
await service.initAdminUser({}); await service.initAdminUser({});
const user = await service.loginUser('admin', 'unleash4all'); await expect(service.loginUser('admin', 'unleash4all')).rejects.toThrow(
expect(user.username).toBe('admin');
});
test('Should create default user - with provided username and password', async () => {
const userStore = new UserStoreMock();
const eventStore = new EventStoreMock();
const accessService = new AccessServiceMock();
const resetTokenStore = new FakeResetTokenStore();
const resetTokenService = new ResetTokenService(
{ resetTokenStore },
config,
);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
{ eventStore, featureTagStore: new FakeFeatureTagStore() },
config,
);
const settingService = new SettingService(
{
settingStore: new FakeSettingStore(),
},
config,
eventService,
);
const service = new UserService({ userStore }, config, {
accessService,
resetTokenService,
emailService,
eventService,
sessionService,
settingService,
});
await service.initAdminUser({
initialAdminUser: {
username: 'admin',
password: 'unleash4all!',
},
});
const user = await service.loginUser('admin', 'unleash4all!');
expect(user.username).toBe('admin');
});
test('Should not create default user - with `createAdminUser` === false', async () => {
const userStore = new UserStoreMock();
const eventStore = new EventStoreMock();
const accessService = new AccessServiceMock();
const resetTokenStore = new FakeResetTokenStore();
const resetTokenService = new ResetTokenService(
{ resetTokenStore },
config,
);
const emailService = new EmailService(config);
const sessionStore = new FakeSessionStore();
const sessionService = new SessionService({ sessionStore }, config);
const eventService = new EventService(
{ eventStore, featureTagStore: new FakeFeatureTagStore() },
config,
);
const settingService = new SettingService(
{
settingStore: new FakeSettingStore(),
},
config,
eventService,
);
const service = new UserService({ userStore }, config, {
accessService,
resetTokenService,
emailService,
eventService,
sessionService,
settingService,
});
await service.initAdminUser({
createAdminUser: false,
initialAdminUser: {
username: 'admin',
password: 'unleash4all!',
},
});
await expect(
service.loginUser('admin', 'unleash4all!'),
).rejects.toThrowError(
'The combination of password and username you provided is invalid', 'The combination of password and username you provided is invalid',
); );
});
}); });
test('Should be a valid password', async () => { test('Should be a valid password', async () => {

View File

@ -15,7 +15,11 @@ import type ResetTokenService from './reset-token-service';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
import OwaspValidationError from '../error/owasp-validation-error'; import OwaspValidationError from '../error/owasp-validation-error';
import type { EmailService } from './email-service'; import type { EmailService } from './email-service';
import type { IAuthOption, IUnleashConfig } from '../types/option'; import type {
IAuthOption,
IUnleashConfig,
UsernameAdminUser,
} from '../types/option';
import type SessionService from './session-service'; import type SessionService from './session-service';
import type { IUnleashStores } from '../types/stores'; import type { IUnleashStores } from '../types/stores';
import PasswordUndefinedError from '../error/password-undefined'; import PasswordUndefinedError from '../error/password-undefined';
@ -86,6 +90,8 @@ class UserService {
private baseUriPath: string; private baseUriPath: string;
readonly unleashUrl: string;
constructor( constructor(
stores: Pick<IUnleashStores, 'userStore'>, stores: Pick<IUnleashStores, 'userStore'>,
{ {
@ -111,16 +117,10 @@ class UserService {
this.sessionService = services.sessionService; this.sessionService = services.sessionService;
this.settingService = services.settingService; this.settingService = services.settingService;
if (authentication.createAdminUser !== false) { process.nextTick(() => this.initAdminUser(authentication));
process.nextTick(() =>
this.initAdminUser({
createAdminUser: authentication.createAdminUser,
initialAdminUser: authentication.initialAdminUser,
}),
);
}
this.baseUriPath = server.baseUriPath || ''; this.baseUriPath = server.baseUriPath || '';
this.unleashUrl = server.unleashUrl;
} }
validatePassword(password: string): boolean { validatePassword(password: string): boolean {
@ -134,33 +134,31 @@ class UserService {
} }
} }
async initAdminUser( async initAdminUser({
initialAdminUserConfig: Pick< createAdminUser,
initialAdminUser,
}: Pick<
IAuthOption, IAuthOption,
'createAdminUser' | 'initialAdminUser' 'createAdminUser' | 'initialAdminUser'
>, >): Promise<void> {
): Promise<void> { if (!createAdminUser) return Promise.resolve();
let username: string;
let password: string;
if ( return this.initAdminUsernameUser(initialAdminUser);
initialAdminUserConfig.createAdminUser !== false &&
initialAdminUserConfig.initialAdminUser
) {
username = initialAdminUserConfig.initialAdminUser.username;
password = initialAdminUserConfig.initialAdminUser.password;
} else {
username = 'admin';
password = 'unleash4all';
} }
async initAdminUsernameUser(
usernameAdminUser?: UsernameAdminUser,
): Promise<void> {
const username = usernameAdminUser?.username || 'admin';
const password = usernameAdminUser?.password || 'unleash4all';
const userCount = await this.store.count(); const userCount = await this.store.count();
if (userCount === 0 && username && password) { if (userCount === 0) {
// create default admin user // create default admin user
try { try {
this.logger.info( this.logger.info(
`Creating default user '${username}' with password '${password}'`, `Creating default admin user, with username '${username}' and password '${password}'`,
); );
const user = await this.store.insert({ const user = await this.store.insert({
username, username,
@ -261,6 +259,62 @@ class UserService {
return userCreated; return userCreated;
} }
async createUserWithEmail(
{ username, email, name, password, rootRole }: ICreateUser,
sendEmail = true,
auditUser: IAuditUser = SYSTEM_USER_AUDIT,
): Promise<{
createdUser: IUserWithRootRole;
inviteLink: string;
emailSent: boolean;
}> {
const createdUser = await this.createUser(
{ username, email, name, password, rootRole },
auditUser,
);
const passwordAuthSettings =
await this.settingService.getWithDefault<SimpleAuthSettings>(
simpleAuthSettingsKey,
{ disabled: false },
);
let inviteLink = this.unleashUrl;
if (!passwordAuthSettings.disabled) {
const inviteUrl = await this.resetTokenService.createNewUserUrl(
createdUser.id,
auditUser.username,
);
inviteLink = inviteUrl.toString();
}
let emailSent = false;
const emailConfigured = this.emailService.configured();
if (emailConfigured && sendEmail && createdUser.email) {
try {
await this.emailService.sendGettingStartedMail(
createdUser.name || '',
createdUser.email,
this.unleashUrl,
inviteLink,
);
emailSent = true;
} catch (e) {
this.logger.warn(
'email was configured, but sending failed due to: ',
e,
);
}
} else {
this.logger.warn(
'email was not sent to the user because email configuration is lacking',
);
}
return { createdUser, inviteLink, emailSent };
}
async updateUser( async updateUser(
{ id, name, email, rootRole }: IUpdateUser, { id, name, email, rootRole }: IUpdateUser,
auditUser: IAuditUser, auditUser: IAuditUser,

View File

@ -66,16 +66,18 @@ export type CustomAuthHandler = (
services?: IUnleashServices, services?: IUnleashServices,
) => void; ) => void;
export type UsernameAdminUser = {
username: string;
password: string;
};
export interface IAuthOption { export interface IAuthOption {
demoAllowAdminLogin?: boolean; demoAllowAdminLogin?: boolean;
enableApiToken: boolean; enableApiToken: boolean;
type: IAuthType; type: IAuthType;
customAuthHandler?: CustomAuthHandler; customAuthHandler?: CustomAuthHandler;
createAdminUser?: boolean; createAdminUser?: boolean;
initialAdminUser?: { initialAdminUser?: UsernameAdminUser;
username: string;
password: string;
};
initApiTokens: ILegacyApiTokenCreate[]; initApiTokens: ILegacyApiTokenCreate[];
} }

View File

@ -97,10 +97,6 @@ afterEach(async () => {
test('should create initial admin user', async () => { test('should create initial admin user', async () => {
await userService.initAdminUser({ await userService.initAdminUser({
createAdminUser: true, createAdminUser: true,
initialAdminUser: {
username: 'admin',
password: 'unleash4all',
},
}); });
await expect(async () => await expect(async () =>
userService.loginUser('admin', 'wrong-password'), userService.loginUser('admin', 'wrong-password'),
@ -121,10 +117,6 @@ test('should not init default user if we already have users', async () => {
); );
await userService.initAdminUser({ await userService.initAdminUser({
createAdminUser: true, createAdminUser: true,
initialAdminUser: {
username: 'admin',
password: 'unleash4all',
},
}); });
const users = await userService.getAll(); const users = await userService.getAll();
expect(users).toHaveLength(1); expect(users).toHaveLength(1);

View File

@ -94,9 +94,7 @@ unleash.start(unleashOptions);
and [notifications](../../reference/notifications.md). and [notifications](../../reference/notifications.md).
- `customAuthHandler`: function `(app: any, config: IUnleashConfig): void` — custom express middleware handling - `customAuthHandler`: function `(app: any, config: IUnleashConfig): void` — custom express middleware handling
authentication. Used when type is set to `custom`. Can not be set via environment variables. authentication. Used when type is set to `custom`. Can not be set via environment variables.
- `initialAdminUser`: `{ username: string, password: string} | null` — whether to create an admin user with default - `initialAdminUser`: `{ username: string, password: string} | undefined` — The initial admin username and password - Defaults to `admin` and `unleash4all`, respectively. Can be set using the `UNLEASH_DEFAULT_ADMIN_USERNAME` and `UNLEASH_DEFAULT_ADMIN_PASSWORD` environment variables.
password - Defaults to using `admin` and `unleash4all` as the username and password. Can not be overridden by
setting the `UNLEASH_DEFAULT_ADMIN_USERNAME` and `UNLEASH_DEFAULT_ADMIN_PASSWORD` environment variables.
- `initApiTokens` / `INIT_ADMIN_API_TOKENS`, `INIT_CLIENT_API_TOKENS`, - `initApiTokens` / `INIT_ADMIN_API_TOKENS`, `INIT_CLIENT_API_TOKENS`,
and `INIT_FRONTEND_API_TOKENS`: `ApiTokens[]` — Array of API tokens to create on startup. The tokens will only and `INIT_FRONTEND_API_TOKENS`: `ApiTokens[]` — Array of API tokens to create on startup. The tokens will only
be created if the database doesn't already contain any API tokens. Example: be created if the database doesn't already contain any API tokens. Example:

View File

@ -36,6 +36,13 @@ If you'd like the default admin user to be created with a different username and
- `UNLEASH_DEFAULT_ADMIN_USERNAME` - `UNLEASH_DEFAULT_ADMIN_USERNAME`
- `UNLEASH_DEFAULT_ADMIN_PASSWORD` - `UNLEASH_DEFAULT_ADMIN_PASSWORD`
Alternatively, you can provide a name and email address for the initial admin user:
- `UNLEASH_DEFAULT_ADMIN_NAME`
- `UNLEASH_DEFAULT_ADMIN_EMAIL`
Unleash will then create the admin account using the provided name and email address. Instead of setting an initial password during account creation, an email will be sent to the specified address with a link for the new admin user to securely set their password.
The way of defining these variables may vary depending on how you run Unleash. The way of defining these variables may vary depending on how you run Unleash.