mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-28 15:27:03 +02:00
feat(core): Just in time role provisioning for SAML login (#21387)
Co-authored-by: Stephen <sjw948@gmail.com>
This commit is contained in:
parent
10e08fd685
commit
2eb1de6c82
|
|
@ -35,6 +35,7 @@ export { ChangeUserRoleInProject } from './project/change-user-role-in-project.d
|
|||
|
||||
export { SamlAcsDto } from './saml/saml-acs.dto';
|
||||
export { SamlPreferences } from './saml/saml-preferences.dto';
|
||||
export { SamlPreferencesAttributeMapping } from './saml/saml-preferences.dto';
|
||||
export { SamlToggleDto } from './saml/saml-toggle.dto';
|
||||
|
||||
export { PasswordUpdateRequestDto } from './user/password-update-request.dto';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { Z } from 'zod-class';
|
|||
export class ProvisioningConfigDto extends Z.class({
|
||||
scopesProvisionInstanceRole: z.boolean(),
|
||||
scopesProvisionProjectRoles: z.boolean(),
|
||||
scopesProvisioningFrequency: z.enum(['never', 'first_login', 'every_login']),
|
||||
scopesName: z.string(),
|
||||
scopesInstanceRoleClaimName: z.string(),
|
||||
scopesProjectsRolesClaimName: z.string(),
|
||||
|
|
@ -13,10 +12,6 @@ export class ProvisioningConfigDto extends Z.class({
|
|||
export class ProvisioningConfigPatchDto extends Z.class({
|
||||
scopesProvisionInstanceRole: z.boolean().optional().nullable(),
|
||||
scopesProvisionProjectRoles: z.boolean().optional().nullable(),
|
||||
scopesProvisioningFrequency: z
|
||||
.enum(['never', 'first_login', 'every_login'])
|
||||
.optional()
|
||||
.nullable(),
|
||||
scopesName: z.string().optional().nullable(),
|
||||
scopesInstanceRoleClaimName: z.string().optional().nullable(),
|
||||
scopesProjectsRolesClaimName: z.string().optional().nullable(),
|
||||
|
|
|
|||
|
|
@ -12,17 +12,24 @@ const SignatureConfigSchema = z.object({
|
|||
}),
|
||||
});
|
||||
|
||||
export class SamlPreferencesAttributeMapping extends Z.class({
|
||||
/** SAML attribute mapped to the user's email. */
|
||||
email: z.string(),
|
||||
/** SAML attribute mapped to the user's first name. */
|
||||
firstName: z.string(),
|
||||
/** SAML attribute mapped to the user's last name. */
|
||||
lastName: z.string(),
|
||||
/** SAML attribute mapped to the user's principal name. */
|
||||
userPrincipalName: z.string(),
|
||||
/** SAML attribute mapped to the n8n instance role. */
|
||||
n8nInstanceRole: z.string().optional(),
|
||||
/** Each element in the array is formatted like "<projectId>:<role>" */
|
||||
n8nProjectRoles: z.array(z.string()).optional(),
|
||||
}) {}
|
||||
|
||||
export class SamlPreferences extends Z.class({
|
||||
/** Mapping of SAML attributes to user fields. */
|
||||
mapping: z
|
||||
.object({
|
||||
email: z.string(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
userPrincipalName: z.string(),
|
||||
n8nInstanceRole: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
mapping: SamlPreferencesAttributeMapping.optional(),
|
||||
/** SAML metadata in XML format. */
|
||||
metadata: z.string().optional(),
|
||||
metadataUrl: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { Config, Env, Nested } from '../decorators';
|
||||
|
||||
type ScopesProvisioningFrequency = 'never' | 'first_login' | 'every_login';
|
||||
|
||||
@Config
|
||||
class SamlConfig {
|
||||
/** Whether to enable SAML SSO. */
|
||||
|
|
@ -39,10 +37,6 @@ class ProvisioningConfig {
|
|||
@Env('N8N_SSO_SCOPES_PROVISION_PROJECT_ROLES')
|
||||
scopesProvisionProjectRoles: boolean = false;
|
||||
|
||||
/** How often to trigger provisioning, never, fist login, or every login */
|
||||
@Env('N8N_SSO_SCOPES_PROVISIONING_FREQUENCY')
|
||||
scopesProvisioningFrequency: ScopesProvisioningFrequency = 'never';
|
||||
|
||||
/** The name of scope to request on oauth flows */
|
||||
@Env('N8N_SSO_SCOPES_NAME')
|
||||
scopesName: string = 'n8n';
|
||||
|
|
|
|||
|
|
@ -381,7 +381,6 @@ describe('GlobalConfig', () => {
|
|||
provisioning: {
|
||||
scopesProvisionInstanceRole: false,
|
||||
scopesProvisionProjectRoles: false,
|
||||
scopesProvisioningFrequency: 'never',
|
||||
scopesName: 'n8n',
|
||||
scopesInstanceRoleClaimName: 'n8n_instance_role',
|
||||
scopesProjectsRolesClaimName: 'n8n_projects',
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ describe('ProvisioningController', () => {
|
|||
const configResponse: ProvisioningConfigDto = {
|
||||
scopesProvisionInstanceRole: true,
|
||||
scopesProvisionProjectRoles: true,
|
||||
scopesProvisioningFrequency: 'every_login',
|
||||
scopesName: 'n8n_test_scope',
|
||||
scopesInstanceRoleClaimName: 'n8n_test_instance_role',
|
||||
scopesProjectsRolesClaimName: 'n8n_test_projects_roles',
|
||||
|
|
@ -68,7 +67,6 @@ describe('ProvisioningController', () => {
|
|||
const configResponse: ProvisioningConfigDto = {
|
||||
scopesProvisionInstanceRole: false,
|
||||
scopesProvisionProjectRoles: false,
|
||||
scopesProvisioningFrequency: 'never',
|
||||
scopesName: 'n8n_test_scope',
|
||||
scopesInstanceRoleClaimName: 'n8n_test_instance_role',
|
||||
scopesProjectsRolesClaimName: 'n8n_test_projects_roles',
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ describe('ProvisioningService', () => {
|
|||
const provisioningConfigDto: ProvisioningConfigDto = {
|
||||
scopesProvisionInstanceRole: true,
|
||||
scopesProvisionProjectRoles: true,
|
||||
scopesProvisioningFrequency: 'every_login',
|
||||
scopesName: 'n8n_test_scope',
|
||||
scopesInstanceRoleClaimName: 'n8n_test_instance_role',
|
||||
scopesProjectsRolesClaimName: 'n8n_test_projects_roles',
|
||||
|
|
@ -143,7 +142,6 @@ describe('ProvisioningService', () => {
|
|||
const overriddenConfig = {
|
||||
scopesProvisionInstanceRole: false,
|
||||
scopesProvisionProjectRoles: false,
|
||||
scopesProvisioningFrequency: 'never',
|
||||
scopesName: 'n8n_test_scope_overridden',
|
||||
scopesInstanceRoleClaimName: 'n8n_test_instance_role_overridden',
|
||||
scopesProjectsRolesClaimName: 'n8n_test_projects_roles_overridden',
|
||||
|
|
@ -173,6 +171,8 @@ describe('ProvisioningService', () => {
|
|||
const user = mock<User>({ role: { slug: 'global:member' } });
|
||||
const roleSlug = 123;
|
||||
|
||||
provisioningService['isInstanceRoleProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionInstanceRoleForUser(user, roleSlug);
|
||||
expect(userRepository.update).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -189,6 +189,8 @@ describe('ProvisioningService', () => {
|
|||
|
||||
roleRepository.findOneOrFail.mockRejectedValue(thrownError);
|
||||
|
||||
provisioningService['isInstanceRoleProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionInstanceRoleForUser(user, roleSlug);
|
||||
expect(userRepository.update).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -207,6 +209,8 @@ describe('ProvisioningService', () => {
|
|||
mock<Role>({ slug: 'global:member', roleType: 'global' }),
|
||||
);
|
||||
|
||||
provisioningService['isInstanceRoleProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionInstanceRoleForUser(user, roleSlug);
|
||||
expect(userRepository.update).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -224,6 +228,8 @@ describe('ProvisioningService', () => {
|
|||
mock<Role>({ slug: 'global:member', roleType: 'global' }),
|
||||
);
|
||||
|
||||
provisioningService['isInstanceRoleProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionInstanceRoleForUser(user, roleSlug);
|
||||
expect(userRepository.update).toHaveBeenCalledWith(user.id, { role: { slug: roleSlug } });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
|
|
@ -237,6 +243,8 @@ describe('ProvisioningService', () => {
|
|||
mock<Role>({ slug: 'global:owner', roleType: 'global' }),
|
||||
);
|
||||
|
||||
provisioningService['isInstanceRoleProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionInstanceRoleForUser(user, roleSlug);
|
||||
expect(userRepository.update).toHaveBeenCalledWith(user.id, { role: { slug: roleSlug } });
|
||||
});
|
||||
|
|
@ -249,6 +257,8 @@ describe('ProvisioningService', () => {
|
|||
mock<Role>({ slug: 'global:owner', roleType: 'global' }),
|
||||
);
|
||||
|
||||
provisioningService['isInstanceRoleProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionInstanceRoleForUser(user, roleSlug);
|
||||
expect(userRepository.update).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
|
|
@ -262,6 +272,8 @@ describe('ProvisioningService', () => {
|
|||
mock<Role>({ slug: 'global:owner', roleType: 'project' }),
|
||||
);
|
||||
|
||||
provisioningService['isInstanceRoleProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionInstanceRoleForUser(user, roleSlug);
|
||||
expect(userRepository.update).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -277,6 +289,8 @@ describe('ProvisioningService', () => {
|
|||
const userId = 'user-id-123';
|
||||
const projectIdToRole = { not: 'an array' };
|
||||
|
||||
provisioningService['isProjectRolesProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionProjectRolesForUser(userId, projectIdToRole);
|
||||
|
||||
expect(projectService.addUser).not.toHaveBeenCalled();
|
||||
|
|
@ -291,6 +305,8 @@ describe('ProvisioningService', () => {
|
|||
const userId = 'user-id-123';
|
||||
const projectIdToRole = 'invalid-json-string';
|
||||
|
||||
provisioningService['isProjectRolesProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionProjectRolesForUser(userId, projectIdToRole);
|
||||
|
||||
expect(projectService.addUser).not.toHaveBeenCalled();
|
||||
|
|
@ -305,6 +321,8 @@ describe('ProvisioningService', () => {
|
|||
const userId = 'user-id-123';
|
||||
const projectIdToRole = [{ projectId: 'project-1', role: 'viewer' }]; // invalid value type
|
||||
|
||||
provisioningService['isProjectRolesProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionProjectRolesForUser(userId, projectIdToRole);
|
||||
|
||||
expect(projectService.addUser).not.toHaveBeenCalled();
|
||||
|
|
@ -321,6 +339,8 @@ describe('ProvisioningService', () => {
|
|||
projectRepository.find.mockResolvedValue([]);
|
||||
roleRepository.find.mockResolvedValue([mock<Role>({ slug: 'project:viewer' })]);
|
||||
|
||||
provisioningService['isProjectRolesProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionProjectRolesForUser(userId, projectIdToRole);
|
||||
|
||||
expect(projectService.addUser).not.toHaveBeenCalled();
|
||||
|
|
@ -332,6 +352,8 @@ describe('ProvisioningService', () => {
|
|||
projectRepository.find.mockResolvedValue([mock<Project>({ id: 'project-1' })]);
|
||||
roleRepository.find.mockResolvedValue([]);
|
||||
|
||||
provisioningService['isProjectRolesProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionProjectRolesForUser(userId, projectIdToRole);
|
||||
|
||||
expect(projectService.addUser).not.toHaveBeenCalled();
|
||||
|
|
@ -350,6 +372,8 @@ describe('ProvisioningService', () => {
|
|||
mock<Role>({ displayName: 'viewer', slug: 'project:viewer' }),
|
||||
]);
|
||||
|
||||
provisioningService['isProjectRolesProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionProjectRolesForUser(userId, projectIdToRole);
|
||||
|
||||
expect(projectService.addUser).not.toHaveBeenCalled();
|
||||
|
|
@ -375,6 +399,8 @@ describe('ProvisioningService', () => {
|
|||
mock<Role>({ displayName: 'editor', slug: 'project:editor' }),
|
||||
]);
|
||||
|
||||
provisioningService['isProjectRolesProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionProjectRolesForUser(userId, projectIdToRole);
|
||||
|
||||
expect(projectService.addUser).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -404,6 +430,8 @@ describe('ProvisioningService', () => {
|
|||
mock<Role>({ displayName: 'editor', slug: 'project:editor' }),
|
||||
]);
|
||||
|
||||
provisioningService['isProjectRolesProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionProjectRolesForUser(userId, projectIdToRole);
|
||||
|
||||
expect(projectService.addUser).toHaveBeenCalledTimes(2);
|
||||
|
|
@ -424,6 +452,8 @@ describe('ProvisioningService', () => {
|
|||
projectRepository.find.mockResolvedValue([mock<Project>({ id: 'project1' })]);
|
||||
roleRepository.find.mockResolvedValue([]);
|
||||
|
||||
provisioningService['isProjectRolesProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionProjectRolesForUser(userId, projectIdToRole);
|
||||
|
||||
expect(projectService.addUser).not.toHaveBeenCalled();
|
||||
|
|
@ -447,6 +477,8 @@ describe('ProvisioningService', () => {
|
|||
mock<Role>({ displayName: 'viewer', slug: 'project:viewer' }),
|
||||
]);
|
||||
|
||||
provisioningService['isProjectRolesProvisioningEnabled'] = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await provisioningService.provisionProjectRolesForUser(userId, projectIdToRole);
|
||||
|
||||
expect(entityManager.transaction).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -535,56 +567,4 @@ describe('ProvisioningService', () => {
|
|||
provisioningService.getConfig = originStateGetConfig;
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInstanceRoleProvisioningEnabled', () => {
|
||||
it('should return true if the instance role provisioning config is enabled', async () => {
|
||||
const originStateGetConfig = provisioningService.getConfig;
|
||||
|
||||
const provisioningConfig = { ...provisioningConfigDto, scopesProvisionInstanceRole: true };
|
||||
provisioningService.getConfig = jest.fn().mockResolvedValue(provisioningConfig);
|
||||
const isInstanceRoleProvisioningEnabled =
|
||||
await provisioningService.isInstanceRoleProvisioningEnabled();
|
||||
expect(isInstanceRoleProvisioningEnabled).toBe(true);
|
||||
|
||||
provisioningService.getConfig = originStateGetConfig;
|
||||
});
|
||||
|
||||
it('should return false if the instance role provisioning config is not enabled', async () => {
|
||||
const originStateGetConfig = provisioningService.getConfig;
|
||||
|
||||
const provisioningConfig = { ...provisioningConfigDto, scopesProvisionInstanceRole: false };
|
||||
provisioningService.getConfig = jest.fn().mockResolvedValue(provisioningConfig);
|
||||
const isInstanceRoleProvisioningEnabled =
|
||||
await provisioningService.isInstanceRoleProvisioningEnabled();
|
||||
expect(isInstanceRoleProvisioningEnabled).toBe(false);
|
||||
|
||||
provisioningService.getConfig = originStateGetConfig;
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProjectRolesProvisioningEnabled', () => {
|
||||
it('should return true if the project roles provisioning config is enabled', async () => {
|
||||
const originStateGetConfig = provisioningService.getConfig;
|
||||
|
||||
const provisioningConfig = { ...provisioningConfigDto, scopesProvisionProjectRoles: true };
|
||||
provisioningService.getConfig = jest.fn().mockResolvedValue(provisioningConfig);
|
||||
const isProjectRolesProvisioningEnabled =
|
||||
await provisioningService.isProjectRolesProvisioningEnabled();
|
||||
expect(isProjectRolesProvisioningEnabled).toBe(true);
|
||||
|
||||
provisioningService.getConfig = originStateGetConfig;
|
||||
});
|
||||
|
||||
it('should return false if the project roles provisioning config is not enabled', async () => {
|
||||
const originStateGetConfig = provisioningService.getConfig;
|
||||
|
||||
const provisioningConfig = { ...provisioningConfigDto, scopesProvisionProjectRoles: false };
|
||||
provisioningService.getConfig = jest.fn().mockResolvedValue(provisioningConfig);
|
||||
const isProjectRolesProvisioningEnabled =
|
||||
await provisioningService.isProjectRolesProvisioningEnabled();
|
||||
expect(isProjectRolesProvisioningEnabled).toBe(false);
|
||||
|
||||
provisioningService.getConfig = originStateGetConfig;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ export class ProvisioningService {
|
|||
}
|
||||
|
||||
async provisionInstanceRoleForUser(user: User, roleSlug: unknown) {
|
||||
if (!(await this.isInstanceRoleProvisioningEnabled())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const globalOwnerRoleSlug = 'global:owner';
|
||||
|
||||
if (typeof roleSlug !== 'string') {
|
||||
|
|
@ -116,6 +120,10 @@ export class ProvisioningService {
|
|||
* ]
|
||||
*/
|
||||
async provisionProjectRolesForUser(userId: string, projectIdToRoles: unknown): Promise<void> {
|
||||
if (!(await this.isProjectRolesProvisioningEnabled())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(projectIdToRoles)) {
|
||||
this.logger.warn(
|
||||
`Skipping project role provisioning. Invalid projectIdToRole type: expected array, received ${typeof projectIdToRoles}`,
|
||||
|
|
@ -254,7 +262,6 @@ export class ProvisioningService {
|
|||
const supportedPatchFields = [
|
||||
'scopesProvisionInstanceRole',
|
||||
'scopesProvisionProjectRoles',
|
||||
'scopesProvisioningFrequency',
|
||||
'scopesName',
|
||||
'scopesInstanceRoleClaimName',
|
||||
'scopesProjectsRolesClaimName',
|
||||
|
|
@ -330,6 +337,22 @@ export class ProvisioningService {
|
|||
return envProvidedConfig;
|
||||
}
|
||||
|
||||
async getInstanceRoleClaimName(): Promise<string | null> {
|
||||
if (!(await this.isInstanceRoleProvisioningEnabled())) {
|
||||
return null;
|
||||
}
|
||||
const provisioningConfig = await this.getConfig();
|
||||
return provisioningConfig.scopesInstanceRoleClaimName;
|
||||
}
|
||||
|
||||
async getProjectsRolesClaimName(): Promise<string | null> {
|
||||
if (!(await this.isProjectRolesProvisioningEnabled())) {
|
||||
return null;
|
||||
}
|
||||
const provisioningConfig = await this.getConfig();
|
||||
return provisioningConfig.scopesProjectsRolesClaimName;
|
||||
}
|
||||
|
||||
async isProvisioningEnabled(): Promise<boolean> {
|
||||
const provisioningConfig = await this.getConfig();
|
||||
return (
|
||||
|
|
@ -338,12 +361,12 @@ export class ProvisioningService {
|
|||
);
|
||||
}
|
||||
|
||||
async isInstanceRoleProvisioningEnabled(): Promise<boolean> {
|
||||
private async isInstanceRoleProvisioningEnabled(): Promise<boolean> {
|
||||
const provisioningConfig = await this.getConfig();
|
||||
return provisioningConfig.scopesProvisionInstanceRole;
|
||||
}
|
||||
|
||||
async isProjectRolesProvisioningEnabled(): Promise<boolean> {
|
||||
private async isProjectRolesProvisioningEnabled(): Promise<boolean> {
|
||||
const provisioningConfig = await this.getConfig();
|
||||
return provisioningConfig.scopesProvisionProjectRoles;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -312,17 +312,13 @@ export class OidcService {
|
|||
|
||||
private async applySsoProvisioning(user: User, claims: any) {
|
||||
const provisioningConfig = await this.provisioningService.getConfig();
|
||||
if (await this.provisioningService.isInstanceRoleProvisioningEnabled()) {
|
||||
await this.provisioningService.provisionInstanceRoleForUser(
|
||||
user,
|
||||
claims[provisioningConfig.scopesInstanceRoleClaimName],
|
||||
);
|
||||
const projectRoleMapping = claims[provisioningConfig.scopesProjectsRolesClaimName];
|
||||
const instanceRole = claims[provisioningConfig.scopesInstanceRoleClaimName];
|
||||
if (instanceRole) {
|
||||
await this.provisioningService.provisionInstanceRoleForUser(user, instanceRole);
|
||||
}
|
||||
if (await this.provisioningService.isProjectRolesProvisioningEnabled()) {
|
||||
await this.provisioningService.provisionProjectRolesForUser(
|
||||
user.id,
|
||||
claims[provisioningConfig.scopesProjectsRolesClaimName],
|
||||
);
|
||||
if (projectRoleMapping) {
|
||||
await this.provisioningService.provisionProjectRolesForUser(user.id, projectRoleMapping);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,4 +52,119 @@ describe('sso/saml/samlHelpers', () => {
|
|||
expect(userRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMappedSamlAttributesFromFlowResult', () => {
|
||||
test('returns the mapped attributes from the flow result', () => {
|
||||
const flowResult = {
|
||||
extract: {
|
||||
attributes: {
|
||||
email: 'test@test.com',
|
||||
firstName: 'test',
|
||||
lastName: 'test',
|
||||
userPrincipalName: 'test',
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const attributeMapping = {
|
||||
email: 'email',
|
||||
firstName: 'firstName',
|
||||
lastName: 'lastName',
|
||||
userPrincipalName: 'userPrincipalName',
|
||||
};
|
||||
const jitClaimNames = {
|
||||
instanceRole: 'instanceRole',
|
||||
projectRoles: 'projectRoles',
|
||||
};
|
||||
|
||||
const result = helpers.getMappedSamlAttributesFromFlowResult(
|
||||
flowResult,
|
||||
attributeMapping,
|
||||
jitClaimNames,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
attributes: {
|
||||
email: 'test@test.com',
|
||||
firstName: 'test',
|
||||
lastName: 'test',
|
||||
userPrincipalName: 'test',
|
||||
},
|
||||
missingAttributes: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('returns the missing attributes from the flow result', () => {
|
||||
const flowResult = {
|
||||
extract: {
|
||||
attributes: {
|
||||
email: 'test@test.com',
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const attributeMapping = {
|
||||
email: 'email',
|
||||
firstName: 'firstName',
|
||||
lastName: 'lastName',
|
||||
userPrincipalName: 'userPrincipalName',
|
||||
};
|
||||
const jitClaimNames = {
|
||||
instanceRole: 'instanceRole',
|
||||
projectRoles: 'projectRoles',
|
||||
};
|
||||
|
||||
const result = helpers.getMappedSamlAttributesFromFlowResult(
|
||||
flowResult,
|
||||
attributeMapping,
|
||||
jitClaimNames,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
attributes: {
|
||||
email: 'test@test.com',
|
||||
},
|
||||
missingAttributes: ['userPrincipalName', 'firstName', 'lastName'],
|
||||
});
|
||||
});
|
||||
test('returns the attributes from the flow result with instance role', () => {
|
||||
const flowResult = {
|
||||
extract: {
|
||||
attributes: {
|
||||
email: 'test@test.com',
|
||||
firstName: 'test',
|
||||
lastName: 'test',
|
||||
userPrincipalName: 'test',
|
||||
projectRoles: ['projectRole1', 'projectRole2'],
|
||||
instanceRole: 'instanceRole',
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const attributeMapping = {
|
||||
email: 'email',
|
||||
instanceRole: 'instanceRole',
|
||||
firstName: 'firstName',
|
||||
lastName: 'lastName',
|
||||
userPrincipalName: 'userPrincipalName',
|
||||
};
|
||||
const jitClaimNames = {
|
||||
instanceRole: 'instanceRole',
|
||||
projectRoles: 'projectRoles',
|
||||
};
|
||||
const result = helpers.getMappedSamlAttributesFromFlowResult(
|
||||
flowResult,
|
||||
attributeMapping,
|
||||
jitClaimNames,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
attributes: {
|
||||
email: 'test@test.com',
|
||||
n8nInstanceRole: 'instanceRole',
|
||||
firstName: 'test',
|
||||
lastName: 'test',
|
||||
userPrincipalName: 'test',
|
||||
n8nProjectRoles: ['projectRole1', 'projectRole2'],
|
||||
},
|
||||
missingAttributes: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import * as samlHelpers from '@/sso.ee/saml/saml-helpers';
|
|||
import { SamlService } from '@/sso.ee/saml/saml.service.ee';
|
||||
import * as ssoHelpers from '@/sso.ee/sso-helpers';
|
||||
|
||||
import type { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
|
||||
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
|
|
@ -150,6 +152,8 @@ describe('SamlService', () => {
|
|||
let settingsRepository: SettingsRepository;
|
||||
let instanceSettings: InstanceSettings;
|
||||
let globalConfig: GlobalConfig;
|
||||
let userRepository: UserRepository;
|
||||
let provisioningService: ProvisioningService;
|
||||
const validator = new SamlValidator(mock());
|
||||
const logger = mockLogger();
|
||||
|
||||
|
|
@ -189,9 +193,12 @@ describe('SamlService', () => {
|
|||
instanceSettings = mock<InstanceSettings>({
|
||||
isMultiMain: true,
|
||||
});
|
||||
provisioningService = mock<ProvisioningService>();
|
||||
userRepository = mock<UserRepository>();
|
||||
globalConfig = mock<GlobalConfig>({
|
||||
sso: { saml: { loginEnabled: false } },
|
||||
});
|
||||
provisioningService = mock<ProvisioningService>();
|
||||
|
||||
jest
|
||||
.spyOn(ssoHelpers, 'reloadAuthenticationMethod')
|
||||
|
|
@ -202,9 +209,10 @@ describe('SamlService', () => {
|
|||
logger,
|
||||
mock<UrlService>(),
|
||||
validator,
|
||||
mock<UserRepository>(),
|
||||
userRepository,
|
||||
settingsRepository,
|
||||
instanceSettings,
|
||||
provisioningService,
|
||||
);
|
||||
// Mock GlobalConfig container access
|
||||
Container.set(require('@n8n/config').GlobalConfig, globalConfig);
|
||||
|
|
@ -312,6 +320,81 @@ describe('SamlService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// TODO: add tests for getAttributesFromLoginResponse
|
||||
|
||||
describe('handleSamlLogin', () => {
|
||||
// TODO: add test cases for remaining logic (so far only for onboarding user)
|
||||
it('throws error for invalid email', async () => {
|
||||
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue({
|
||||
email: 'invalid',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
userPrincipalName: '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
samlService.handleSamlLogin(mock<express.Request>(), 'post'),
|
||||
).rejects.toThrowError(new BadRequestError('Invalid email format'));
|
||||
});
|
||||
|
||||
it('logs in user that has already completed onboarding', async () => {
|
||||
const samlAttributes = {
|
||||
email: 'foo@bar.com',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
userPrincipalName: 'foo@bar.com',
|
||||
};
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: samlAttributes.email,
|
||||
authIdentities: [
|
||||
{ providerType: 'saml', providerId: samlAttributes.userPrincipalName } as any,
|
||||
],
|
||||
} as any;
|
||||
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue(samlAttributes);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser);
|
||||
|
||||
const loginResult = await samlService.handleSamlLogin(mock<express.Request>(), 'post');
|
||||
|
||||
expect(loginResult).toEqual({
|
||||
authenticatedUser: mockUser,
|
||||
attributes: samlAttributes,
|
||||
onboardingRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('provisions instance and project role for onboarded user', async () => {
|
||||
const samlAttributes = {
|
||||
email: 'foo@bar.com',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
userPrincipalName: 'foo@bar.com',
|
||||
n8nInstanceRole: 'global:admin',
|
||||
n8nProjectRoles: ['rgjhURvl0rnEQL3v:viewer', 'ussa2R6P7aDtuRaZ:viewer'],
|
||||
};
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: samlAttributes.email,
|
||||
authIdentities: [
|
||||
{ providerType: 'saml', providerId: samlAttributes.userPrincipalName } as any,
|
||||
],
|
||||
} as any;
|
||||
jest.spyOn(samlService, 'getAttributesFromLoginResponse').mockResolvedValue(samlAttributes);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser);
|
||||
|
||||
await samlService.handleSamlLogin(mock<express.Request>(), 'post');
|
||||
|
||||
expect(provisioningService.provisionInstanceRoleForUser).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
samlAttributes.n8nInstanceRole,
|
||||
);
|
||||
expect(provisioningService.provisionProjectRolesForUser).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
samlAttributes.n8nProjectRoles,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadFromDbAndApplySamlPreferences', () => {
|
||||
test('does throw `InvalidSamlMetadataError` when no valid SAML metadata could have been loaded', async () => {
|
||||
// ARRANGE
|
||||
|
|
|
|||
|
|
@ -19,20 +19,12 @@ jest.mock('../../saml-helpers', () => ({
|
|||
}));
|
||||
|
||||
import { isConnectionTestRequest, isSamlLicensedAndEnabled } from '../../saml-helpers';
|
||||
import { type ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
|
||||
|
||||
const authService = mock<AuthService>();
|
||||
const samlService = mock<SamlService>();
|
||||
const urlService = mock<UrlService>();
|
||||
const eventService = mock<EventService>();
|
||||
const provisioningService = mock<ProvisioningService>();
|
||||
const controller = new SamlController(
|
||||
authService,
|
||||
samlService,
|
||||
urlService,
|
||||
eventService,
|
||||
provisioningService,
|
||||
);
|
||||
const controller = new SamlController(authService, samlService, urlService, eventService);
|
||||
|
||||
const user = mock<User>({
|
||||
id: '123',
|
||||
|
|
@ -46,7 +38,6 @@ const attributes: SamlUserAttributes = {
|
|||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
userPrincipalName: 'upn:test@example.com',
|
||||
n8nInstanceRole: 'n8n_instance_role',
|
||||
};
|
||||
|
||||
describe('Test views', () => {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import {
|
|||
} from '../service-provider.ee';
|
||||
import type { SamlLoginBinding } from '../types';
|
||||
import { getInitSSOFormView } from '../views/init-sso-post';
|
||||
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
|
||||
|
||||
@RestController('/sso/saml')
|
||||
export class SamlController {
|
||||
|
|
@ -35,7 +34,6 @@ export class SamlController {
|
|||
private readonly samlService: SamlService,
|
||||
private readonly urlService: UrlService,
|
||||
private readonly eventService: EventService,
|
||||
private readonly provisioningService: ProvisioningService,
|
||||
) {}
|
||||
|
||||
@Get('/metadata', { skipAuth: true })
|
||||
|
|
@ -131,16 +129,6 @@ export class SamlController {
|
|||
if (isSamlLicensedAndEnabled()) {
|
||||
this.authService.issueCookie(res, loginResult.authenticatedUser, true, req.browserId);
|
||||
|
||||
const isRoleProvisioningEnabled =
|
||||
await this.provisioningService.isInstanceRoleProvisioningEnabled();
|
||||
|
||||
if (isRoleProvisioningEnabled && loginResult.attributes.n8nInstanceRole) {
|
||||
await this.provisioningService.provisionInstanceRoleForUser(
|
||||
loginResult.authenticatedUser,
|
||||
loginResult.attributes.n8nInstanceRole,
|
||||
);
|
||||
}
|
||||
|
||||
if (loginResult.onboardingRequired) {
|
||||
return res.redirect(this.urlService.getInstanceBaseUrl() + '/saml/onboarding');
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -136,6 +136,10 @@ type GetMappedSamlReturn = {
|
|||
export function getMappedSamlAttributesFromFlowResult(
|
||||
flowResult: FlowResult,
|
||||
attributeMapping: SamlAttributeMapping,
|
||||
jitClaimNames: {
|
||||
instanceRole: string | null;
|
||||
projectRoles: string | null;
|
||||
},
|
||||
): GetMappedSamlReturn {
|
||||
const result: GetMappedSamlReturn = {
|
||||
attributes: undefined,
|
||||
|
|
@ -144,21 +148,28 @@ export function getMappedSamlAttributesFromFlowResult(
|
|||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (flowResult?.extract?.attributes) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const attributes = flowResult.extract.attributes as { [key: string]: string };
|
||||
const attributes = flowResult.extract.attributes as { [key: string]: string | string[] };
|
||||
// TODO:SAML: fetch mapped attributes from flowResult.extract.attributes and create or login user
|
||||
const email = attributes[attributeMapping.email];
|
||||
const firstName = attributes[attributeMapping.firstName];
|
||||
const lastName = attributes[attributeMapping.lastName];
|
||||
const userPrincipalName = attributes[attributeMapping.userPrincipalName];
|
||||
const n8nInstanceRole = attributes[attributeMapping.n8nInstanceRole];
|
||||
const email = attributes[attributeMapping.email] as string;
|
||||
const firstName = attributes[attributeMapping.firstName] as string;
|
||||
const lastName = attributes[attributeMapping.lastName] as string;
|
||||
const userPrincipalName = attributes[attributeMapping.userPrincipalName] as string;
|
||||
|
||||
result.attributes = {
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
userPrincipalName,
|
||||
n8nInstanceRole,
|
||||
};
|
||||
if (jitClaimNames.instanceRole && typeof attributes[jitClaimNames.instanceRole] === 'string') {
|
||||
result.attributes.n8nInstanceRole = attributes[jitClaimNames.instanceRole] as string;
|
||||
}
|
||||
if (jitClaimNames.projectRoles && attributes[jitClaimNames.projectRoles]) {
|
||||
const projectRolesFromFlowResult = attributes[jitClaimNames.projectRoles];
|
||||
result.attributes.n8nProjectRoles = Array.isArray(projectRolesFromFlowResult)
|
||||
? projectRolesFromFlowResult
|
||||
: [projectRolesFromFlowResult];
|
||||
}
|
||||
if (!email) result.missingAttributes.push(attributeMapping.email);
|
||||
if (!userPrincipalName) result.missingAttributes.push(attributeMapping.userPrincipalName);
|
||||
if (!firstName) result.missingAttributes.push(attributeMapping.firstName);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ProvisioningConfigDto, SamlPreferences } from '@n8n/api-types';
|
||||
import type { SamlPreferences, SamlPreferencesAttributeMapping } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { Settings, User } from '@n8n/db';
|
||||
|
|
@ -32,7 +32,7 @@ import { isSsoJustInTimeProvisioningEnabled, reloadAuthenticationMethod } from '
|
|||
|
||||
import { AuthError } from '@/errors/response-errors/auth.error';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { PROVISIONING_PREFERENCES_DB_KEY } from '@/modules/provisioning.ee/constants';
|
||||
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
@Service()
|
||||
|
|
@ -48,8 +48,6 @@ export class SamlService {
|
|||
firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname',
|
||||
lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname',
|
||||
userPrincipalName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn',
|
||||
// this value is loaded on init from the provisioning config
|
||||
n8nInstanceRole: '',
|
||||
},
|
||||
metadata: '',
|
||||
metadataUrl: '',
|
||||
|
|
@ -86,6 +84,7 @@ export class SamlService {
|
|||
private readonly userRepository: UserRepository,
|
||||
private readonly settingsRepository: SettingsRepository,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly provisioningService: ProvisioningService,
|
||||
) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
|
|
@ -220,6 +219,7 @@ export class SamlService {
|
|||
(e) => e.providerType === 'saml' && e.providerId === attributes.userPrincipalName,
|
||||
)
|
||||
) {
|
||||
await this.applySsoProvisioning(user, attributes);
|
||||
return {
|
||||
authenticatedUser: user,
|
||||
attributes,
|
||||
|
|
@ -229,6 +229,7 @@ export class SamlService {
|
|||
// Login path for existing users that are NOT fully set up for SAML
|
||||
const updatedUser = await updateUserFromSamlAttributes(user, attributes);
|
||||
const onboardingRequired = !updatedUser.firstName || !updatedUser.lastName;
|
||||
await this.applySsoProvisioning(updatedUser, attributes);
|
||||
return {
|
||||
authenticatedUser: updatedUser,
|
||||
attributes,
|
||||
|
|
@ -239,6 +240,7 @@ export class SamlService {
|
|||
// New users to be created JIT based on SAML attributes
|
||||
if (isSsoJustInTimeProvisioningEnabled()) {
|
||||
const newUser = await createUserFromSamlAttributes(attributes);
|
||||
await this.applySsoProvisioning(newUser, attributes);
|
||||
return {
|
||||
authenticatedUser: newUser,
|
||||
attributes,
|
||||
|
|
@ -255,6 +257,18 @@ export class SamlService {
|
|||
};
|
||||
}
|
||||
|
||||
private async applySsoProvisioning(user: User, attributes: SamlPreferencesAttributeMapping) {
|
||||
if (attributes?.n8nInstanceRole) {
|
||||
await this.provisioningService.provisionInstanceRoleForUser(user, attributes.n8nInstanceRole);
|
||||
}
|
||||
if (attributes?.n8nProjectRoles) {
|
||||
await this.provisioningService.provisionProjectRolesForUser(
|
||||
user.id,
|
||||
attributes.n8nProjectRoles,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async broadcastReloadSAMLConfigurationCommand(): Promise<void> {
|
||||
if (this.instanceSettings.isMultiMain) {
|
||||
const { Publisher } = await import('@/scaling/pubsub/publisher.service');
|
||||
|
|
@ -388,18 +402,8 @@ export class SamlService {
|
|||
const samlPreferences = await this.settingsRepository.findOne({
|
||||
where: { key: SAML_PREFERENCES_DB_KEY },
|
||||
});
|
||||
const provisioningConfigObject = await this.settingsRepository.findOne({
|
||||
where: { key: PROVISIONING_PREFERENCES_DB_KEY },
|
||||
});
|
||||
if (samlPreferences) {
|
||||
const prefs = jsonParse<SamlPreferences>(samlPreferences.value);
|
||||
const provisioningConfig = jsonParse<ProvisioningConfigDto>(
|
||||
provisioningConfigObject?.value ?? '{}',
|
||||
);
|
||||
|
||||
if (prefs && prefs.mapping) {
|
||||
prefs.mapping.n8nInstanceRole = provisioningConfig.scopesInstanceRoleClaimName;
|
||||
}
|
||||
|
||||
if (prefs) {
|
||||
if (apply) {
|
||||
|
|
@ -500,6 +504,10 @@ export class SamlService {
|
|||
const { attributes, missingAttributes } = getMappedSamlAttributesFromFlowResult(
|
||||
parsedSamlResponse,
|
||||
this._samlPreferences.mapping,
|
||||
{
|
||||
instanceRole: await this.provisioningService.getInstanceRoleClaimName(),
|
||||
projectRoles: await this.provisioningService.getProjectsRolesClaimName(),
|
||||
},
|
||||
);
|
||||
if (!attributes) {
|
||||
throw new AuthError('SAML Authentication failed. Invalid SAML response.');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { SamlPreferences } from '@n8n/api-types';
|
||||
import type { SamlPreferences, SamlPreferencesAttributeMapping } from '@n8n/api-types';
|
||||
|
||||
export type SamlLoginBinding = SamlPreferences['loginBinding'];
|
||||
export type SamlAttributeMapping = NonNullable<SamlPreferences['mapping']>;
|
||||
export type SamlAttributeMapping = NonNullable<SamlPreferencesAttributeMapping>;
|
||||
export type SamlUserAttributes = SamlAttributeMapping;
|
||||
|
|
|
|||
|
|
@ -2428,13 +2428,8 @@
|
|||
"settings.provisioning.scopesProjectsRolesClaimName": "Projects Roles Claim Name",
|
||||
"settings.provisioning.scopesProjectsRolesClaimName.placeholder": "Enter projects roles claim name",
|
||||
"settings.provisioning.scopesProjectsRolesClaimName.help": "The claim name used to provision projects and their roles from Oauth. For SAML / LDAP, this will be the attribute name checked.",
|
||||
"settings.provisioning.scopesProvisionInstanceRole": "Provision Instance Role",
|
||||
"settings.provisioning.scopesProvisionInstanceRole.help": "Enable automatic provisioning of instance roles",
|
||||
"settings.provisioning.scopesProvisionProjectRoles": "Provision Project Roles",
|
||||
"settings.provisioning.scopesProvisionProjectRoles.help": "Enable automatic provisioning of projects and project roles",
|
||||
"settings.provisioning.scopesProvisioningFrequency": "Provisioning Frequency",
|
||||
"settings.provisioning.scopesProvisioningFrequency.placeholder": "Select provisioning frequency",
|
||||
"settings.provisioning.scopesProvisioningFrequency.help": "How often to provision projects and roles",
|
||||
"settings.provisioning.toggle": "Provision instance and project roles",
|
||||
"settings.provisioning.toggle.help": "Project access can only be defined on external provider. Any existing project access configured in n8n, but not on the provider, will be removed once a user logs in.",
|
||||
"settings.externalSecrets.title": "External Secrets",
|
||||
"settings.externalSecrets.info": "Connect external secrets tools for centralized credentials management across environments, and to enhance system security.",
|
||||
"settings.externalSecrets.info.link": "More info",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ export interface ProvisioningConfig {
|
|||
scopesProjectsRolesClaimName: string;
|
||||
scopesProvisionInstanceRole: boolean;
|
||||
scopesProvisionProjectRoles: boolean;
|
||||
scopesProvisioningFrequency: string;
|
||||
}
|
||||
|
||||
export const getProvisioningConfig = async (
|
||||
|
|
|
|||
|
|
@ -7,15 +7,7 @@ import { useProvisioningStore } from '../provisioning.store';
|
|||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import {
|
||||
N8nHeading,
|
||||
N8nText,
|
||||
N8nSpinner,
|
||||
N8nInput,
|
||||
N8nButton,
|
||||
N8nSelect,
|
||||
N8nOption,
|
||||
} from '@n8n/design-system';
|
||||
import { N8nHeading, N8nText, N8nSpinner, N8nInput, N8nButton } from '@n8n/design-system';
|
||||
import { type ProvisioningConfig } from '@n8n/rest-api-client';
|
||||
|
||||
const i18n = useI18n();
|
||||
|
|
@ -53,30 +45,22 @@ const form = reactive({
|
|||
scopesName: '',
|
||||
scopesInstanceRoleClaimName: '',
|
||||
scopesProjectsRolesClaimName: '',
|
||||
scopesProvisionInstanceRole: false,
|
||||
scopesProvisionProjectRoles: false,
|
||||
scopesProvisioningFrequency: 'never',
|
||||
provisioningEnabled: false,
|
||||
});
|
||||
|
||||
// Frequency options
|
||||
const frequencyOptions = [
|
||||
{ label: 'Never', value: 'never' },
|
||||
{ label: 'First Login', value: 'first_login' },
|
||||
{ label: 'Every Login', value: 'every_login' },
|
||||
];
|
||||
|
||||
const isFormDirty = computed(() => {
|
||||
const cfg = provisioningStore.provisioningConfig;
|
||||
if (!cfg) return false;
|
||||
const keys: Array<keyof ProvisioningConfig> = [
|
||||
const config = provisioningStore.provisioningConfig;
|
||||
if (!config) return false;
|
||||
const formKeysThatMatchWithConfig: Array<keyof typeof form & keyof ProvisioningConfig> = [
|
||||
'scopesName',
|
||||
'scopesInstanceRoleClaimName',
|
||||
'scopesProjectsRolesClaimName',
|
||||
'scopesProvisionInstanceRole',
|
||||
'scopesProvisionProjectRoles',
|
||||
'scopesProvisioningFrequency',
|
||||
];
|
||||
return keys.some((key) => form[key] !== cfg[key]);
|
||||
const configChanged = formKeysThatMatchWithConfig.some((key) => form[key] !== config[key]);
|
||||
const provisioningEnabledChanged =
|
||||
form.provisioningEnabled !==
|
||||
(config.scopesProvisionInstanceRole && config.scopesProvisionProjectRoles);
|
||||
return configChanged || provisioningEnabledChanged;
|
||||
});
|
||||
|
||||
const loadFormData = () => {
|
||||
|
|
@ -86,16 +70,19 @@ const loadFormData = () => {
|
|||
scopesName: cfg.scopesName || '',
|
||||
scopesInstanceRoleClaimName: cfg.scopesInstanceRoleClaimName || '',
|
||||
scopesProjectsRolesClaimName: cfg.scopesProjectsRolesClaimName || '',
|
||||
scopesProvisionInstanceRole: cfg.scopesProvisionInstanceRole ?? false,
|
||||
scopesProvisionProjectRoles: cfg.scopesProvisionProjectRoles ?? false,
|
||||
scopesProvisioningFrequency: cfg.scopesProvisioningFrequency || 'never',
|
||||
});
|
||||
form.provisioningEnabled = cfg.scopesProvisionInstanceRole;
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
await provisioningStore.saveProvisioningConfig({ ...form });
|
||||
const { provisioningEnabled, ...dataToSave } = form;
|
||||
await provisioningStore.saveProvisioningConfig({
|
||||
...dataToSave,
|
||||
scopesProvisionInstanceRole: provisioningEnabled,
|
||||
scopesProvisionProjectRoles: provisioningEnabled,
|
||||
});
|
||||
await provisioningStore.getProvisioningConfig();
|
||||
loadFormData();
|
||||
|
||||
|
|
@ -115,7 +102,7 @@ const onSave = async () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-2xl">
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.heading">
|
||||
<N8nHeading size="2xlarge">{{ i18n.baseText('settings.provisioning.title') }}</N8nHeading>
|
||||
</div>
|
||||
|
|
@ -130,64 +117,16 @@ const onSave = async () => {
|
|||
|
||||
<div v-else>
|
||||
<div :class="$style.group">
|
||||
<label>{{ i18n.baseText('settings.provisioning.scopesProvisionInstanceRole') }}</label>
|
||||
<div :class="$style.switchContainer">
|
||||
<label :class="$style.switchLabel">
|
||||
<input
|
||||
v-model="form.scopesProvisionInstanceRole"
|
||||
type="checkbox"
|
||||
:class="$style.checkbox"
|
||||
/>
|
||||
<span :class="$style.switchText">
|
||||
{{
|
||||
form.scopesProvisionInstanceRole
|
||||
? i18n.baseText('generic.yes')
|
||||
: i18n.baseText('generic.no')
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<small>{{ i18n.baseText('settings.provisioning.scopesProvisionInstanceRole.help') }}</small>
|
||||
</div>
|
||||
|
||||
<div :class="$style.group">
|
||||
<label>{{ i18n.baseText('settings.provisioning.scopesProvisionProjectRoles') }}</label>
|
||||
<div :class="$style.switchContainer">
|
||||
<label :class="$style.switchLabel">
|
||||
<input
|
||||
v-model="form.scopesProvisionProjectRoles"
|
||||
type="checkbox"
|
||||
:class="$style.checkbox"
|
||||
/>
|
||||
<span :class="$style.switchText">
|
||||
{{
|
||||
form.scopesProvisionProjectRoles
|
||||
? i18n.baseText('generic.yes')
|
||||
: i18n.baseText('generic.no')
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<small>{{ i18n.baseText('settings.provisioning.scopesProvisionProjectRoles.help') }}</small>
|
||||
</div>
|
||||
|
||||
<div :class="$style.group">
|
||||
<label>{{ i18n.baseText('settings.provisioning.scopesProvisioningFrequency') }}</label>
|
||||
<N8nSelect
|
||||
v-model="form.scopesProvisioningFrequency"
|
||||
size="large"
|
||||
:placeholder="
|
||||
i18n.baseText('settings.provisioning.scopesProvisioningFrequency.placeholder')
|
||||
"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="option in frequencyOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
<small>{{ i18n.baseText('settings.provisioning.scopesProvisioningFrequency.help') }}</small>
|
||||
<label for="provisioning-enabled">{{
|
||||
i18n.baseText('settings.provisioning.toggle')
|
||||
}}</label>
|
||||
<small>{{ i18n.baseText('settings.provisioning.toggle.help') }}</small>
|
||||
<input
|
||||
id="provisioning-enabled"
|
||||
v-model="form.provisioningEnabled"
|
||||
type="checkbox"
|
||||
:class="$style.checkbox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.group">
|
||||
|
|
@ -244,6 +183,11 @@ const onSave = async () => {
|
|||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
padding-bottom: var(--spacing--2xl);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
|
@ -277,30 +221,19 @@ const onSave = async () => {
|
|||
|
||||
small {
|
||||
display: block;
|
||||
padding: var(--spacing--2xs) 0 0;
|
||||
padding: var(--spacing--2xs) 0;
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text);
|
||||
}
|
||||
}
|
||||
|
||||
.switchContainer {
|
||||
margin: var(--spacing--xs) 0;
|
||||
}
|
||||
|
||||
.switchLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
.frequencySelect {
|
||||
display: block;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
margin-right: var(--spacing--xs);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.switchText {
|
||||
font-size: var(--font-size--sm);
|
||||
font-weight: var(--font-weight--medium);
|
||||
color: var(--color--text);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user