diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index 660351fc5..12d8a48ce 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -1,6 +1,11 @@ import type { Application, CreateApplication } from '@logto/schemas'; -import { ApplicationType, Applications, SearchJointMode } from '@logto/schemas'; -import { pick } from '@silverhand/essentials'; +import { + ApplicationType, + Applications, + OrganizationApplicationRelations, + SearchJointMode, +} from '@logto/schemas'; +import { condArray, pick } from '@silverhand/essentials'; import type { CommonQueryMethods, SqlSqlToken } from '@silverhand/slonik'; import { sql } from '@silverhand/slonik'; @@ -23,6 +28,7 @@ import { } from './application-user-consent-scopes.js'; const { table, fields } = convertToIdentifiers(Applications); +const organizationApplicationRelations = convertToIdentifiers(OrganizationApplicationRelations); /** * The schema field keys that can be used for searching apps. For the actual field names, @@ -34,13 +40,13 @@ export const applicationSearchKeys = Object.freeze(['id', 'name', 'description'] /** * The actual database field names that can be used for searching apps. For the schema field - * keys, see {@link userSearchKeys}. + * keys, see {@link applicationSearchKeys}. */ const applicationSearchFields = Object.freeze( Object.values(pick(Applications.fields, ...applicationSearchKeys)) ); -const buildApplicationConditions = (search: Search) => { +const buildApplicationSearchConditions = (search: Search) => { return conditionalSql( search.matches.length > 0, () => @@ -53,40 +59,60 @@ const buildApplicationConditions = (search: Search) => { }; const buildConditionArray = (conditions: SqlSqlToken[]) => { - const filteredConditions = conditions.filter((condition) => condition.sql !== ''); + const filteredConditions = conditions.filter((condition) => condition.sql.trim() !== ''); return conditionalArraySql( filteredConditions, (filteredConditions) => sql`where ${sql.join(filteredConditions, sql` and `)}` ); }; +type ApplicationConditions = { + /** The search config object, can apply to fields in {@link applicationSearchFields}. */ + search: Search; + /** Exclude applications with these ids. */ + excludeApplicationIds?: string[]; + /** Exclude applications associated with an organization. */ + excludeOrganizationId?: string; + /** Filter applications by types, if not provided, all types will be included. */ + types?: ApplicationType[]; + /** Filter applications by whether it is a third party application. */ + isThirdParty?: boolean; +}; + +const buildApplicationConditions = ({ + search, + excludeApplicationIds, + excludeOrganizationId, + types, + isThirdParty, +}: ApplicationConditions) => { + return buildConditionArray( + condArray( + excludeApplicationIds?.length && + sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})`, + excludeOrganizationId && + sql` + not exists ( + select 1 from ${organizationApplicationRelations.table} + where ${organizationApplicationRelations.fields.applicationId} = ${fields.id} + and ${organizationApplicationRelations.fields.organizationId}=${excludeOrganizationId} + )`, + types?.length && sql`${fields.type} in (${sql.join(types, sql`, `)})`, + typeof isThirdParty === 'boolean' && sql`${fields.isThirdParty} = ${isThirdParty}`, + buildApplicationSearchConditions(search) + ) + ); +}; + export const createApplicationQueries = (pool: CommonQueryMethods) => { /** * Get the number of applications that match the search conditions, conditions are joined in `and` mode. - * - * @param search The search config object, can apply to `id`, `name` and `description` field for application. - * @param excludeApplicationIds Exclude applications with these ids. - * @param isThirdParty Optional boolean, filter applications by whether it is a third party application. - * @param types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included. - * @returns A Promise that resolves the number of applications that match the search conditions. */ - const countApplications = async ( - search: Search, - excludeApplicationIds: string[], - isThirdParty?: boolean, - types?: ApplicationType[] - ) => { + const countApplications = async (conditions: ApplicationConditions) => { const { count } = await pool.one<{ count: string }>(sql` select count(*) from ${table} - ${buildConditionArray([ - excludeApplicationIds.length > 0 - ? sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})` - : sql``, - types && types.length > 0 ? sql`${fields.type} in (${sql.join(types, sql`, `)})` : sql``, - typeof isThirdParty === 'boolean' ? sql`${fields.isThirdParty} = ${isThirdParty}` : sql``, - buildApplicationConditions(search), - ])} + ${buildApplicationConditions(conditions)} `); return { count: Number(count) }; @@ -94,47 +120,17 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { /** * Get the list of applications that match the search conditions, conditions are joined in `and` mode. - * - * @param conditions The conditions to filter applications. - * @param conditions.search The search config object, can apply to `id`, `name` and `description` field for application - * @param conditions.excludeApplicationIds Exclude applications with these ids. - * @param conditions.types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included. - * @param conditions.isThirdParty Optional boolean, filter applications by whether it is a third party application. - * @param conditions.pagination Optional pagination config object. - * @param conditions.pagination.limit The number of applications to return. - * @param conditions.pagination.offset The offset of applications to return. - * @returns A Promise that resolves the list of applications that match the search conditions. */ - const findApplications = async ({ - search, - excludeApplicationIds, - types, - isThirdParty, - pagination, - }: { - search: Search; - excludeApplicationIds: string[]; - types?: ApplicationType[]; - isThirdParty?: boolean; - pagination?: { - limit: number; - offset: number; - }; - }) => + const findApplications = async ( + conditions: ApplicationConditions, + pagination?: { limit: number; offset: number } + ) => pool.any(sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} - ${buildConditionArray([ - excludeApplicationIds.length > 0 - ? sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})` - : sql``, - types && types.length > 0 ? sql`${fields.type} in (${sql.join(types, sql`, `)})` : sql``, - typeof isThirdParty === 'boolean' ? sql`${fields.isThirdParty} = ${isThirdParty}` : sql``, - buildApplicationConditions(search), - ])} + ${buildApplicationConditions(conditions)} order by ${fields.createdAt} desc - ${conditionalSql(pagination?.limit, (value) => sql`limit ${value}`)} - ${conditionalSql(pagination?.offset, (value) => sql`offset ${value}`)} + ${conditionalSql(pagination, ({ limit, offset }) => sql`limit ${limit} offset ${offset}`)} `); const findTotalNumberOfApplications = async () => getTotalRowCountWithPool(pool)(table); @@ -153,14 +149,13 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { ) => updateApplication({ set, where: { id }, jsonbMode: 'merge' }); const countAllApplications = async () => - countApplications( - { + countApplications({ + search: { matches: [], joint: SearchJointMode.And, // Dummy since there is no match isCaseSensitive: false, // Dummy since there is no match }, - [] - ); + }); const countM2mApplications = async () => { const { count } = await pool.one<{ count: string }>(sql` @@ -193,7 +188,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { ${buildConditionArray([ sql`${fields.type} = ${ApplicationType.MachineToMachine}`, sql`${fields.id} in (${sql.join(applicationIds, sql`, `)})`, - buildApplicationConditions(search), + buildApplicationSearchConditions(search), ])} `); @@ -216,7 +211,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { ${buildConditionArray([ sql`${fields.type} = ${ApplicationType.MachineToMachine}`, sql`${fields.id} in (${sql.join(applicationIds, sql`, `)})`, - buildApplicationConditions(search), + buildApplicationSearchConditions(search), ])} limit ${limit} offset ${offset} diff --git a/packages/core/src/queries/organization/application-relations.ts b/packages/core/src/queries/organization/application-relations.ts index 8d4df5624..bff48db92 100644 --- a/packages/core/src/queries/organization/application-relations.ts +++ b/packages/core/src/queries/organization/application-relations.ts @@ -6,6 +6,7 @@ import { OrganizationRoles, OrganizationRoleApplicationRelations, type ApplicationWithOrganizationRoles, + type OrganizationWithRoles, } from '@logto/schemas'; import { type CommonQueryMethods, sql } from '@silverhand/slonik'; @@ -25,6 +26,45 @@ export class ApplicationRelationQueries extends TwoRelationsQueries< super(pool, OrganizationApplicationRelations.table, Organizations, Applications); } + async getOrganizationsByApplicationId( + applicationId: string, + { limit, offset }: GetEntitiesOptions + ): Promise<[totalCount: number, organizations: readonly OrganizationWithRoles[]]> { + const organizations = convertToIdentifiers(Organizations, true); + const roles = convertToIdentifiers(OrganizationRoles, true); + const { fields } = convertToIdentifiers(OrganizationApplicationRelations, true); + const relations = convertToIdentifiers(OrganizationRoleApplicationRelations, true); + + const [{ count }, entities] = await Promise.all([ + this.pool.one<{ count: string }>(sql` + select count(*) + from ${this.table} + left join ${organizations.table} + on ${fields.organizationId} = ${organizations.fields.id} + where ${fields.applicationId} = ${applicationId} + `), + this.pool.any(sql` + select + ${sql.join(Object.values(organizations.fields), sql`, `)}, + ${aggregateRoles()} + from ${this.table} + left join ${organizations.table} + on ${fields.organizationId} = ${organizations.fields.id} + left join ${relations.table} + on ${relations.fields.organizationId} = ${organizations.fields.id} + and ${relations.fields.applicationId} = ${fields.applicationId} + left join ${roles.table} + on ${relations.fields.organizationRoleId} = ${roles.fields.id} + where ${fields.applicationId} = ${applicationId} + group by ${organizations.fields.id} + limit ${limit} + offset ${offset} + `), + ]); + + return [Number(count), entities]; + } + /** Get the applications of an organization with their organization roles. */ async getApplicationsByOrganizationId( organizationId: string, diff --git a/packages/core/src/routes/admin-user/search.ts b/packages/core/src/routes/admin-user/search.ts index a00894212..0f23168f7 100644 --- a/packages/core/src/routes/admin-user/search.ts +++ b/packages/core/src/routes/admin-user/search.ts @@ -65,7 +65,7 @@ export default function adminUserSearchRoutes( if (excludeRoleId && excludeOrganizationId) { throw new RequestError({ code: 'request.invalid_input', - status: 422, + status: 400, details: 'Parameter `excludeRoleId` and `excludeOrganizationId` cannot be used at the same time.', }); diff --git a/packages/core/src/routes/applications/application-organization.openapi.json b/packages/core/src/routes/applications/application-organization.openapi.json new file mode 100644 index 000000000..9edad9907 --- /dev/null +++ b/packages/core/src/routes/applications/application-organization.openapi.json @@ -0,0 +1,16 @@ +{ + "paths": { + "/api/applications/{id}/organizations": { + "get": { + "tags": ["Dev feature"], + "summary": "Get application organizations", + "description": "Get the list of organizations that an application is associated with.", + "responses": { + "200": { + "description": "An array of organizations that the application is associated with." + } + } + } + } + } +} diff --git a/packages/core/src/routes/applications/application-organization.ts b/packages/core/src/routes/applications/application-organization.ts new file mode 100644 index 000000000..dff7584af --- /dev/null +++ b/packages/core/src/routes/applications/application-organization.ts @@ -0,0 +1,44 @@ +import { organizationWithOrganizationRolesGuard } from '@logto/schemas'; +import { z } from 'zod'; + +import { EnvSet } from '#src/env-set/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; + +import { type ManagementApiRouter, type RouterInitArgs } from '../types.js'; + +export default function applicationOrganizationRoutes( + ...[router, { queries }]: RouterInitArgs +) { + // TODO: Remove + if (!EnvSet.values.isDevFeaturesEnabled) { + return; + } + + router.get( + '/applications/:id/organizations', + koaPagination(), + koaGuard({ + params: z.object({ id: z.string() }), + response: organizationWithOrganizationRolesGuard.array(), + status: [200, 404], + }), + async (ctx, next) => { + const { id } = ctx.guard.params; + + // Ensure that the user exists. + await queries.applications.findApplicationById(id); + + const [count, entities] = + await queries.organizations.relations.apps.getOrganizationsByApplicationId( + id, + ctx.pagination + ); + + ctx.pagination.totalCount = count; + ctx.body = entities; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/applications/application.ts b/packages/core/src/routes/applications/application.ts index c6d9e45e2..15328665e 100644 --- a/packages/core/src/routes/applications/application.ts +++ b/packages/core/src/routes/applications/application.ts @@ -76,6 +76,7 @@ export default function applicationRoutes( .or(applicationTypeGuard.transform((type) => [type])) .optional(), excludeRoleId: string().optional(), + excludeOrganizationId: string().optional(), isThirdParty: z.union([z.literal('true'), z.literal('false')]).optional(), }), response: z.array(Applications.guard), @@ -84,7 +85,21 @@ export default function applicationRoutes( async (ctx, next) => { const { limit, offset, disabled: paginationDisabled } = ctx.pagination; const { searchParams } = ctx.URL; - const { types, excludeRoleId, isThirdParty: isThirdPartyParam } = ctx.guard.query; + const { + types, + excludeRoleId, + excludeOrganizationId, + isThirdParty: isThirdPartyParam, + } = ctx.guard.query; + + if (excludeRoleId && excludeOrganizationId) { + throw new RequestError({ + code: 'request.invalid_input', + status: 400, + details: + 'Parameter `excludeRoleId` and `excludeOrganizationId` cannot be used at the same time.', + }); + } const isThirdParty = parseIsThirdPartQueryParam(isThirdPartyParam); @@ -100,20 +115,35 @@ export default function applicationRoutes( ); if (paginationDisabled) { - ctx.body = await findApplications({ search, excludeApplicationIds, types, isThirdParty }); + ctx.body = await findApplications({ + search, + excludeApplicationIds, + excludeOrganizationId, + types, + isThirdParty, + }); return next(); } const [{ count }, applications] = await Promise.all([ - countApplications(search, excludeApplicationIds, isThirdParty, types), - findApplications({ + countApplications({ search, excludeApplicationIds, - types, + excludeOrganizationId, isThirdParty, - pagination: { limit, offset }, + types, }), + findApplications( + { + search, + excludeApplicationIds, + excludeOrganizationId, + types, + isThirdParty, + }, + { limit, offset } + ), ]); // Return totalCount to pagination middleware diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index b1e34613d..13c02824a 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -12,6 +12,7 @@ import type TenantContext from '#src/tenants/TenantContext.js'; import koaAuth from '../middleware/koa-auth/index.js'; import adminUserRoutes from './admin-user/index.js'; +import applicationOrganizationRoutes from './applications/application-organization.js'; import applicationProtectedAppMetadataRoutes from './applications/application-protected-app-metadata.js'; import applicationRoleRoutes from './applications/application-role.js'; import applicationSignInExperienceRoutes from './applications/application-sign-in-experience.js'; @@ -52,16 +53,17 @@ const createRouters = (tenant: TenantContext) => { managementRouter.use(koaTenantGuard(tenant.id, tenant.queries)); managementRouter.use(koaManagementApiHooks(tenant.libraries.hooks)); + // TODO: FIXME @sijie @darcy mount these routes in `applicationRoutes` instead applicationRoutes(managementRouter, tenant); applicationRoleRoutes(managementRouter, tenant); + applicationProtectedAppMetadataRoutes(managementRouter, tenant); + applicationOrganizationRoutes(managementRouter, tenant); // Third-party application related routes applicationUserConsentScopeRoutes(managementRouter, tenant); applicationSignInExperienceRoutes(managementRouter, tenant); applicationUserConsentOrganizationRoutes(managementRouter, tenant); - applicationProtectedAppMetadataRoutes(managementRouter, tenant); - logtoConfigRoutes(managementRouter, tenant); connectorRoutes(managementRouter, tenant); resourceRoutes(managementRouter, tenant); diff --git a/packages/integration-tests/src/api/application.ts b/packages/integration-tests/src/api/application.ts index ccb040c10..d0a7037b4 100644 --- a/packages/integration-tests/src/api/application.ts +++ b/packages/integration-tests/src/api/application.ts @@ -5,6 +5,7 @@ import { type OidcClientMetadata, type Role, type ProtectedAppMetadata, + type OrganizationWithRoles, } from '@logto/schemas'; import { formUrlEncodedHeaders } from '@logto/shared'; import { conditional } from '@silverhand/essentials'; @@ -108,3 +109,11 @@ export const generateM2mLog = async (applicationId: string) => { }), }); }; + +/** Get organizations that an application is associated with. */ +export const getOrganizations = async (applicationId: string, page: number, pageSize: number) => + authedAdminApi + .get(`applications/${applicationId}/organizations`, { + searchParams: { page, page_size: pageSize }, + }) + .json(); diff --git a/packages/integration-tests/src/tests/api/application/application.organization.test.ts b/packages/integration-tests/src/tests/api/application/application.organization.test.ts new file mode 100644 index 000000000..407b2661c --- /dev/null +++ b/packages/integration-tests/src/tests/api/application/application.organization.test.ts @@ -0,0 +1,85 @@ +import { type Application, ApplicationType } from '@logto/schemas'; + +import { + createApplication as createApplicationApi, + deleteApplication, + getApplications, + getOrganizations, +} from '#src/api/application.js'; +import { OrganizationApiTest } from '#src/helpers/organization.js'; +import { devFeatureTest, generateTestName } from '#src/utils.js'; + +devFeatureTest.describe('application organizations', () => { + const organizationApi = new OrganizationApiTest(); + const applications: Application[] = []; + const createApplication = async (...args: Parameters) => { + const created = await createApplicationApi(...args); + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + applications.push(created); + return created; + }; + + beforeAll(async () => { + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + applications.push( + await createApplication(generateTestName(), ApplicationType.MachineToMachine) + ); + await Promise.all( + Array.from({ length: 30 }).map(async () => { + const organization = await organizationApi.create({ name: generateTestName() }); + await organizationApi.applications.add(organization.id, [applications[0]!.id]); + return organization; + }) + ); + }); + + afterAll(async () => { + await Promise.all([ + organizationApi.cleanUp(), + // eslint-disable-next-line @typescript-eslint/no-empty-function + ...applications.map(async ({ id }) => deleteApplication(id).catch(() => {})), + ]); + }); + + it('should get organizations by application id with pagination', async () => { + const organizations1 = await getOrganizations(applications[0]!.id, 1, 30); + const organizations2 = await getOrganizations(applications[0]!.id, 2, 10); + const organizations3 = await getOrganizations(applications[0]!.id, 2, 20); + + expect(organizations1).toEqual( + expect.arrayContaining( + organizationApi.organizations.map((object) => expect.objectContaining(object)) + ) + ); + expect(organizations1).toHaveLength(30); + expect(organizations2).toHaveLength(10); + expect(organizations3).toHaveLength(10); + expect(organizations2[0]?.id).toBe(organizations1[10]?.id); + expect(organizations3[0]?.id).toBe(organizations1[20]?.id); + }); + + it('should be able to fetch applications by excluding an organization', async () => { + const excludedOrganization = await organizationApi.create({ name: generateTestName() }); + const applications = await Promise.all( + Array.from({ length: 3 }).map(async () => + createApplication(generateTestName(), ApplicationType.MachineToMachine) + ) + ); + await organizationApi.applications.add(excludedOrganization.id, [applications[0]!.id]); + + const fetchedApplications = await getApplications(undefined, { + excludeOrganizationId: excludedOrganization.id, + page_size: '100', // Just in case + }); + + expect(fetchedApplications).not.toEqual( + expect.arrayContaining([expect.objectContaining(applications[0]!)]) + ); + expect(fetchedApplications).toEqual( + expect.arrayContaining([ + expect.objectContaining(applications[1]!), + expect.objectContaining(applications[2]!), + ]) + ); + }); +}); diff --git a/packages/integration-tests/src/tests/api/application/application.test.ts b/packages/integration-tests/src/tests/api/application/application.test.ts index f89618833..9508355dd 100644 --- a/packages/integration-tests/src/tests/api/application/application.test.ts +++ b/packages/integration-tests/src/tests/api/application/application.test.ts @@ -10,7 +10,7 @@ import { } from '#src/api/index.js'; import { expectRejects } from '#src/helpers/index.js'; -describe('admin console application', () => { +describe('application APIs', () => { it('should create application successfully', async () => { const applicationName = 'test-create-app'; const applicationType = ApplicationType.SPA;