diff --git a/.changeset/afraid-buckets-accept.md b/.changeset/afraid-buckets-accept.md new file mode 100644 index 000000000..863ec4388 --- /dev/null +++ b/.changeset/afraid-buckets-accept.md @@ -0,0 +1,7 @@ +--- +"@logto/core": minor +--- + +filter scopes assigned to organization roles + +For api `GET /resources`, extend the query parameter `includeScopes` with new value `assignedByOrganizations` to filter scopes assigned by organization roles only. diff --git a/packages/core/src/queries/organization/index.ts b/packages/core/src/queries/organization/index.ts index 573c82411..5d9cac3e7 100644 --- a/packages/core/src/queries/organization/index.ts +++ b/packages/core/src/queries/organization/index.ts @@ -45,8 +45,8 @@ class OrganizationRolesQueries extends SchemaQueries< } override async findAll( - limit: number, - offset: number, + limit?: number, + offset?: number, search?: SearchOptions ): Promise<[totalNumber: number, rows: Readonly]> { return Promise.all([ diff --git a/packages/core/src/routes/resource.openapi.json b/packages/core/src/routes/resource.openapi.json index a07aa28fb..1532aacd2 100644 --- a/packages/core/src/routes/resource.openapi.json +++ b/packages/core/src/routes/resource.openapi.json @@ -12,7 +12,7 @@ { "in": "query", "name": "includeScopes", - "description": "If it's provided with a truthy value (`true`, `1`, `yes`), the scopes of each resource will be included in the response." + "description": "If it's provided with a truthy value (`true`, `1`, `yes`) or `assignedByOrganizations`, the scopes of each resource will be included in the response. And if it is set to `assignedByOrganizations`, only the scopes assigned by organizations will be included." } ], "responses": { diff --git a/packages/core/src/routes/resource.ts b/packages/core/src/routes/resource.ts index 6d2bd88ca..351718214 100644 --- a/packages/core/src/routes/resource.ts +++ b/packages/core/src/routes/resource.ts @@ -3,6 +3,7 @@ import { generateStandardId } from '@logto/shared'; import { yes } from '@silverhand/essentials'; import { boolean, object, string } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; @@ -33,6 +34,7 @@ export default function resourceRoutes( deleteResourceById, }, scopes: scopeQueries, + organizations: { roles: organizationRolesQueries }, } = queries; router.get( @@ -48,13 +50,22 @@ export default function resourceRoutes( async (ctx, next) => { const { limit, offset, disabled } = ctx.pagination; const { - query: { includeScopes }, + query: { includeScopes: includeScopesQuery }, } = ctx.guard; + const includeOnlyOrganizationScopes = includeScopesQuery === 'assignedByOrganizations'; + const includeScopes = includeOnlyOrganizationScopes || yes(includeScopesQuery); + + const [, organizationRoles] = + includeOnlyOrganizationScopes && EnvSet.values.isDevFeaturesEnabled + ? await organizationRolesQueries.findAll() + : [undefined, undefined]; + if (disabled) { const resources = await findAllResources(); - ctx.body = yes(includeScopes) - ? await attachScopesToResources(resources, scopeQueries) + + ctx.body = includeScopes + ? await attachScopesToResources(resources, scopeQueries, organizationRoles) : resources; return next(); @@ -66,8 +77,8 @@ export default function resourceRoutes( ]); ctx.pagination.totalCount = count; - ctx.body = yes(includeScopes) - ? await attachScopesToResources(resources, scopeQueries) + ctx.body = includeScopes + ? await attachScopesToResources(resources, scopeQueries, organizationRoles) : resources; return next(); diff --git a/packages/core/src/utils/resource.ts b/packages/core/src/utils/resource.ts index 25dc3e86d..04ab645e7 100644 --- a/packages/core/src/utils/resource.ts +++ b/packages/core/src/utils/resource.ts @@ -1,10 +1,23 @@ -import { type Resource, type ResourceResponse } from '@logto/schemas'; +import { + type OrganizationRoleWithScopes, + type Resource, + type ResourceResponse, +} from '@logto/schemas'; import type Queries from '#src/tenants/Queries.js'; +/** + * Query and attach scopes to resources. + * + * @param resources list of resources + * @param scopeQueries queries + * @param organizationRoles when provided, only the scopes that are assigned to the organization roles + * @returns + */ export const attachScopesToResources = async ( resources: readonly Resource[], - scopeQueries: Queries['scopes'] + scopeQueries: Queries['scopes'], + organizationRoles?: readonly OrganizationRoleWithScopes[] ): Promise => { const { findScopesByResourceIds } = scopeQueries; const resourceIds = resources.map(({ id }) => id); @@ -12,6 +25,14 @@ export const attachScopesToResources = async ( return resources.map((resource) => ({ ...resource, - scopes: scopes.filter(({ resourceId }) => resourceId === resource.id), + scopes: scopes + .filter(({ resourceId }) => resourceId === resource.id) + .filter( + ({ id }) => + !organizationRoles || + organizationRoles.some(({ resourceScopes }) => + resourceScopes.some((scope) => scope.id === id) + ) + ), })); }; diff --git a/packages/integration-tests/src/api/resource.ts b/packages/integration-tests/src/api/resource.ts index 66bdaa4f0..1630fe56b 100644 --- a/packages/integration-tests/src/api/resource.ts +++ b/packages/integration-tests/src/api/resource.ts @@ -1,4 +1,5 @@ -import type { Resource, CreateResource } from '@logto/schemas'; +import type { Resource, CreateResource, Scope } from '@logto/schemas'; +import { conditionalString } from '@silverhand/essentials'; import { type Options } from 'ky'; import { generateResourceIndicator, generateResourceName } from '#src/utils.js'; @@ -15,7 +16,14 @@ export const createResource = async (name?: string, indicator?: string) => }) .json(); -export const getResources = async () => authedAdminApi.get('resources').json(); +export const getResources = async (query?: string) => + authedAdminApi.get(`resources${conditionalString(query && `?${query}`)}`).json< + Array< + Resource & { + scopes?: Scope[]; + } + > + >(); export const getResource = async (resourceId: string, options?: Options) => authedAdminApi.get(`resources/${resourceId}`, options).json(); diff --git a/packages/integration-tests/src/tests/api/resource.test.ts b/packages/integration-tests/src/tests/api/resource.test.ts index 87503a8a6..c0618404b 100644 --- a/packages/integration-tests/src/tests/api/resource.test.ts +++ b/packages/integration-tests/src/tests/api/resource.test.ts @@ -9,8 +9,15 @@ import { deleteResource, setDefaultResource, } from '#src/api/index.js'; +import { createScope } from '#src/api/scope.js'; import { expectRejects } from '#src/helpers/index.js'; -import { generateResourceIndicator, generateResourceName } from '#src/utils.js'; +import { OrganizationRoleApiTest } from '#src/helpers/organization.js'; +import { + generateResourceIndicator, + generateResourceName, + generateRoleName, + generateScopeName, +} from '#src/utils.js'; describe('admin console api resources', () => { it('should get management api resource details successfully', async () => { @@ -67,6 +74,49 @@ describe('admin console api resources', () => { expect(resources.findIndex(({ name }) => name === resourceName)).not.toBe(-1); }); + it('should get resource list with scopes successfully', async () => { + const resourceName = generateResourceName(); + const resourceIndicator = generateResourceIndicator(); + + const resource = await createResource(resourceName, resourceIndicator); + const scopeName = generateScopeName(); + await createScope(resource.id, scopeName); + + // Get all resources + const resources = await getResources('includeScopes=true'); + + expect(resources.find(({ name }) => name === resourceName)).toHaveProperty('scopes'); + }); + + it('should get resource list with organization assigned scopes successfully', async () => { + const roleApi = new OrganizationRoleApiTest(); + + const resourceName = generateResourceName(); + const resourceIndicator = generateResourceIndicator(); + + const resource = await createResource(resourceName, resourceIndicator); + const scopeName = generateScopeName(); + const scopeName2 = generateScopeName(); + const scope = await createScope(resource.id, scopeName); + await createScope(resource.id, scopeName2); + + await roleApi.create({ + name: generateRoleName(), + description: 'test description.', + resourceScopeIds: [scope.id], + }); + + const resourcesWithAllScopes = await getResources('includeScopes=true'); + expect(resourcesWithAllScopes.find(({ name }) => name === resourceName)?.scopes).toHaveLength( + 2 + ); + + const resources = await getResources('includeScopes=assignedByOrganizations'); + expect(resources.find(({ name }) => name === resourceName)?.scopes).toHaveLength(1); + + await roleApi.cleanUp(); + }); + it('should update api resource details successfully', async () => { const resource = await createResource();