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

Merge pull request #6086 from logto-io/gao-org-app-apis

feat(core): add organization - application apis
This commit is contained in:
Gao Sun 2024-06-23 11:02:19 +08:00 committed by GitHub
commit 1620bbd718
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 298 additions and 77 deletions

View file

@ -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<Application>(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}

View file

@ -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<OrganizationWithRoles>(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,

View file

@ -65,7 +65,7 @@ export default function adminUserSearchRoutes<T extends ManagementApiRouter>(
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.',
});

View file

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

View file

@ -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<T extends ManagementApiRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
// 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();
}
);
}

View file

@ -76,6 +76,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
.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<T extends ManagementApiRouter>(
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<T extends ManagementApiRouter>(
);
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

View file

@ -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);

View file

@ -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<OrganizationWithRoles[]>();

View file

@ -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<typeof createApplicationApi>) => {
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]!),
])
);
});
});

View file

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