From 5c936e09761d274d2b776c584b77afc38a4d8502 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 15 Dec 2023 18:12:52 +0800 Subject: [PATCH] feat(core,schemas): add GET application user consent scopes api (#5103) * feat(core,schemas): add GET application user consent scopes api add GET application user consent scopes api * fix(core): remove useless field declare remove useless field declare --- packages/core/src/libraries/application.ts | 49 ++++++++++++++++- .../application-user-consent-scopes.ts | 17 +++++- ...pplication-user-consent-scope.openapi.json | 29 ++++++++++ .../application-user-consent-scope.ts | 38 +++++++++++++ packages/core/src/utils/RelationQueries.ts | 9 +++- .../src/api/application-user-consent-scope.ts | 6 +++ .../application-user-consent-scope.test.ts | 53 ++++++++++++++++++- packages/schemas/src/types/application.ts | 28 +++++++++- 8 files changed, 223 insertions(+), 6 deletions(-) diff --git a/packages/core/src/libraries/application.ts b/packages/core/src/libraries/application.ts index c6727d02e..fc95febed 100644 --- a/packages/core/src/libraries/application.ts +++ b/packages/core/src/libraries/application.ts @@ -1,9 +1,25 @@ -import type { Scope } from '@logto/schemas'; +import { OrganizationScopes, Scopes, type Scope } from '@logto/schemas'; import RequestError from '#src/errors/RequestError/index.js'; import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; +const groupResourceScopesByResourceId = ( + scopes: readonly Scope[] +): Array<{ + resourceId: string; + scopes: Scope[]; +}> => { + const resourceMap = new Map(); + + for (const scope of scopes) { + const existingScopes = resourceMap.get(scope.resourceId) ?? []; + resourceMap.set(scope.resourceId, [...existingScopes, scope]); + } + + return Array.from(resourceMap, ([resourceId, scopes]) => ({ resourceId, scopes })); +}; + export const createApplicationLibrary = (queries: Queries) => { const { applications: { @@ -16,6 +32,7 @@ export const createApplicationLibrary = (queries: Queries) => { rolesScopes: { findRolesScopesByRoleIds }, organizations: { scopes: organizationScopesQuery }, scopes: { findScopesByIdsAndResourceIndicator, findScopesByIds }, + resources: { findResourceById }, } = queries; const findApplicationScopesForResourceIndicator = async ( @@ -112,10 +129,40 @@ export const createApplicationLibrary = (queries: Queries) => { } }; + // Get application user consent organization scopes + const getApplicationUserConsentOrganizationScopes = async (applicationId: string) => { + const [, scopes] = await userConsentOrganizationScopes.getEntities(OrganizationScopes, { + applicationId, + }); + + return scopes; + }; + + const getApplicationUserConsentResourceScopes = async (applicationId: string) => { + const [, scopes] = await userConsentResourceScopes.getEntities(Scopes, { + applicationId, + }); + + const groupedScopes = groupResourceScopesByResourceId(scopes); + + return Promise.all( + groupedScopes.map(async ({ resourceId, scopes }) => ({ + resource: await findResourceById(resourceId), + scopes, + })) + ); + }; + + const getApplicationUserConsentScopes = async (applicationId: string) => + useConsentUserScopes.findAllByApplicationId(applicationId); + return { validateThirdPartyApplicationById, findApplicationScopesForResourceIndicator, validateApplicationUserConsentScopes, assignApplicationUserConsentScopes, + getApplicationUserConsentOrganizationScopes, + getApplicationUserConsentResourceScopes, + getApplicationUserConsentScopes, }; }; diff --git a/packages/core/src/queries/application-user-consent-scopes.ts b/packages/core/src/queries/application-user-consent-scopes.ts index 995c01c6a..3da1d383e 100644 --- a/packages/core/src/queries/application-user-consent-scopes.ts +++ b/packages/core/src/queries/application-user-consent-scopes.ts @@ -1,3 +1,4 @@ +import { type UserScope } from '@logto/core-kit'; import { ApplicationUserConsentOrganizationScopes, ApplicationUserConsentResourceScopes, @@ -6,7 +7,8 @@ import { OrganizationScopes, Scopes, } from '@logto/schemas'; -import { type CommonQueryMethods } from 'slonik'; +import { convertToIdentifiers } from '@logto/shared'; +import { sql, type CommonQueryMethods } from 'slonik'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { TwoRelationsQueries } from '#src/utils/RelationQueries.js'; @@ -34,7 +36,20 @@ export const createApplicationUserConsentUserScopeQueries = (pool: CommonQueryMe onConflict: { ignore: true }, }); + const findAllByApplicationId = async (applicationId: string) => { + const { table, fields } = convertToIdentifiers(ApplicationUserConsentUserScopes); + + const scopes = await pool.any<{ userScope: UserScope }>(sql` + select ${fields.userScope} + from ${table} + where ${fields.applicationId} = ${applicationId} + `); + + return scopes.map(({ userScope }) => userScope); + }; + return { insert, + findAllByApplicationId, }; }; diff --git a/packages/core/src/routes/applications/application-user-consent-scope.openapi.json b/packages/core/src/routes/applications/application-user-consent-scope.openapi.json index 729d63cb5..3aa2d1429 100644 --- a/packages/core/src/routes/applications/application-user-consent-scope.openapi.json +++ b/packages/core/src/routes/applications/application-user-consent-scope.openapi.json @@ -34,6 +34,35 @@ "description": "Any of the given organization scope, resource scope or user scope is not found" } } + }, + "get": { + "summary": "List all the user consent scopes of an application.", + "description": "List all the user consent scopes of an application by application id", + "responses": { + "200": { + "description": "All the user consent scopes of the application are listed successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "organizationScopes": { + "description": "A list of organization scope details assigned to the application." + }, + "resourceScopes": { + "description": "A list of resource scope details grouped by resource id assigned to the application." + }, + "userScopes": { + "description": "A list of user scope enum value assigned to the application." + } + } + } + } + } + }, + "404": { + "description": "The application is not found" + } + } } } } diff --git a/packages/core/src/routes/applications/application-user-consent-scope.ts b/packages/core/src/routes/applications/application-user-consent-scope.ts index 5beed2739..19d686d45 100644 --- a/packages/core/src/routes/applications/application-user-consent-scope.ts +++ b/packages/core/src/routes/applications/application-user-consent-scope.ts @@ -1,4 +1,5 @@ import { UserScope } from '@logto/core-kit'; +import { applicationUserConsentScopesResponseGuard } from '@logto/schemas'; import { object, string, nativeEnum } from 'zod'; import koaGuard from '#src/middleware/koa-guard.js'; @@ -9,11 +10,17 @@ export default function applicationUserConsentScopeRoutes { + const { applicationId } = ctx.guard.params; + + await findApplicationById(applicationId); + + // Note: The following queries will return full data schema, we rely on the response guard to filter out the fields we don't need. + const [organizationScopes, resourceScopes, userScopes] = await Promise.all([ + getApplicationUserConsentOrganizationScopes(applicationId), + getApplicationUserConsentResourceScopes(applicationId), + getApplicationUserConsentScopes(applicationId), + ]); + + ctx.body = { + organizationScopes, + resourceScopes, + userScopes, + }; + + return next(); + } + ); } diff --git a/packages/core/src/utils/RelationQueries.ts b/packages/core/src/utils/RelationQueries.ts index a254d1df5..7273f6c18 100644 --- a/packages/core/src/utils/RelationQueries.ts +++ b/packages/core/src/utils/RelationQueries.ts @@ -19,7 +19,14 @@ type TableInfo< guard: z.ZodType; }; -type InferSchema = T extends TableInfo ? Schema : never; +type InferSchema = T extends TableInfo< + infer _, + infer _, + Extract, + infer Schema +> + ? Schema + : never; type CamelCaseIdObject = KeysToCamelCase<{ [Key in `${T}_id`]: string; diff --git a/packages/integration-tests/src/api/application-user-consent-scope.ts b/packages/integration-tests/src/api/application-user-consent-scope.ts index b153da186..e59f8456b 100644 --- a/packages/integration-tests/src/api/application-user-consent-scope.ts +++ b/packages/integration-tests/src/api/application-user-consent-scope.ts @@ -1,4 +1,5 @@ import { type UserScope } from '@logto/core-kit'; +import { type ApplicationUserConsentScopesResponse } from '@logto/schemas'; import { authedAdminApi } from './api.js'; @@ -10,3 +11,8 @@ export const assignUserConsentScopes = async ( userScopes?: UserScope[]; } ) => authedAdminApi.post(`applications/${applicationId}/user-consent-scopes`, { json: payload }); + +export const getUserConsentScopes = async (applicationId: string) => + authedAdminApi + .get(`applications/${applicationId}/user-consent-scopes`) + .json(); diff --git a/packages/integration-tests/src/tests/api/application-user-consent-scope.test.ts b/packages/integration-tests/src/tests/api/application-user-consent-scope.test.ts index 39a4524cc..6dd6086bb 100644 --- a/packages/integration-tests/src/tests/api/application-user-consent-scope.test.ts +++ b/packages/integration-tests/src/tests/api/application-user-consent-scope.test.ts @@ -1,7 +1,10 @@ import { UserScope } from '@logto/core-kit'; import { ApplicationType } from '@logto/schemas'; -import { assignUserConsentScopes } from '#src/api/application-user-consent-scope.js'; +import { + assignUserConsentScopes, + getUserConsentScopes, +} from '#src/api/application-user-consent-scope.js'; import { createApplication, deleteApplication } from '#src/api/application.js'; import { OrganizationScopeApi } from '#src/api/organization-scope.js'; import { createResource, deleteResource } from '#src/api/resource.js'; @@ -122,4 +125,52 @@ describe('assign user consent scopes to application', () => { }) ).resolves.not.toThrow(); }); + + it('should return 404 when trying to get consent scopes from non-existing application', async () => { + await expectRejects(getUserConsentScopes('non-existing-application'), { + code: 'entity.not_exists_with_id', + statusCode: 404, + }); + }); + + it('should return consent scopes successfully', async () => { + // This test depends on the previous success assignment test + const result = await getUserConsentScopes(applicationIds.get('thirdPartyApp')!); + + expect(result.organizationScopes.length).toBe(organizationScopes.size); + + for (const organizationScopeId of organizationScopes.values()) { + expect(result.organizationScopes.some(({ id }) => id === organizationScopeId)).toBeTruthy(); + } + + expect(result.resourceScopes.length).toBe(1); + expect(result.resourceScopes[0]!.resource.id).toBe(Array.from(resourceIds)[0]); + expect(result.resourceScopes[0]!.scopes.length).toBe(resourceScopes.size); + + for (const resourceScopeId of resourceScopes.values()) { + expect( + result.resourceScopes[0]!.scopes.some(({ id }) => id === resourceScopeId) + ).toBeTruthy(); + } + + expect(result.userScopes.length).toBe(3); + + for (const userScope of [UserScope.Profile, UserScope.Email, UserScope.OrganizationRoles]) { + expect(result.userScopes.includes(userScope)).toBeTruthy(); + } + }); + + it('should return empty consent scopes when no scopes assigned', async () => { + const newApp = await createApplication('new-app', ApplicationType.Traditional, { + isThirdParty: true, + }); + + const result = await getUserConsentScopes(newApp.id); + + expect(result.organizationScopes.length).toBe(0); + expect(result.resourceScopes.length).toBe(0); + expect(result.userScopes.length).toBe(0); + + await deleteApplication(newApp.id); + }); }); diff --git a/packages/schemas/src/types/application.ts b/packages/schemas/src/types/application.ts index 1e2f795cc..d0bf23bd0 100644 --- a/packages/schemas/src/types/application.ts +++ b/packages/schemas/src/types/application.ts @@ -1,6 +1,13 @@ -import { type z } from 'zod'; +import { UserScope } from '@logto/core-kit'; +import { z } from 'zod'; -import { Applications, type Application } from '../db-entries/index.js'; +import { + Applications, + type Application, + OrganizationScopes, + Resources, + Scopes, +} from '../db-entries/index.js'; export type ApplicationResponse = Application & { isAdmin: boolean }; @@ -31,3 +38,20 @@ export const applicationPatchGuard = applicationCreateGuard.partial().omit({ type: true, isThirdParty: true, }); + +export const applicationUserConsentScopesResponseGuard = z.object({ + organizationScopes: z.array( + OrganizationScopes.guard.pick({ id: true, name: true, description: true }) + ), + resourceScopes: z.array( + z.object({ + resource: Resources.guard.pick({ id: true, name: true, indicator: true }), + scopes: z.array(Scopes.guard.pick({ id: true, name: true, description: true })), + }) + ), + userScopes: z.array(z.nativeEnum(UserScope)), +}); + +export type ApplicationUserConsentScopesResponse = z.infer< + typeof applicationUserConsentScopesResponseGuard +>;