0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): filter scopes assigned to organization roles

This commit is contained in:
wangsijie 2024-04-30 14:13:00 +08:00
parent 726a65dd8e
commit 3c2cc3c6ef
No known key found for this signature in database
GPG key ID: C72642FE24F7D42B
7 changed files with 111 additions and 14 deletions

View file

@ -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.

View file

@ -45,8 +45,8 @@ class OrganizationRolesQueries extends SchemaQueries<
} }
override async findAll( override async findAll(
limit: number, limit?: number,
offset: number, offset?: number,
search?: SearchOptions<OrganizationRoleKeys> search?: SearchOptions<OrganizationRoleKeys>
): Promise<[totalNumber: number, rows: Readonly<OrganizationRoleWithScopes[]>]> { ): Promise<[totalNumber: number, rows: Readonly<OrganizationRoleWithScopes[]>]> {
return Promise.all([ return Promise.all([

View file

@ -12,7 +12,7 @@
{ {
"in": "query", "in": "query",
"name": "includeScopes", "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": { "responses": {

View file

@ -3,6 +3,7 @@ import { generateStandardId } from '@logto/shared';
import { yes } from '@silverhand/essentials'; import { yes } from '@silverhand/essentials';
import { boolean, object, string } from 'zod'; import { boolean, object, string } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js'; import koaPagination from '#src/middleware/koa-pagination.js';
@ -33,6 +34,7 @@ export default function resourceRoutes<T extends ManagementApiRouter>(
deleteResourceById, deleteResourceById,
}, },
scopes: scopeQueries, scopes: scopeQueries,
organizations: { roles: organizationRolesQueries },
} = queries; } = queries;
router.get( router.get(
@ -48,13 +50,22 @@ export default function resourceRoutes<T extends ManagementApiRouter>(
async (ctx, next) => { async (ctx, next) => {
const { limit, offset, disabled } = ctx.pagination; const { limit, offset, disabled } = ctx.pagination;
const { const {
query: { includeScopes }, query: { includeScopes: includeScopesQuery },
} = ctx.guard; } = ctx.guard;
const includeOnlyOrganizationScopes = includeScopesQuery === 'assignedByOrganizations';
const includeScopes = includeOnlyOrganizationScopes || yes(includeScopesQuery);
const [, organizationRoles] =
includeOnlyOrganizationScopes && EnvSet.values.isDevFeaturesEnabled
? await organizationRolesQueries.findAll()
: [undefined, undefined];
if (disabled) { if (disabled) {
const resources = await findAllResources(); const resources = await findAllResources();
ctx.body = yes(includeScopes)
? await attachScopesToResources(resources, scopeQueries) ctx.body = includeScopes
? await attachScopesToResources(resources, scopeQueries, organizationRoles)
: resources; : resources;
return next(); return next();
@ -66,8 +77,8 @@ export default function resourceRoutes<T extends ManagementApiRouter>(
]); ]);
ctx.pagination.totalCount = count; ctx.pagination.totalCount = count;
ctx.body = yes(includeScopes) ctx.body = includeScopes
? await attachScopesToResources(resources, scopeQueries) ? await attachScopesToResources(resources, scopeQueries, organizationRoles)
: resources; : resources;
return next(); return next();

View file

@ -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'; 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 ( export const attachScopesToResources = async (
resources: readonly Resource[], resources: readonly Resource[],
scopeQueries: Queries['scopes'] scopeQueries: Queries['scopes'],
organizationRoles?: readonly OrganizationRoleWithScopes[]
): Promise<ResourceResponse[]> => { ): Promise<ResourceResponse[]> => {
const { findScopesByResourceIds } = scopeQueries; const { findScopesByResourceIds } = scopeQueries;
const resourceIds = resources.map(({ id }) => id); const resourceIds = resources.map(({ id }) => id);
@ -12,6 +25,14 @@ export const attachScopesToResources = async (
return resources.map((resource) => ({ return resources.map((resource) => ({
...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)
)
),
})); }));
}; };

View file

@ -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 { type Options } from 'ky';
import { generateResourceIndicator, generateResourceName } from '#src/utils.js'; import { generateResourceIndicator, generateResourceName } from '#src/utils.js';
@ -15,7 +16,14 @@ export const createResource = async (name?: string, indicator?: string) =>
}) })
.json<Resource>(); .json<Resource>();
export const getResources = async () => authedAdminApi.get('resources').json<Resource[]>(); 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) => export const getResource = async (resourceId: string, options?: Options) =>
authedAdminApi.get(`resources/${resourceId}`, options).json<Resource>(); authedAdminApi.get(`resources/${resourceId}`, options).json<Resource>();

View file

@ -9,8 +9,15 @@ import {
deleteResource, deleteResource,
setDefaultResource, setDefaultResource,
} from '#src/api/index.js'; } from '#src/api/index.js';
import { createScope } from '#src/api/scope.js';
import { expectRejects } from '#src/helpers/index.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', () => { describe('admin console api resources', () => {
it('should get management api resource details successfully', async () => { 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); 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 () => { it('should update api resource details successfully', async () => {
const resource = await createResource(); const resource = await createResource();