diff --git a/packages/core/src/queries/organization/index.ts b/packages/core/src/queries/organization/index.ts index 35fb5d51aa76..35cdcd76b135 100644 --- a/packages/core/src/queries/organization/index.ts +++ b/packages/core/src/queries/organization/index.ts @@ -19,6 +19,7 @@ import { OrganizationInvitationStatus, OrganizationRoleResourceScopeRelations, Scopes, + Resources, } from '@logto/schemas'; import { sql, type CommonQueryMethods } from '@silverhand/slonik'; @@ -57,25 +58,52 @@ class OrganizationRolesQueries extends SchemaQueries< ) { const { table, fields } = convertToIdentifiers(OrganizationRoles, true); const relations = convertToIdentifiers(OrganizationRoleScopeRelations, true); + const resourceScopeRelations = convertToIdentifiers( + OrganizationRoleResourceScopeRelations, + true + ); const scopes = convertToIdentifiers(OrganizationScopes, true); + const resourceScopes = convertToIdentifiers(Scopes, true); + const resource = convertToIdentifiers(Resources, true); return sql` select ${table}.*, coalesce( - json_agg( - json_build_object( + json_agg(distinct + jsonb_build_object( 'id', ${scopes.fields.id}, - 'name', ${scopes.fields.name} - ) order by ${scopes.fields.name} + 'name', ${scopes.fields.name}, + 'order', ${scopes.fields.id} + ) ) filter (where ${scopes.fields.id} is not null), '[]' - ) as scopes -- left join could produce nulls as scopes + ) as scopes, -- left join could produce nulls as scopes + coalesce( + json_agg(distinct + jsonb_build_object( + 'id', ${resourceScopes.fields.id}, + 'name', ${resourceScopes.fields.name}, + 'resource', json_build_object( + 'id', ${resource.fields.id}, + 'name', ${resource.fields.name} + ), + 'order', ${resourceScopes.fields.id} + ) + ) filter (where ${resourceScopes.fields.id} is not null), + '[]' + ) as "resourceScopes" -- left join could produce nulls as resourceScopes from ${table} left join ${relations.table} on ${relations.fields.organizationRoleId} = ${fields.id} left join ${scopes.table} on ${relations.fields.organizationScopeId} = ${scopes.fields.id} + left join ${resourceScopeRelations.table} + on ${resourceScopeRelations.fields.organizationRoleId} = ${fields.id} + left join ${resourceScopes.table} + on ${resourceScopeRelations.fields.scopeId} = ${resourceScopes.fields.id} + left join ${resource.table} + on ${resourceScopes.fields.resourceId} = ${resource.fields.id} ${conditionalSql(roleId, (id) => { return sql`where ${fields.id} = ${id}`; })} diff --git a/packages/core/src/routes/organization/roles.openapi.json b/packages/core/src/routes/organization/roles.openapi.json index 830430f1ff41..b9148fdf0b23 100644 --- a/packages/core/src/routes/organization/roles.openapi.json +++ b/packages/core/src/routes/organization/roles.openapi.json @@ -29,6 +29,12 @@ }, "description": { "description": "The description of the organization role." + }, + "organizationScopeIds": { + "description": "An array of organization scope IDs to be assigned to the organization role." + }, + "scopeIds": { + "description": "An array of resource scope IDs to be assigned to the organization role." } } } diff --git a/packages/core/src/routes/organization/roles.ts b/packages/core/src/routes/organization/roles.ts index d6ba40e715ba..f664b9b481aa 100644 --- a/packages/core/src/routes/organization/roles.ts +++ b/packages/core/src/routes/organization/roles.ts @@ -57,6 +57,7 @@ export default function organizationRoleRoutes( /** Allows to carry an initial set of scopes for creating a new organization role. */ type CreateOrganizationRolePayload = Omit & { organizationScopeIds: string[]; + scopeIds: string[]; }; const createGuard: z.ZodType = @@ -66,6 +67,7 @@ export default function organizationRoleRoutes( }) .extend({ organizationScopeIds: z.array(z.string()).default([]), + scopeIds: z.array(z.string()).default([]), }); router.post( @@ -76,11 +78,17 @@ export default function organizationRoleRoutes( status: [201, 422], }), async (ctx, next) => { - const { organizationScopeIds: scopeIds, ...data } = ctx.guard.body; + const { organizationScopeIds, scopeIds, ...data } = ctx.guard.body; const role = await roles.insert({ id: generateStandardId(), ...data }); + if (organizationScopeIds.length > 0) { + await rolesScopes.insert( + ...organizationScopeIds.map<[string, string]>((id) => [role.id, id]) + ); + } + if (scopeIds.length > 0) { - await rolesScopes.insert(...scopeIds.map<[string, string]>((id) => [role.id, id])); + await rolesResourceScopes.insert(...scopeIds.map<[string, string]>((id) => [role.id, id])); } ctx.body = role; diff --git a/packages/integration-tests/src/api/organization-role.ts b/packages/integration-tests/src/api/organization-role.ts index 9e8c535d4bb0..8bd125340bc3 100644 --- a/packages/integration-tests/src/api/organization-role.ts +++ b/packages/integration-tests/src/api/organization-role.ts @@ -12,6 +12,7 @@ export type CreateOrganizationRolePostData = { name: string; description?: string; organizationScopeIds?: string[]; + scopeIds?: string[]; }; export class OrganizationRoleApi extends ApiFactory< diff --git a/packages/integration-tests/src/tests/api/organization/organization-role.test.ts b/packages/integration-tests/src/tests/api/organization/organization-role.test.ts index aa9d1bfba4e8..42fc1620b88d 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-role.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-role.test.ts @@ -14,6 +14,15 @@ describe('organization role APIs', () => { describe('organization roles', () => { const roleApi = new OrganizationRoleApiTest(); const scopeApi = new OrganizationScopeApiTest(); + const resourceScopeApi = new ScopeApiTest(); + + beforeAll(async () => { + await resourceScopeApi.initResource(); + }); + + afterAll(async () => { + await resourceScopeApi.cleanUp(); + }); afterEach(async () => { await Promise.all([roleApi.cleanUp(), scopeApi.cleanUp()]); @@ -72,17 +81,23 @@ describe('organization role APIs', () => { expect(role).toStrictEqual(createdRole); }); - it('should be able to create a new organization with initial scopes', async () => { + it('should be able to create a new organization with initial organization scopes and resource scopes', async () => { const [scope1, scope2] = await Promise.all([ scopeApi.create({ name: 'test' + randomId() }), scopeApi.create({ name: 'test' + randomId() }), ]); + const [resourceScope1, resourceScope2] = await Promise.all([ + resourceScopeApi.create({ name: 'test' + randomId() }), + resourceScopeApi.create({ name: 'test' + randomId() }), + ]); const createdRole = await roleApi.create({ name: 'test' + randomId(), description: 'test description.', organizationScopeIds: [scope1.id, scope2.id], + scopeIds: [resourceScope1.id, resourceScope2.id], }); const scopes = await roleApi.getScopes(createdRole.id); + const resourceScopes = await roleApi.getResourceScopes(createdRole.id); const roles = await roleApi.getList(); const roleWithScopes = roles.find((role) => role.id === createdRole.id); @@ -92,6 +107,13 @@ describe('organization role APIs', () => { ); expect(scopes).toContainEqual(expect.objectContaining(pick(scope, 'id', 'name'))); } + + for (const scope of [resourceScope1, resourceScope2]) { + expect(roleWithScopes?.resourceScopes).toContainEqual( + expect.objectContaining(pick(scope, 'id', 'name')) + ); + expect(resourceScopes).toContainEqual(expect.objectContaining(pick(scope, 'id', 'name'))); + } }); it('should fail when try to get an organization role that does not exist', async () => { diff --git a/packages/schemas/src/types/organization.ts b/packages/schemas/src/types/organization.ts index 64ab17f8e91e..51407c45a50d 100644 --- a/packages/schemas/src/types/organization.ts +++ b/packages/schemas/src/types/organization.ts @@ -20,8 +20,21 @@ export type OrganizationScopeEntity = { name: string; }; +/** + * The simplified resource scope entity that is returned for some endpoints. + */ +export type ResourceScopeEntity = { + id: string; + name: string; + resource: { + id: string; + name: string; + }; +}; + export type OrganizationRoleWithScopes = OrganizationRole & { scopes: OrganizationScopeEntity[]; + resourceScopes: ResourceScopeEntity[]; }; export const organizationRoleWithScopesGuard: ToZodObject = @@ -32,6 +45,16 @@ export const organizationRoleWithScopesGuard: ToZodObject