0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

feat(core,schemas): add CRUD for consent organization resource scopes (#5804)

feat(core,schemas): add crud for user consent organization resource scopes
This commit is contained in:
wangsijie 2024-04-30 15:09:13 +08:00 committed by GitHub
parent 2e96eea60c
commit 5adf3dfad7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 176 additions and 26 deletions

View file

@ -10,6 +10,11 @@ export const permissionTabs = Object.freeze({
title: 'application_details.permissions.api_resource',
key: ApplicationUserConsentScopeType.ResourceScopes,
},
[ApplicationUserConsentScopeType.OrganizationResourceScopes]: {
// TODO @xiaoyijun: update the title
title: 'application_details.permissions.api_resource',
key: ApplicationUserConsentScopeType.OrganizationResourceScopes,
},
[ApplicationUserConsentScopeType.OrganizationScopes]: {
title: 'application_details.permissions.organization',
key: ApplicationUserConsentScopeType.OrganizationScopes,

View file

@ -72,6 +72,8 @@ const useApplicationScopesAssignment = (applicationId: string) => {
[ApplicationUserConsentScopeType.UserScopes]: userScopesAssignment,
[ApplicationUserConsentScopeType.OrganizationScopes]: organizationScopesAssignment,
[ApplicationUserConsentScopeType.ResourceScopes]: resourceScopesAssignment,
// TODO @xiaoyijun: Replace with correct scopes
[ApplicationUserConsentScopeType.OrganizationResourceScopes]: resourceScopesAssignment,
}),
[organizationScopesAssignment, resourceScopesAssignment, userScopesAssignment]
);

View file

@ -32,6 +32,7 @@ export const createApplicationLibrary = (queries: Queries) => {
applications: {
findApplicationById,
userConsentOrganizationScopes,
userConsentOrganizationResourceScopes,
userConsentResourceScopes,
userConsentUserScopes,
},
@ -76,16 +77,20 @@ export const createApplicationLibrary = (queries: Queries) => {
{
organizationScopes = [],
resourceScopes = [],
organizationResourceScopes = [],
}: {
organizationScopes?: string[];
resourceScopes?: string[];
organizationResourceScopes?: string[];
},
tenantId: string
) => {
const [organizationScopesData, resourceScopesData] = await Promise.all([
organizationScopesQuery.findByIds(organizationScopes),
findScopesByIds(resourceScopes),
]);
const [organizationScopesData, resourceScopesData, organizationResourceScopesData] =
await Promise.all([
organizationScopesQuery.findByIds(organizationScopes),
findScopesByIds(resourceScopes),
findScopesByIds(organizationResourceScopes),
]);
const invalidOrganizationScopes = organizationScopes.filter(
(scope) => !organizationScopesData.some(({ id }) => id === scope)
@ -95,22 +100,28 @@ export const createApplicationLibrary = (queries: Queries) => {
(scope) => !resourceScopesData.some(({ id }) => id === scope)
);
const invalidOrganizationResourceScopes = organizationResourceScopes.filter(
(scope) => !organizationResourceScopesData.some(({ id }) => id === scope)
);
// Assert that all scopes exist, return the missing ones
assertThat(
invalidOrganizationScopes.length === 0 && invalidResourceScopes.length === 0,
invalidOrganizationScopes.length === 0 &&
invalidResourceScopes.length === 0 &&
invalidOrganizationResourceScopes.length === 0,
new RequestError(
{
code: 'application.user_consent_scopes_not_found',
status: 422,
},
{ invalidOrganizationScopes, invalidResourceScopes }
{ invalidOrganizationScopes, invalidResourceScopes, invalidOrganizationResourceScopes }
)
);
const managementApiResourceIndicator = getManagementApiResourceIndicator(tenantId);
const managementApiScopes = await findScopesByIdsAndResourceIndicator(
resourceScopes,
[...resourceScopes, ...organizationResourceScopes],
managementApiResourceIndicator
);
@ -129,10 +140,12 @@ export const createApplicationLibrary = (queries: Queries) => {
{
organizationScopes,
resourceScopes,
organizationResourceScopes,
userScopes,
}: {
organizationScopes?: string[];
resourceScopes?: string[];
organizationResourceScopes?: string[];
userScopes?: string[];
}
) => {
@ -148,6 +161,12 @@ export const createApplicationLibrary = (queries: Queries) => {
);
}
if (organizationResourceScopes) {
await userConsentOrganizationResourceScopes.insert(
...organizationResourceScopes.map<[string, string]>((scope) => [applicationId, scope])
);
}
if (userScopes) {
await Promise.all(
userScopes.map(async (userScope) =>
@ -181,6 +200,21 @@ export const createApplicationLibrary = (queries: Queries) => {
);
};
const getApplicationUserConsentOrganizationResourceScopes = async (applicationId: string) => {
const [, scopes] = await userConsentOrganizationResourceScopes.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) =>
userConsentUserScopes.findAllByApplicationId(applicationId);
@ -198,6 +232,10 @@ export const createApplicationLibrary = (queries: Queries) => {
await userConsentResourceScopes.delete({ applicationId, scopeId });
break;
}
case ApplicationUserConsentScopeType.OrganizationResourceScopes: {
await userConsentOrganizationResourceScopes.delete({ applicationId, scopeId });
break;
}
case ApplicationUserConsentScopeType.UserScopes: {
await userConsentUserScopes.deleteByApplicationIdAndScopeId(applicationId, scopeId);
break;
@ -250,6 +288,7 @@ export const createApplicationLibrary = (queries: Queries) => {
assignApplicationUserConsentScopes,
getApplicationUserConsentOrganizationScopes,
getApplicationUserConsentResourceScopes,
getApplicationUserConsentOrganizationResourceScopes,
getApplicationUserConsentScopes,
deleteApplicationUserConsentScopesByTypeAndScopeId,
validateUserConsentOrganizationMembership,

View file

@ -2,6 +2,7 @@ import { type UserScope } from '@logto/core-kit';
import {
ApplicationUserConsentOrganizationScopes,
ApplicationUserConsentResourceScopes,
ApplicationUserConsentOrganizationResourceScopes,
ApplicationUserConsentUserScopes,
Applications,
OrganizationScopes,
@ -32,6 +33,15 @@ export class ApplicationUserConsentResourceScopeQueries extends TwoRelationsQuer
}
}
export class ApplicationUserConsentOrganizationResourceScopeQueries extends TwoRelationsQueries<
typeof Applications,
typeof Scopes
> {
constructor(pool: CommonQueryMethods) {
super(pool, ApplicationUserConsentOrganizationResourceScopes.table, Applications, Scopes);
}
}
export const createApplicationUserConsentUserScopeQueries = (pool: CommonQueryMethods) => {
const insert = buildInsertIntoWithPool(pool)(ApplicationUserConsentUserScopes, {
onConflict: { ignore: true },

View file

@ -15,6 +15,7 @@ import type { OmitAutoSetFields } from '#src/utils/sql.js';
import ApplicationUserConsentOrganizationsQuery from './application-user-consent-organizations.js';
import {
ApplicationUserConsentOrganizationResourceScopeQueries,
ApplicationUserConsentOrganizationScopeQueries,
ApplicationUserConsentResourceScopeQueries,
createApplicationUserConsentUserScopeQueries,
@ -253,6 +254,8 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
deleteApplicationById,
userConsentOrganizationScopes: new ApplicationUserConsentOrganizationScopeQueries(pool),
userConsentResourceScopes: new ApplicationUserConsentResourceScopeQueries(pool),
userConsentOrganizationResourceScopes:
new ApplicationUserConsentOrganizationResourceScopeQueries(pool),
userConsentUserScopes: createApplicationUserConsentUserScopeQueries(pool),
userConsentOrganizations: new ApplicationUserConsentOrganizationsQuery(pool),
};

View file

@ -15,6 +15,9 @@
"resourceScopes": {
"description": "A list of resource scope id to assign to the application. Throws error if any given resource scope is not found."
},
"organizationResourceScopes": {
"description": "A list of organization resource scope id to assign to the application. Throws error if any given resource scope is not found."
},
"userScopes": {
"description": "A list of user scope enum value to assign to the application."
}
@ -51,6 +54,9 @@
"resourceScopes": {
"description": "A list of resource scope details grouped by resource id assigned to the application."
},
"organizationResourceScopes": {
"description": "A list of organization resource scope details grouped by resource id assigned to the application."
},
"userScopes": {
"description": "A list of user scope enum value assigned to the application."
}

View file

@ -5,6 +5,7 @@ import {
} from '@logto/schemas';
import { object, string, nativeEnum } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';
@ -24,6 +25,7 @@ export default function applicationUserConsentScopeRoutes<T extends ManagementAp
assignApplicationUserConsentScopes,
getApplicationUserConsentOrganizationScopes,
getApplicationUserConsentResourceScopes,
getApplicationUserConsentOrganizationResourceScopes,
getApplicationUserConsentScopes,
deleteApplicationUserConsentScopesByTypeAndScopeId,
},
@ -40,6 +42,7 @@ export default function applicationUserConsentScopeRoutes<T extends ManagementAp
body: object({
organizationScopes: string().array().optional(),
resourceScopes: string().array().optional(),
organizationResourceScopes: string().array().optional(),
userScopes: nativeEnum(UserScope).array().optional(),
}),
status: [201, 404, 422],
@ -50,11 +53,15 @@ export default function applicationUserConsentScopeRoutes<T extends ManagementAp
body,
} = ctx.guard;
// TODO @wangsijie: Remove this when feature is enabled in production
const { organizationResourceScopes, ...rest } = body;
const theBody = EnvSet.values.isDevFeaturesEnabled ? body : rest;
await validateThirdPartyApplicationById(applicationId);
await validateApplicationUserConsentScopes(body, tenantId);
await validateApplicationUserConsentScopes(theBody, tenantId);
await assignApplicationUserConsentScopes(applicationId, body);
await assignApplicationUserConsentScopes(applicationId, theBody);
ctx.status = 201;
@ -68,7 +75,9 @@ export default function applicationUserConsentScopeRoutes<T extends ManagementAp
params: object({
applicationId: string(),
}),
response: applicationUserConsentScopesResponseGuard,
response: EnvSet.values.isDevFeaturesEnabled
? applicationUserConsentScopesResponseGuard
: applicationUserConsentScopesResponseGuard.omit({ organizationResourceScopes: true }),
status: [200, 404],
}),
async (ctx, next) => {
@ -77,17 +86,26 @@ export default function applicationUserConsentScopeRoutes<T extends ManagementAp
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),
]);
const [organizationScopes, resourceScopes, organizationResourceScopes, userScopes] =
await Promise.all([
getApplicationUserConsentOrganizationScopes(applicationId),
getApplicationUserConsentResourceScopes(applicationId),
getApplicationUserConsentOrganizationResourceScopes(applicationId),
getApplicationUserConsentScopes(applicationId),
]);
ctx.body = {
organizationScopes,
resourceScopes,
userScopes,
};
ctx.body = EnvSet.values.isDevFeaturesEnabled
? {
organizationScopes,
resourceScopes,
organizationResourceScopes,
userScopes,
}
: {
organizationScopes,
resourceScopes,
userScopes,
};
return next();
}

View file

@ -11,6 +11,7 @@ export const assignUserConsentScopes = async (
payload: {
organizationScopes?: string[];
resourceScopes?: string[];
organizationResourceScopes?: string[];
userScopes?: UserScope[];
}
) => authedAdminApi.post(`applications/${applicationId}/user-consent-scopes`, { json: payload });

View file

@ -16,6 +16,7 @@ describe('assign user consent scopes to application', () => {
const applicationIds = new Map<string, string>();
const organizationScopes = new Map<string, string>();
const resourceScopes = new Map<string, string>();
const organizationResourceScopes = new Map<string, string>();
const resourceIds = new Set<string>();
const organizationScopeApi = new OrganizationScopeApi();
@ -52,6 +53,12 @@ describe('assign user consent scopes to application', () => {
resourceScopes.set('resourceScope1', resourceScope1.id);
resourceScopes.set('resourceScope2', resourceScope2.id);
const resourceScope3 = await createScope(resource.id);
const resourceScope4 = await createScope(resource.id);
organizationResourceScopes.set('resourceScope1', resourceScope3.id);
organizationResourceScopes.set('resourceScope2', resourceScope4.id);
});
afterAll(async () => {
@ -75,6 +82,7 @@ describe('assign user consent scopes to application', () => {
assignUserConsentScopes(applicationIds.get('firstPartyApp')!, {
organizationScopes: Array.from(organizationScopes.values()),
resourceScopes: Array.from(resourceScopes.values()),
organizationResourceScopes: Array.from(organizationResourceScopes.values()),
}),
{
code: 'application.third_party_application_only',
@ -107,11 +115,24 @@ describe('assign user consent scopes to application', () => {
);
});
it('should throw error when trying to assign a non-existing organization resource scope', async () => {
await expectRejects(
assignUserConsentScopes(applicationIds.get('thirdPartyApp')!, {
organizationResourceScopes: ['non-existing-resource-scope'],
}),
{
code: 'application.user_consent_scopes_not_found',
status: 422,
}
);
});
it('should assign scopes to third-party application successfully', async () => {
await expect(
assignUserConsentScopes(applicationIds.get('thirdPartyApp')!, {
organizationScopes: Array.from(organizationScopes.values()),
resourceScopes: Array.from(resourceScopes.values()),
organizationResourceScopes: Array.from(organizationResourceScopes.values()),
userScopes: [UserScope.Profile, UserScope.Email, UserScope.OrganizationRoles],
})
).resolves.not.toThrow();
@ -122,6 +143,7 @@ describe('assign user consent scopes to application', () => {
assignUserConsentScopes(applicationIds.get('thirdPartyApp')!, {
organizationScopes: [organizationScopes.get('organizationScope1')!],
resourceScopes: [resourceScopes.get('resourceScope1')!],
organizationResourceScopes: [organizationResourceScopes.get('resourceScope1')!],
userScopes: [UserScope.Profile],
})
).resolves.not.toThrow();
@ -154,6 +176,18 @@ describe('assign user consent scopes to application', () => {
).toBeTruthy();
}
expect(result.organizationResourceScopes.length).toBe(1);
expect(result.organizationResourceScopes[0]!.resource.id).toBe(Array.from(resourceIds)[0]);
expect(result.organizationResourceScopes[0]!.scopes.length).toBe(
organizationResourceScopes.size
);
for (const resourceScopeId of organizationResourceScopes.values()) {
expect(
result.organizationResourceScopes[0]!.scopes.some(({ id }) => id === resourceScopeId)
).toBeTruthy();
}
expect(result.userScopes.length).toBe(3);
for (const userScope of [UserScope.Profile, UserScope.Email, UserScope.OrganizationRoles]) {
@ -170,6 +204,7 @@ describe('assign user consent scopes to application', () => {
expect(result.organizationScopes.length).toBe(0);
expect(result.resourceScopes.length).toBe(0);
expect(result.organizationResourceScopes.length).toBe(0);
expect(result.userScopes.length).toBe(0);
await deleteApplication(newApp.id);
@ -214,6 +249,18 @@ describe('assign user consent scopes to application', () => {
}
);
await expectRejects(
deleteUserConsentScopes(
applicationIds.get('thirdPartyApp')!,
ApplicationUserConsentScopeType.OrganizationResourceScopes,
'non-existing-resource-scope'
),
{
code: 'entity.not_found',
status: 404,
}
);
await expectRejects(
deleteUserConsentScopes(
applicationIds.get('thirdPartyApp')!,
@ -232,6 +279,7 @@ describe('assign user consent scopes to application', () => {
assignUserConsentScopes(applicationIds.get('thirdPartyApp')!, {
organizationScopes: Array.from(organizationScopes.values()),
resourceScopes: Array.from(resourceScopes.values()),
organizationResourceScopes: Array.from(organizationResourceScopes.values()),
userScopes: [UserScope.Profile, UserScope.Email, UserScope.OrganizationRoles],
})
).resolves.not.toThrow();
@ -252,6 +300,14 @@ describe('assign user consent scopes to application', () => {
)
).resolves.not.toThrow();
await expect(
deleteUserConsentScopes(
applicationIds.get('thirdPartyApp')!,
ApplicationUserConsentScopeType.OrganizationResourceScopes,
organizationResourceScopes.get('resourceScope1')!
)
).resolves.not.toThrow();
await expect(
deleteUserConsentScopes(
applicationIds.get('thirdPartyApp')!,
@ -274,6 +330,12 @@ describe('assign user consent scopes to application', () => {
)
).toBeUndefined();
expect(
result.organizationResourceScopes[0]!.scopes.find(
({ id }) => id === organizationResourceScopes.get('resourceScope1')!
)
).toBeUndefined();
expect(result.userScopes.includes(UserScope.OrganizationRoles)).toBeFalsy();
});
});

View file

@ -40,22 +40,26 @@ export const applicationPatchGuard = applicationCreateGuard.partial().omit({
isThirdParty: true,
});
const resourceScopesGuard = 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 })),
})
);
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 })),
})
),
resourceScopes: resourceScopesGuard,
organizationResourceScopes: resourceScopesGuard,
userScopes: z.array(z.nativeEnum(UserScope)),
});
export enum ApplicationUserConsentScopeType {
OrganizationScopes = 'organization-scopes',
ResourceScopes = 'resource-scopes',
OrganizationResourceScopes = 'organization-resource-scopes',
UserScopes = 'user-scopes',
}