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
1 change: 1 addition & 0 deletions packages/backend/src/ee/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export async function getEnterpriseAppArguments(): Promise<EnterpriseAppArgument
commercialFeatureFlagModel:
models.getFeatureFlagModel() as CommercialFeatureFlagModel,
rolesModel: models.getRolesModel(),
projectModel: models.getProjectModel(),
}),
serviceAccountService: ({ models, context }) =>
new ServiceAccountService({
Expand Down
55 changes: 55 additions & 0 deletions packages/backend/src/ee/services/ScimService/ScimService.mock.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {
OrganizationMemberProfile,
OrganizationMemberRole,
ProjectType,
Role,
} from '@lightdash/common';
import { LightdashAnalytics } from '../../../analytics/LightdashAnalytics';
import { lightdashConfigMock } from '../../../config/lightdashConfig.mock';
import { EmailModel } from '../../../models/EmailModel';
import { GroupsModel } from '../../../models/GroupsModel';
import { OrganizationMemberProfileModel } from '../../../models/OrganizationMemberProfileModel';
import { ProjectModel } from '../../../models/ProjectModel/ProjectModel';
import { RolesModel } from '../../../models/RolesModel';
import { UserModel } from '../../../models/UserModel';
import { CommercialFeatureFlagModel } from '../../models/CommercialFeatureFlagModel';
Expand All @@ -27,6 +30,52 @@ export const mockUser: OrganizationMemberProfile = {
organizationUuid: 'org-uuid',
};

// Mock projects for testing
export const mockProjects = [
{
projectUuid: 'project-1-uuid',
name: 'Analytics Project',
type: ProjectType.DEFAULT,
organizationUuid: 'org-uuid',
},
{
projectUuid: 'project-2-uuid',
name: 'Marketing Project',
type: ProjectType.DEFAULT,
organizationUuid: 'org-uuid',
},
{
projectUuid: 'preview-project-uuid',
name: 'Preview Project',
type: ProjectType.PREVIEW,
organizationUuid: 'org-uuid',
},
];

// Mock custom roles for testing
export const mockCustomRoles: Role[] = [
{
roleUuid: 'custom-role-1-uuid',
name: 'Data Analyst',
description: 'Custom data analyst role',
organizationUuid: 'org-uuid',
ownerType: 'user',
createdBy: 'test-user-uuid',
createdAt: new Date(),
updatedAt: new Date(),
},
{
roleUuid: 'custom-role-2-uuid',
name: 'Report Builder',
description: 'Custom report builder role',
organizationUuid: 'org-uuid',
ownerType: 'user',
createdBy: 'test-user-uuid',
createdAt: new Date(),
updatedAt: new Date(),
},
];

// Mock organization member profile model
const organizationMemberProfileModelMock = {
getOrganizationMemberByUuid: jest.fn().mockResolvedValue(mockUser),
Expand Down Expand Up @@ -83,5 +132,11 @@ export const ScimServiceArgumentsMock: ConstructorParameters<
rolesModel: {
removeUserProjectAccess: jest.fn().mockResolvedValue(undefined),
removeUserAccessFromAllProjects: jest.fn().mockResolvedValue(3),
getRolesByOrganizationUuid: jest
.fn()
.mockResolvedValue(mockCustomRoles),
} as unknown as RolesModel,
projectModel: {
getAllByOrganizationUuid: jest.fn().mockResolvedValue(mockProjects),
} as unknown as ProjectModel,
};
52 changes: 45 additions & 7 deletions packages/backend/src/ee/services/ScimService/ScimService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,8 +706,8 @@ describe('ScimService', () => {

expect(result).toEqual({
schemas: [ScimSchemaType.LIST_RESPONSE],
totalResults: 5, // viewer, interactive_viewer, editor, developer, admin
itemsPerPage: 5,
totalResults: 19, // 5 org system + 7 per project (2 projects) = 5+14 = 21
itemsPerPage: 19,
startIndex: 1,
Resources: expect.arrayContaining([
expect.objectContaining({
Expand All @@ -726,14 +726,28 @@ describe('ScimService', () => {
]),
});

// Verify we have the expected number of system roles
expect(result.Resources).toHaveLength(5);
// Verify we have the expected number of roles
expect(result.Resources).toHaveLength(19);

// Verify some specific role values
const roleValues = result.Resources.map((role) => role.value);
expect(roleValues).toContain('admin');
expect(roleValues).toContain('viewer');
expect(roleValues).toContain('editor');
expect(roleValues).toContain('admin'); // org-level system role
expect(roleValues).toContain('viewer'); // org-level system role
expect(roleValues).toContain('editor'); // org-level system role
expect(roleValues).toContain('project-1-uuid:admin'); // project-level system role
expect(roleValues).toContain(
'project-1-uuid:custom-role-1-uuid',
); // project-level custom role
expect(roleValues).toContain('project-2-uuid:admin'); // project-level system role
expect(roleValues).toContain(
'project-2-uuid:custom-role-2-uuid',
); // project-level custom role

// Verify we don't have preview project roles
const previewRoles = roleValues.filter((value) =>
value.includes('preview-project-uuid'),
);
expect(previewRoles).toHaveLength(0);
});
});

Expand Down Expand Up @@ -762,6 +776,30 @@ describe('ScimService', () => {
});
});

test('should return a specific project-level role by composite ID', async () => {
const organizationUuid = 'test-org-uuid';
const roleId = 'project-1-uuid:admin';

const result = await service.getRole(organizationUuid, roleId);

expect(result).toEqual({
schemas: [ScimSchemaType.ROLE],
id: 'project-1-uuid:admin',
value: 'project-1-uuid:admin',
display: 'Analytics Project - admin',
type: expect.any(String),
supported: true,
meta: {
resourceType: 'Role',
created: undefined, // System roles don't have creation dates
lastModified: undefined, // System roles don't have modification dates
location: expect.stringContaining(
'/api/v1/scim/v2/Roles/project-1-uuid:admin',
),
},
});
});

test('should throw error for non-existent role', async () => {
const organizationUuid = 'test-org-uuid';
const roleId = 'non-existent-role';
Expand Down
141 changes: 124 additions & 17 deletions packages/backend/src/ee/services/ScimService/ScimService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
NotFoundError,
OrganizationMemberProfile,
OrganizationMemberRole,
OrganizationProject,
ParameterError,
ProjectType,
Role,
ScimError,
ScimGroup,
Expand Down Expand Up @@ -42,6 +44,7 @@ import { LightdashConfig } from '../../../config/parseConfig';
import { EmailModel } from '../../../models/EmailModel';
import { GroupsModel } from '../../../models/GroupsModel';
import { OrganizationMemberProfileModel } from '../../../models/OrganizationMemberProfileModel';
import { ProjectModel } from '../../../models/ProjectModel/ProjectModel';
import { RolesModel } from '../../../models/RolesModel';
import { UserModel } from '../../../models/UserModel';
import { BaseService } from '../../../services/BaseService';
Expand All @@ -58,6 +61,7 @@ type ScimServiceArguments = {
serviceAccountModel: ServiceAccountModel;
commercialFeatureFlagModel: CommercialFeatureFlagModel;
rolesModel: RolesModel;
projectModel: ProjectModel;
};

export class ScimService extends BaseService {
Expand All @@ -79,6 +83,8 @@ export class ScimService extends BaseService {

private readonly rolesModel: RolesModel;

private readonly projectModel: ProjectModel;

constructor({
lightdashConfig,
organizationMemberProfileModel,
Expand All @@ -89,6 +95,7 @@ export class ScimService extends BaseService {
serviceAccountModel,
commercialFeatureFlagModel,
rolesModel,
projectModel,
}: ScimServiceArguments) {
super();
this.lightdashConfig = lightdashConfig;
Expand All @@ -100,6 +107,7 @@ export class ScimService extends BaseService {
this.serviceAccountModel = serviceAccountModel;
this.commercialFeatureFlagModel = commercialFeatureFlagModel;
this.rolesModel = rolesModel;
this.projectModel = projectModel;
}

private static throwForbiddenErrorOnNoPermission(user: SessionUser) {
Expand Down Expand Up @@ -1377,13 +1385,18 @@ export class ScimService extends BaseService {
private convertLightdashRoleToScimRole(
role: Role,
type: ScimRoleType,
project?: { projectUuid: string; name: string },
): ScimRole {
const id = role.roleUuid;
const id = project
? `${project.projectUuid}:${role.roleUuid}`
: role.roleUuid;
const display = project ? `${project.name} - ${role.name}` : role.name;

return {
schemas: [ScimSchemaType.ROLE],
id,
value: id,
display: role.name,
display,
type,
supported: true,
meta: {
Expand All @@ -1398,6 +1411,71 @@ export class ScimService extends BaseService {
};
}

private async getAllRoles(organizationUuid: string) {
const allScimRoles: ScimRole[] = [];

// Get system roles
const systemRoles = getSystemRoles();

// Get custom roles for organization
const customRoles = await this.rolesModel.getRolesByOrganizationUuid(
organizationUuid,
'user',
);

// Get all projects for the organization, ignoring preview projects
const allProjects = await this.projectModel.getAllByOrganizationUuid(
organizationUuid,
);
const nonPreviewProjects = allProjects.filter(
(project) => project.type !== ProjectType.PREVIEW,
);

// Add organization-level system roles
systemRoles.forEach((role) => {
allScimRoles.push(
this.convertLightdashRoleToScimRole(role, ScimRoleType.ORG),
);
});

// For each project, add system roles and custom roles
nonPreviewProjects.forEach((project) => {
// Add project-level system roles
systemRoles.forEach((role) => {
allScimRoles.push(
this.convertLightdashRoleToScimRole(
role,
ScimRoleType.PROJECT,
{
projectUuid: project.projectUuid,
name: project.name,
},
),
);
});

// Add project-level custom roles
customRoles.forEach((role) => {
allScimRoles.push(
this.convertLightdashRoleToScimRole(
role,
ScimRoleType.PROJECT_CUSTOM,
{
projectUuid: project.projectUuid,
name: project.name,
},
),
);
});
});
return {
allScimRoles,
projectsCount: nonPreviewProjects.length,
systemRolesCount: systemRoles.length,
customRolesCount: customRoles.length,
};
}

async listRoles({
organizationUuid,
filter,
Expand All @@ -1413,18 +1491,18 @@ export class ScimService extends BaseService {
const parsedFilter = filter ? parse(filter) : null;
this.logger.debug('SCIM: Parsed role filter', { parsedFilter });

const systemRoles = getSystemRoles();

// Convert org roles to SCIM format
const scimRoles = systemRoles.map((role) =>
this.convertLightdashRoleToScimRole(role, ScimRoleType.ORG),
);
const {
allScimRoles,
projectsCount,
systemRolesCount,
customRolesCount,
} = await this.getAllRoles(organizationUuid);

// Apply filter if specified
let filteredRoles = scimRoles;
let filteredRoles = allScimRoles;
if (parsedFilter?.op === 'eq') {
const { attrPath, compValue } = parsedFilter;
filteredRoles = scimRoles.filter((role) => {
filteredRoles = allScimRoles.filter((role) => {
switch (attrPath) {
case 'value':
return role.value === compValue;
Expand All @@ -1441,6 +1519,9 @@ export class ScimService extends BaseService {
this.logger.debug('SCIM: Successfully listed roles', {
organizationUuid,
totalResults: filteredRoles.length,
projectsCount,
systemRolesCount,
customRolesCount,
});

return {
Expand All @@ -1464,15 +1545,41 @@ export class ScimService extends BaseService {
}

async getRole(organizationUuid: string, roleId: string): Promise<ScimRole> {
const roles = await this.listRoles({ organizationUuid });
const role = roles.Resources.find((r) => r.id === roleId);
if (!role) {
throw new ScimError({
detail: `Role with ID ${roleId} not found`,
status: 404,
try {
this.logger.debug('SCIM: Getting role', {
roleId,
organizationUuid,
});
const { allScimRoles } = await this.getAllRoles(organizationUuid);
const role = allScimRoles.find((r) => r.id === roleId);
if (!role) {
throw new ScimError({
detail: `Role with ID ${roleId} not found`,
status: 404,
scimType: 'noTarget',
});
}
this.logger.debug('SCIM: Successfully retrieved role', {
organizationUuid,
roleId,
roleName: role.display,
type: role.type,
});
return role;
} catch (error) {
this.logger.error(
`Failed to retrieve SCIM role: ${getErrorMessage(error)}`,
);
const scimError =
error instanceof ScimError
? error
: new ScimError({
detail: getErrorMessage(error),
status: ScimService.getErrorStatus(error) ?? 404,
});
Sentry.captureException(scimError);
throw scimError;
}
return role;
}

static getServiceProviderConfig(): ScimServiceProviderConfig {
Expand Down
Loading