diff --git a/.changeset/fresh-gorillas-obey.md b/.changeset/fresh-gorillas-obey.md new file mode 100644 index 00000000000..603bbb29625 --- /dev/null +++ b/.changeset/fresh-gorillas-obey.md @@ -0,0 +1,7 @@ +--- +"@logto/core": minor +--- + +pagination is now optional for `GET /api/organizations/:id/users/:userId/roles` + +The default pagination is now removed. This isn't considered a breaking change, but we marked it as minor to get your attention. diff --git a/packages/core/src/routes/organization-role/index.ts b/packages/core/src/routes/organization-role/index.ts index 32951fa55af..44ba5404b92 100644 --- a/packages/core/src/routes/organization-role/index.ts +++ b/packages/core/src/routes/organization-role/index.ts @@ -60,9 +60,7 @@ export default function organizationRoleRoutes( }), async (ctx, next) => { const { limit, offset } = ctx.pagination; - const search = parseSearchOptions(organizationRoleSearchKeys, ctx.guard.query); - const [count, entities] = await roles.findAll(limit, offset, search); ctx.pagination.totalCount = count; diff --git a/packages/core/src/routes/organization/application/index.openapi.json b/packages/core/src/routes/organization/application/index.openapi.json index 2f75e22ad88..911a740be52 100644 --- a/packages/core/src/routes/organization/application/index.openapi.json +++ b/packages/core/src/routes/organization/application/index.openapi.json @@ -82,6 +82,37 @@ } } }, + "/api/organizations/{id}/applications/roles": { + "post": { + "tags": ["Dev feature"], + "summary": "Assign roles to applications in an organization", + "description": "Assign roles to applications in the specified organization.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "applicationIds": { + "description": "An array of application IDs to assign roles to." + }, + "organizationRoleIds": { + "description": "An array of organization role IDs to assign to the applications." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Roles were assigned to the applications successfully." + }, + "422": { + "description": "At least one of the IDs provided is not valid. For example, the organization ID, application ID, or organization role ID does not exist; the application is not a member of the organization; or the role type is not assignable to the application." + } + } + } + }, "/api/organizations/{id}/applications/{applicationId}/roles": { "get": { "tags": ["Dev feature"], diff --git a/packages/core/src/routes/organization/application/index.ts b/packages/core/src/routes/organization/application/index.ts index 7a02bb6af09..94f10725675 100644 --- a/packages/core/src/routes/organization/application/index.ts +++ b/packages/core/src/routes/organization/application/index.ts @@ -54,6 +54,35 @@ export default function applicationRoutes( } ); + router.post( + '/:id/applications/roles', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + body: z.object({ + applicationIds: z.string().min(1).array().nonempty(), + organizationRoleIds: z.string().min(1).array().nonempty(), + }), + status: [201, 422], + }), + async (ctx, next) => { + const { id } = ctx.guard.params; + const { applicationIds, organizationRoleIds } = ctx.guard.body; + + await organizations.relations.appsRoles.insert( + ...organizationRoleIds.flatMap((organizationRoleId) => + applicationIds.map((applicationId) => ({ + organizationId: id, + applicationId, + organizationRoleId, + })) + ) + ); + + ctx.status = 201; + return next(); + } + ); + // MARK: Organization - application role relation routes applicationRoleRelationRoutes(router, organizations); } diff --git a/packages/core/src/routes/organization/application/role-relations.ts b/packages/core/src/routes/organization/application/role-relations.ts index b9e3f6702ae..fa9722ee512 100644 --- a/packages/core/src/routes/organization/application/role-relations.ts +++ b/packages/core/src/routes/organization/application/role-relations.ts @@ -35,7 +35,7 @@ export default function applicationRoleRelationRoutes( router.get( pathname, - koaPagination(), + koaPagination({ isOptional: true }), koaGuard({ params: z.object(params), response: OrganizationRoles.guard.array(), @@ -49,10 +49,14 @@ export default function applicationRoleRelationRoutes( { organizationId: id, applicationId, - } + }, + ctx.pagination.disabled ? undefined : ctx.pagination ); - ctx.pagination.totalCount = totalCount; + if (!ctx.pagination.disabled) { + ctx.pagination.totalCount = totalCount; + } + ctx.body = entities; return next(); } diff --git a/packages/core/src/routes/organization/user/index.openapi.json b/packages/core/src/routes/organization/user/index.openapi.json index c1396516c09..db02188271c 100644 --- a/packages/core/src/routes/organization/user/index.openapi.json +++ b/packages/core/src/routes/organization/user/index.openapi.json @@ -91,7 +91,7 @@ "/api/organizations/{id}/users/roles": { "post": { "summary": "Assign roles to organization user members", - "description": "Assign roles to user members of the specified organization with the given data.", + "description": "Assign roles to user members of the specified organization.", "requestBody": { "content": { "application/json": { @@ -113,7 +113,7 @@ "description": "Roles were assigned to organization users successfully." }, "422": { - "description": "At least one of the IDs provided is not valid. For example, the organization ID, user ID, or organization role ID does not exist; the user is not a member of the organization." + "description": "At least one of the IDs provided is not valid. For example, the organization ID, user ID, or organization role ID does not exist; the user is not a member of the organization; or the role type is not assignable to the user." } } } diff --git a/packages/core/src/routes/organization/user/role-relations.ts b/packages/core/src/routes/organization/user/role-relations.ts index 597592c3c0d..7f9d0de3fdc 100644 --- a/packages/core/src/routes/organization/user/role-relations.ts +++ b/packages/core/src/routes/organization/user/role-relations.ts @@ -39,7 +39,7 @@ export default function userRoleRelationRoutes( router.get( pathname, - koaPagination(), + koaPagination({ isOptional: true }), koaGuard({ params: z.object(params), response: OrganizationRoles.guard.array(), @@ -54,10 +54,12 @@ export default function userRoleRelationRoutes( organizationId: id, userId, }, - ctx.pagination + ctx.pagination.disabled ? undefined : ctx.pagination ); - ctx.pagination.totalCount = totalCount; + if (!ctx.pagination.disabled) { + ctx.pagination.totalCount = totalCount; + } ctx.body = entities; return next(); } diff --git a/packages/integration-tests/src/api/organization.ts b/packages/integration-tests/src/api/organization.ts index 87baf87cb72..6bf0c2c6bc9 100644 --- a/packages/integration-tests/src/api/organization.ts +++ b/packages/integration-tests/src/api/organization.ts @@ -36,6 +36,16 @@ export class OrganizationApi extends ApiFactory; } + async addApplicationsRoles( + id: string, + applicationIds: string[], + organizationRoleIds: string[] + ): Promise { + await authedAdminApi.post(`${this.path}/${id}/applications/roles`, { + json: { applicationIds, organizationRoleIds }, + }); + } + async addUsers(id: string, userIds: string[]): Promise { await authedAdminApi.post(`${this.path}/${id}/users`, { json: { userIds } }); } @@ -68,9 +78,9 @@ export class OrganizationApi extends ApiFactory { + async getUserRoles(id: string, userId: string, query?: Query): Promise { return authedAdminApi - .get(`${this.path}/${id}/users/${userId}/roles`) + .get(`${this.path}/${id}/users/${userId}/roles`, { searchParams: query }) .json(); } @@ -98,9 +108,24 @@ export class OrganizationApi extends ApiFactory { + async getApplicationRoles( + id: string, + applicationId: string, + page?: number, + pageSize?: number + ): Promise { + const search = new URLSearchParams(); + + if (page) { + search.set('page', String(page)); + } + + if (pageSize) { + search.set('page_size', String(pageSize)); + } + return authedAdminApi - .get(`${this.path}/${id}/applications/${applicationId}/roles`) + .get(`${this.path}/${id}/applications/${applicationId}/roles`, { searchParams: search }) .json(); } diff --git a/packages/integration-tests/src/helpers/organization.ts b/packages/integration-tests/src/helpers/organization.ts index c98fdea0858..e324c23a212 100644 --- a/packages/integration-tests/src/helpers/organization.ts +++ b/packages/integration-tests/src/helpers/organization.ts @@ -137,10 +137,12 @@ export class OrganizationApiTest extends OrganizationApi { * when they are deleted by other tests. */ async cleanUp(): Promise { - await Promise.all( + await Promise.all([ // Use `trySafe` to avoid error when organization is deleted by other tests. - this.organizations.map(async (organization) => trySafe(this.delete(organization.id))) - ); + ...this.organizations.map(async (organization) => trySafe(this.delete(organization.id))), + this.roleApi.cleanUp(), + this.scopeApi.cleanUp(), + ]); this.#organizations = []; } } diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts index 17942fa77bd..d01b14f9ff2 100644 --- a/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts +++ b/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts @@ -340,7 +340,7 @@ describe('organization role data hook events', () => { }); afterAll(async () => { - await organizationScopeApi.cleanUp(); + await Promise.all([organizationScopeApi.cleanUp(), roleApi.cleanUp()]); }); it.each(organizationRoleDataHookTestCases)( diff --git a/packages/integration-tests/src/tests/api/interaction/consent/happy-path.test.ts b/packages/integration-tests/src/tests/api/interaction/consent/happy-path.test.ts index fc51bcb6bf9..bb5770c47c1 100644 --- a/packages/integration-tests/src/tests/api/interaction/consent/happy-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/consent/happy-path.test.ts @@ -152,11 +152,9 @@ describe('consent api', () => { }); describe('get consent info with organization resource scopes', () => { - const roleApi = new OrganizationRoleApiTest(); const organizationApi = new OrganizationApiTest(); afterEach(async () => { - await roleApi.cleanUp(); await organizationApi.cleanUp(); }); @@ -167,7 +165,7 @@ describe('consent api', () => { const resource = await createResource(generateResourceName(), generateResourceIndicator()); const scope = await createScope(resource.id, generateScopeName()); const scope2 = await createScope(resource.id, generateScopeName()); - const role = await roleApi.create({ + const role = await organizationApi.roleApi.create({ name: generateRoleName(), resourceScopeIds: [scope.id], }); @@ -219,7 +217,7 @@ describe('consent api', () => { const resource = await createResource(generateResourceName(), generateResourceIndicator()); const scope = await createScope(resource.id, generateScopeName()); - const role = await roleApi.create({ + const role = await organizationApi.roleApi.create({ name: generateRoleName(), resourceScopeIds: [scope.id], }); @@ -397,10 +395,12 @@ describe('consent api', () => { // Scope2 is removed because organization2 is not consented expect(getAccessTokenPayload(accessToken)).toHaveProperty('scope', scope.name); - await roleApi.cleanUp(); - await organizationApi.cleanUp(); - await deleteResource(resource.id); - await deleteUser(user.id); + await Promise.all([ + roleApi.cleanUp(), + organizationApi.cleanUp(), + deleteResource(resource.id), + deleteUser(user.id), + ]); }); }); diff --git a/packages/integration-tests/src/tests/api/organization/organization-application.test.ts b/packages/integration-tests/src/tests/api/organization/organization-application.test.ts index 9d35140593c..b4ae58698b1 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-application.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-application.test.ts @@ -259,5 +259,59 @@ devFeatureTest.describe('organization application APIs', () => { expect.objectContaining({ code: 'entity.db_constraint_violated' }) ); }); + + it('should be able to assign multiple roles to multiple applications', async () => { + const organization = await organizationApi.create({ name: 'test' }); + const roles = await Promise.all( + Array.from({ length: 30 }).map(async () => + organizationApi.roleApi.create({ + name: `test-${generateTestName()}`, + type: RoleType.MachineToMachine, + }) + ) + ); + const applications = await Promise.all( + Array.from({ length: 3 }).map(async () => + createApplication(generateTestName(), ApplicationType.MachineToMachine) + ) + ); + + await organizationApi.applications.add( + organization.id, + applications.map(({ id }) => id) + ); + await organizationApi.addApplicationsRoles( + organization.id, + applications.map(({ id }) => id), + roles.map(({ id }) => id) + ); + const fetchedRoles = await Promise.all( + applications.map(async ({ id }) => organizationApi.getApplicationRoles(organization.id, id)) + ); + + expect(fetchedRoles).toEqual( + Array.from({ length: 3 }).map(() => + expect.arrayContaining(roles.map((role) => expect.objectContaining(role))) + ) + ); + + // Test pagination + const fetchedRoles1 = await organizationApi.getApplicationRoles( + organization.id, + applications[0]!.id, + 1, + 20 + ); + const fetchedRoles2 = await organizationApi.getApplicationRoles( + organization.id, + applications[0]!.id, + 2, + 10 + ); + expect(fetchedRoles1).toHaveLength(20); + expect(fetchedRoles2).toHaveLength(10); + expect(roles).toEqual(expect.arrayContaining(fetchedRoles1)); + expect(roles).toEqual(expect.arrayContaining(fetchedRoles2)); + }); }); }); diff --git a/packages/integration-tests/src/tests/api/organization/organization-user.test.ts b/packages/integration-tests/src/tests/api/organization/organization-user.test.ts index 91801d43a43..6247ff01e7a 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-user.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-user.test.ts @@ -307,6 +307,40 @@ describe('organization user APIs', () => { expect.objectContaining({ code: 'entity.db_constraint_violated' }) ); }); + + it('should be able to get organization roles for a user with or without pagination', async () => { + const organization = await organizationApi.create({ name: 'test' }); + const user = await userApi.create({ username: generateTestName() }); + const roles = await Promise.all( + Array.from({ length: 30 }).map(async () => roleApi.create({ name: generateTestName() })) + ); + + await organizationApi.addUsers(organization.id, [user.id]); + await organizationApi.addUserRoles( + organization.id, + user.id, + roles.map(({ id }) => id) + ); + + const roles1 = await organizationApi.getUserRoles(organization.id, user.id, { + page: 1, + page_size: 20, + }); + const roles2 = await organizationApi.getUserRoles(organization.id, user.id, { + page: 2, + page_size: 10, + }); + + expect(roles1).toHaveLength(20); + expect(roles2).toHaveLength(10); + expect(roles2[0]?.id).toBe(roles1[10]?.id); + expect(roles).toEqual(expect.arrayContaining(roles1)); + expect(roles).toEqual(expect.arrayContaining(roles2)); + + const allRoles = await organizationApi.getUserRoles(organization.id, user.id); + expect(allRoles).toHaveLength(30); + expect(allRoles).toEqual(expect.arrayContaining(roles)); + }); }); describe('organization - user - organization role - organization scopes relation', () => {