diff --git a/.changeset/real-camels-cheat.md b/.changeset/real-camels-cheat.md new file mode 100644 index 00000000000..28ea73331bb --- /dev/null +++ b/.changeset/real-camels-cheat.md @@ -0,0 +1,7 @@ +--- +"@logto/core": patch +--- + +Provide management API to fetch user organization scopes based on user organization roles + +- GET `organizations/:id/users/:userId/scopes` diff --git a/packages/core/package.json b/packages/core/package.json index 5178fe48d42..4db9f56412c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -148,6 +148,14 @@ "import/no-unused-modules": "off" } }, + { + "files": [ + "*.openapi.json" + ], + "rules": { + "max-lines": "off" + } + }, { "files": [ "src/include.d/oidc-provider/**/*" diff --git a/packages/core/src/oidc/grants/refresh-token.test.ts b/packages/core/src/oidc/grants/refresh-token.test.ts index cda582c1a82..b4b6b09b908 100644 --- a/packages/core/src/oidc/grants/refresh-token.test.ts +++ b/packages/core/src/oidc/grants/refresh-token.test.ts @@ -307,9 +307,9 @@ describe('organization token grant', () => { Sinon.stub(tenant.queries.organizations.relations.users, 'exists').resolves(true); Sinon.stub(tenant.queries.applications, 'findApplicationById').resolves(mockApplication); Sinon.stub(tenant.queries.organizations.relations.rolesUsers, 'getUserScopes').resolves([ - { id: 'foo', name: 'foo' }, - { id: 'bar', name: 'bar' }, - { id: 'baz', name: 'baz' }, + { tenantId: 'default', id: 'foo', name: 'foo', description: 'foo' }, + { tenantId: 'default', id: 'bar', name: 'bar', description: 'bar' }, + { tenantId: 'default', id: 'baz', name: 'baz', description: 'baz' }, ]); const entityStub = Sinon.stub(ctx.oidc, 'entity'); diff --git a/packages/core/src/queries/organization/relations.ts b/packages/core/src/queries/organization/relations.ts index 9d84c2a052a..98070ecae8b 100644 --- a/packages/core/src/queries/organization/relations.ts +++ b/packages/core/src/queries/organization/relations.ts @@ -9,7 +9,7 @@ import { type OrganizationWithRoles, type UserWithOrganizationRoles, type FeaturedUser, - type OrganizationScopeEntity, + type OrganizationScope, } from '@logto/schemas'; import { sql, type CommonQueryMethods } from '@silverhand/slonik'; @@ -178,14 +178,14 @@ export class RoleUserRelationQueries extends RelationQueries< async getUserScopes( organizationId: string, userId: string - ): Promise { + ): Promise { const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true); const roleScopeRelations = convertToIdentifiers(OrganizationRoleScopeRelations, true); const scopes = convertToIdentifiers(OrganizationScopes, true); - return this.pool.any(sql` + return this.pool.any(sql` select distinct on (${scopes.fields.id}) - ${scopes.fields.id}, ${scopes.fields.name} + ${sql.join(Object.values(scopes.fields), sql`, `)} from ${this.table} join ${roleScopeRelations.table} on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId} diff --git a/packages/core/src/routes/organization/index.openapi.json b/packages/core/src/routes/organization/index.openapi.json index 1720577408d..db076cbfac7 100644 --- a/packages/core/src/routes/organization/index.openapi.json +++ b/packages/core/src/routes/organization/index.openapi.json @@ -288,6 +288,20 @@ } } } + }, + "/api/organizations/{id}/users/{userId}/scopes": { + "get": { + "summary": "Get scopes for a user in an organization tailored by the organization roles", + "description": "Get scopes assigned to a user in the specified organization tailored by the organization roles. The scopes are derived from the organization roles assigned to the user.", + "responses": { + "200": { + "description": "A list of scopes assigned to the user." + }, + "422": { + "description": "The user is not a member of the organization." + } + } + } } } } diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index 39d69fc855a..54fb04faf23 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -4,6 +4,7 @@ import { Organizations, featuredUserGuard, userWithOrganizationRolesGuard, + OrganizationScopes, } from '@logto/schemas'; import { yes } from '@silverhand/essentials'; import { z } from 'zod'; @@ -235,6 +236,23 @@ export default function organizationRoutes(...args: Rout } ); + router.get( + '/:id/users/:userId/scopes', + koaGuard({ + params: z.object(params), + response: z.array(OrganizationScopes.guard), + status: [200, 422], + }), + async (ctx, next) => { + const { id, userId } = ctx.guard.params; + + const scopes = await organizations.relations.rolesUsers.getUserScopes(id, userId); + + ctx.body = scopes; + return next(); + } + ); + // MARK: Mount sub-routes organizationRoleRoutes(...args); organizationScopeRoutes(...args); diff --git a/packages/integration-tests/src/api/organization.ts b/packages/integration-tests/src/api/organization.ts index 776ffbeb6ee..154472b19eb 100644 --- a/packages/integration-tests/src/api/organization.ts +++ b/packages/integration-tests/src/api/organization.ts @@ -4,6 +4,7 @@ import { type OrganizationWithRoles, type UserWithOrganizationRoles, type OrganizationWithFeatured, + type OrganizationScope, } from '@logto/schemas'; import { authedAdminApi } from './api.js'; @@ -67,4 +68,10 @@ export class OrganizationApi extends ApiFactory< async getUserOrganizations(userId: string): Promise { return authedAdminApi.get(`users/${userId}/organizations`).json(); } + + async getUserOrganizationScopes(id: string, userId: string): Promise { + return authedAdminApi + .get(`${this.path}/${id}/users/${userId}/scopes`) + .json(); + } } 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 72292542f9b..69872d917be 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 @@ -247,4 +247,41 @@ describe('organization user APIs', () => { expect(response instanceof HTTPError && response.response.status).toBe(422); // Require membership }); }); + + describe('organization - user - organization role - organization scopes relation', () => { + it('should be able to get organization scopes for a user with a specific role', async () => { + const organizationApi = new OrganizationApiTest(); + const { roleApi, scopeApi } = organizationApi; + const userApi = new UserApiTest(); + + const organization = await organizationApi.create({ name: 'test' }); + const user = await userApi.create({ username: generateTestName() }); + await organizationApi.addUsers(organization.id, [user.id]); + + const [role1, role2] = await Promise.all([ + roleApi.create({ name: generateTestName() }), + roleApi.create({ name: generateTestName() }), + ]); + const [scope1, scope2] = await Promise.all([ + scopeApi.create({ name: generateTestName() }), + scopeApi.create({ name: generateTestName() }), + ]); + + // Assign scope1 and scope2 to role1 + await roleApi.addScopes(role1.id, [scope1.id, scope2.id]); + // Assign scope1 to role2 + await roleApi.addScopes(role2.id, [scope1.id]); + + // Assign role1 to user + await organizationApi.addUserRoles(organization.id, user.id, [role1.id]); + const scopes = await organizationApi.getUserOrganizationScopes(organization.id, user.id); + expect(scopes.map(({ name }) => name)).toMatchObject([scope1.name, scope2.name]); + + // Remove role1 and assign role2 to user + await organizationApi.deleteUserRole(organization.id, user.id, role1.id); + await organizationApi.addUserRoles(organization.id, user.id, [role2.id]); + const newScopes = await organizationApi.getUserOrganizationScopes(organization.id, user.id); + expect(newScopes.map(({ name }) => name)).toEqual([scope1.name]); + }); + }); });