0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core,phrases): add post application user consent scopes api (#5101)

add post application user consent scopes api
This commit is contained in:
simeng-li 2023-12-15 16:59:48 +08:00 committed by GitHub
parent fc71c8ae33
commit cb43ebb7d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 489 additions and 4 deletions

View file

@ -2,7 +2,7 @@
"typescript.tsdk": "node_modules/typescript/lib",
"[scss]": {
"editor.codeActionsOnSave": {
"source.fixAll.stylelint": true
"source.fixAll.stylelint": "explicit"
}
},
"stylelint.validate": [
@ -24,7 +24,7 @@
"typescriptreact",
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.eslint": "explicit"
},
"json.schemas": [
{

View file

@ -44,3 +44,26 @@ export const buildFindEntityByIdWithPool =
}
};
};
export const buildFindEntitiesByIdsWithPool =
(pool: CommonQueryMethods) =>
<
Key extends string,
CreateSchema extends Partial<SchemaLike<WithId<Key>>>,
Schema extends SchemaLike<WithId<Key>>,
>(
schema: GeneratedSchema<WithId<Key>, CreateSchema, Schema>
) => {
const { table, fields } = convertToIdentifiers(schema);
const isKeyOfSchema = isKeyOf(schema);
// Make sure id is key of the schema
assertThat(isKeyOfSchema('id'), 'entity.not_exists');
return async (ids: string[]) =>
pool.any<Schema>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.id} in (${ids.length > 0 ? sql.join(ids, sql`, `) : sql`null`})
`);
};

View file

@ -1,12 +1,21 @@
import 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';
export const createApplicationLibrary = (queries: Queries) => {
const {
applications: {
findApplicationById,
userConsentOrganizationScopes,
userConsentResourceScopes,
useConsentUserScopes,
},
applicationsRoles: { findApplicationsRolesByApplicationId },
rolesScopes: { findRolesScopesByRoleIds },
scopes: { findScopesByIdsAndResourceIndicator },
organizations: { scopes: organizationScopesQuery },
scopes: { findScopesByIdsAndResourceIndicator, findScopesByIds },
} = queries;
const findApplicationScopesForResourceIndicator = async (
@ -25,7 +34,88 @@ export const createApplicationLibrary = (queries: Queries) => {
return scopes;
};
// Guard application exists and is a third party application
const validateThirdPartyApplicationById = async (applicationId: string) => {
const application = await findApplicationById(applicationId);
assertThat(
application.isThirdParty,
'application.user_consent_scopes_only_for_third_party_applications'
);
};
// Guard that all scopes exist
const validateApplicationUserConsentScopes = async ({
organizationScopes = [],
resourceScopes = [],
}: {
organizationScopes?: string[];
resourceScopes?: string[];
}) => {
const [organizationScopesData, resourceScopesData] = await Promise.all([
organizationScopesQuery.findByIds(organizationScopes),
findScopesByIds(resourceScopes),
]);
// Assert that all scopes exist, return the missing ones
const invalidOrganizationScopes = organizationScopes.filter(
(scope) => !organizationScopesData.some(({ id }) => id === scope)
);
const invalidResourceScopes = resourceScopes.filter(
(scope) => !resourceScopesData.some(({ id }) => id === scope)
);
assertThat(
invalidOrganizationScopes.length === 0 && invalidResourceScopes.length === 0,
new RequestError(
{
code: 'application.user_consent_scopes_not_found',
status: 422,
},
{ invalidOrganizationScopes, invalidResourceScopes }
)
);
};
// Assign consent scopes to application
const assignApplicationUserConsentScopes = async (
applicationId: string,
{
organizationScopes,
resourceScopes,
userScopes,
}: {
organizationScopes?: string[];
resourceScopes?: string[];
userScopes?: string[];
}
) => {
if (organizationScopes) {
await userConsentOrganizationScopes.insert(
...organizationScopes.map<[string, string]>((scope) => [applicationId, scope])
);
}
if (resourceScopes) {
await userConsentResourceScopes.insert(
...resourceScopes.map<[string, string]>((scope) => [applicationId, scope])
);
}
if (userScopes) {
await Promise.all(
userScopes.map(async (userScope) =>
useConsentUserScopes.insert({ applicationId, userScope })
)
);
}
};
return {
validateThirdPartyApplicationById,
findApplicationScopesForResourceIndicator,
validateApplicationUserConsentScopes,
assignApplicationUserConsentScopes,
};
};

View file

@ -0,0 +1,40 @@
import {
ApplicationUserConsentOrganizationScopes,
ApplicationUserConsentResourceScopes,
ApplicationUserConsentUserScopes,
Applications,
OrganizationScopes,
Scopes,
} from '@logto/schemas';
import { type CommonQueryMethods } from 'slonik';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { TwoRelationsQueries } from '#src/utils/RelationQueries.js';
export class ApplicationUserConsentOrganizationScopeQueries extends TwoRelationsQueries<
typeof Applications,
typeof OrganizationScopes
> {
constructor(pool: CommonQueryMethods) {
super(pool, ApplicationUserConsentOrganizationScopes.table, Applications, OrganizationScopes);
}
}
export class ApplicationUserConsentResourceScopeQueries extends TwoRelationsQueries<
typeof Applications,
typeof Scopes
> {
constructor(pool: CommonQueryMethods) {
super(pool, ApplicationUserConsentResourceScopes.table, Applications, Scopes);
}
}
export const createApplicationUserConsentUserScopeQueries = (pool: CommonQueryMethods) => {
const insert = buildInsertIntoWithPool(pool)(ApplicationUserConsentUserScopes, {
onConflict: { ignore: true },
});
return {
insert,
};
};

View file

@ -13,6 +13,12 @@ import { DeletionError } from '#src/errors/SlonikError/index.js';
import { buildConditionsFromSearch } from '#src/utils/search.js';
import type { Search } from '#src/utils/search.js';
import {
ApplicationUserConsentOrganizationScopeQueries,
ApplicationUserConsentResourceScopeQueries,
createApplicationUserConsentUserScopeQueries,
} from './application-user-consent-scopes.js';
const { table, fields } = convertToIdentifiers(Applications);
const buildApplicationConditions = (search: Search) => {
@ -233,5 +239,8 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
findM2mApplicationsByIds,
findApplicationsByIds,
deleteApplicationById,
userConsentOrganizationScopes: new ApplicationUserConsentOrganizationScopeQueries(pool),
userConsentResourceScopes: new ApplicationUserConsentResourceScopeQueries(pool),
useConsentUserScopes: createApplicationUserConsentUserScopeQueries(pool),
};
};

View file

@ -0,0 +1,40 @@
{
"paths": {
"/api/applications/{applicationId}/user-consent-scopes": {
"post": {
"summary": "Assign user consent scopes to application.",
"description": "Assign the user consent scopes to an application by application id",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"organizationScopes": {
"description": "A list of organization scope id to assign to the application. Throws error if any given organization scope is not found."
},
"resourceScopes": {
"description": "A list of 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."
}
}
}
}
}
},
"responses": {
"201": {
"description": "All the user consent scopes are assigned to the application successfully"
},
"404": {
"description": "The application is not found"
},
"422": {
"description": "Any of the given organization scope, resource scope or user scope is not found"
}
}
}
}
}
}

View file

@ -0,0 +1,52 @@
import { UserScope } from '@logto/core-kit';
import { object, string, nativeEnum } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import type { AuthedRouter, RouterInitArgs } from '../types.js';
export default function applicationUserConsentScopeRoutes<T extends AuthedRouter>(
...[
router,
{
libraries: {
applications: {
validateThirdPartyApplicationById,
validateApplicationUserConsentScopes,
assignApplicationUserConsentScopes,
},
},
},
]: RouterInitArgs<T>
) {
router.post(
'/applications/:applicationId/user-consent-scopes',
koaGuard({
params: object({
applicationId: string(),
}),
body: object({
organizationScopes: string().array().optional(),
resourceScopes: string().array().optional(),
userScopes: nativeEnum(UserScope).array().optional(),
}),
status: [201, 404, 422],
}),
async (ctx, next) => {
const {
params: { applicationId },
body,
} = ctx.guard;
await validateThirdPartyApplicationById(applicationId);
await validateApplicationUserConsentScopes(body);
await assignApplicationUserConsentScopes(applicationId, body);
ctx.status = 201;
return next();
}
);
}

View file

@ -12,6 +12,7 @@ import koaAuth from '../middleware/koa-auth/index.js';
import adminUserRoutes from './admin-user/index.js';
import applicationRoleRoutes from './applications/application-role.js';
import applicationUserConsentScopeRoutes from './applications/application-user-consent-scope.js';
import applicationRoutes from './applications/application.js';
import authnRoutes from './authn.js';
import connectorRoutes from './connector/index.js';
@ -45,6 +46,11 @@ const createRouters = (tenant: TenantContext) => {
applicationRoutes(managementRouter, tenant);
applicationRoleRoutes(managementRouter, tenant);
if (EnvSet.values.isDevFeaturesEnabled) {
applicationUserConsentScopeRoutes(managementRouter, tenant);
}
logtoConfigRoutes(managementRouter, tenant);
connectorRoutes(managementRouter, tenant);
resourceRoutes(managementRouter, tenant);

View file

@ -4,7 +4,10 @@ import { type CommonQueryMethods } from 'slonik';
import { buildDeleteByIdWithPool } from '#src/database/delete-by-id.js';
import { buildFindAllEntitiesWithPool } from '#src/database/find-all-entities.js';
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
import {
buildFindEntitiesByIdsWithPool,
buildFindEntityByIdWithPool,
} from '#src/database/find-entity-by-id.js';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { buildGetTotalRowCountWithPool } from '#src/database/row-count.js';
import { buildUpdateWhereWithPool } from '#src/database/update-where.js';
@ -31,6 +34,9 @@ export default class SchemaQueries<
) => Promise<readonly Schema[]>;
#findById: (id: string) => Promise<Readonly<Schema>>;
#findByIds: (ids: string[]) => Promise<readonly Schema[]>;
#insert: (data: OmitAutoSetFields<CreateSchema>) => Promise<Readonly<Schema>>;
#updateById: <SetKey extends Key | 'id', WhereKey extends Key | 'id'>(
@ -47,6 +53,7 @@ export default class SchemaQueries<
this.#findTotalNumber = buildGetTotalRowCountWithPool(this.pool, this.schema);
this.#findAll = buildFindAllEntitiesWithPool(this.pool)(this.schema, orderBy && [orderBy]);
this.#findById = buildFindEntityByIdWithPool(this.pool)(this.schema);
this.#findByIds = buildFindEntitiesByIdsWithPool(this.pool)(this.schema);
this.#insert = buildInsertIntoWithPool(this.pool)(this.schema, { returning: true });
this.#updateById = buildUpdateWhereWithPool(this.pool)(this.schema, true);
this.#deleteById = buildDeleteByIdWithPool(this.pool, this.schema.table);
@ -64,6 +71,10 @@ export default class SchemaQueries<
return this.#findById(id);
}
async findByIds(ids: string[]): Promise<readonly Schema[]> {
return this.#findByIds(ids);
}
async insert(data: CreateSchema): Promise<Readonly<Schema>> {
return this.#insert(data);
}

View file

@ -25,6 +25,7 @@
"@jest/test-sequencer": "^29.5.0",
"@jest/types": "^29.1.2",
"@logto/connector-kit": "workspace:^2.0.0",
"@logto/core-kit": "workspace:^",
"@logto/js": "^3.0.1",
"@logto/node": "^2.2.0",
"@logto/schemas": "workspace:^1.12.0",

View file

@ -0,0 +1,12 @@
import { type UserScope } from '@logto/core-kit';
import { authedAdminApi } from './api.js';
export const assignUserConsentScopes = async (
applicationId: string,
payload: {
organizationScopes?: string[];
resourceScopes?: string[];
userScopes?: UserScope[];
}
) => authedAdminApi.post(`applications/${applicationId}/user-consent-scopes`, { json: payload });

View file

@ -0,0 +1,125 @@
import { UserScope } from '@logto/core-kit';
import { ApplicationType } from '@logto/schemas';
import { assignUserConsentScopes } 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';
import { createScope } from '#src/api/scope.js';
import { expectRejects } from '#src/helpers/index.js';
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 resourceIds = new Set<string>();
const organizationScopeApi = new OrganizationScopeApi();
beforeAll(async () => {
const firstPartyApp = await createApplication('first-party-application', ApplicationType.SPA);
const thirdPartyApp = await createApplication(
'third-party-application',
ApplicationType.Traditional,
{
isThirdParty: true,
}
);
applicationIds.set('firstPartyApp', firstPartyApp.id);
applicationIds.set('thirdPartyApp', thirdPartyApp.id);
const organizationScope1 = await organizationScopeApi.create({
name: 'organization-scope-1',
});
const organizationScope2 = await organizationScopeApi.create({
name: 'organization-scope-2',
});
organizationScopes.set('organizationScope1', organizationScope1.id);
organizationScopes.set('organizationScope2', organizationScope2.id);
const resource = await createResource();
resourceIds.add(resource.id);
const resourceScope1 = await createScope(resource.id);
const resourceScope2 = await createScope(resource.id);
resourceScopes.set('resourceScope1', resourceScope1.id);
resourceScopes.set('resourceScope2', resourceScope2.id);
});
afterAll(async () => {
await Promise.all(
Array.from(resourceIds).map(async (resourceId) => deleteResource(resourceId))
);
await Promise.all(
Array.from(organizationScopes.values()).map(async (organizationScopeId) =>
organizationScopeApi.delete(organizationScopeId)
)
);
await Promise.all(
Array.from(applicationIds.values()).map(async (applicationId) =>
deleteApplication(applicationId)
)
);
});
it('should throw error when trying to assign scopes to non-third-party application', async () => {
await expectRejects(
assignUserConsentScopes(applicationIds.get('firstPartyApp')!, {
organizationScopes: Array.from(organizationScopes.values()),
resourceScopes: Array.from(resourceScopes.values()),
}),
{
code: 'application.user_consent_scopes_only_for_third_party_applications',
statusCode: 400,
}
);
});
it('should throw error when trying to assign a non-existing organization scope', async () => {
await expectRejects(
assignUserConsentScopes(applicationIds.get('thirdPartyApp')!, {
organizationScopes: ['non-existing-organization-scope'],
}),
{
code: 'application.user_consent_scopes_not_found',
statusCode: 422,
}
);
});
it('should throw error when trying to assign a non-existing resource scope', async () => {
await expectRejects(
assignUserConsentScopes(applicationIds.get('thirdPartyApp')!, {
resourceScopes: ['non-existing-resource-scope'],
}),
{
code: 'application.user_consent_scopes_not_found',
statusCode: 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()),
userScopes: [UserScope.Profile, UserScope.Email, UserScope.OrganizationRoles],
})
).resolves.not.toThrow();
});
it('should not throw error when trying to assign existing consent scopes', async () => {
await expect(
assignUserConsentScopes(applicationIds.get('thirdPartyApp')!, {
organizationScopes: [organizationScopes.get('organizationScope1')!],
resourceScopes: [resourceScopes.get('resourceScope1')!],
userScopes: [UserScope.Profile],
})
).resolves.not.toThrow();
});
});

View file

@ -6,6 +6,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -4,6 +4,9 @@ const application = {
invalid_role_type: 'Can not assign user type role to machine to machine application.',
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -6,6 +6,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -6,6 +6,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -6,6 +6,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -6,6 +6,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -5,6 +5,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -5,6 +5,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -6,6 +6,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -6,6 +6,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -6,6 +6,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -5,6 +5,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -5,6 +5,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -5,6 +5,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -5,6 +5,11 @@ const application = {
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
/** UNTRANSLATED */
user_consent_scopes_only_for_third_party_applications:
'Only third-party applications can manage user consent scopes.',
/** UNTRANSLATED */
user_consent_scopes_not_found: 'Invalid user consent scopes.',
};
export default Object.freeze(application);

View file

@ -3747,6 +3747,9 @@ importers:
'@logto/connector-kit':
specifier: workspace:^2.0.0
version: link:../toolkit/connector-kit
'@logto/core-kit':
specifier: workspace:^
version: link:../toolkit/core-kit
'@logto/js':
specifier: ^3.0.1
version: 3.0.1