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

Merge pull request #5793 from logto-io/yemq-refactor-jwt-library-methods

refactor(core): move the deploy/undeploy worker methods to jwtCustomizerLibrary
This commit is contained in:
Darcy Ye 2024-04-26 10:05:06 +08:00 committed by GitHub
commit 012a2c0e36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 148 additions and 137 deletions

View file

@ -1,13 +1,29 @@
import type { JwtCustomizerUserContext } from '@logto/schemas';
import { userInfoSelectFields, jwtCustomizerUserContextGuard } from '@logto/schemas';
import { deduplicate, pick, pickState } from '@silverhand/essentials';
import {
userInfoSelectFields,
jwtCustomizerUserContextGuard,
type LogtoJwtTokenKey,
type JwtCustomizerType,
type JwtCustomizerUserContext,
} from '@logto/schemas';
import { deduplicate, pick, pickState, assert } from '@silverhand/essentials';
import deepmerge from 'deepmerge';
import RequestError from '#src/errors/RequestError/index.js';
import type { LogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { type ScopeLibrary } from '#src/libraries/scope.js';
import { type UserLibrary } from '#src/libraries/user.js';
import type Queries from '#src/tenants/Queries.js';
import {
getJwtCustomizerScripts,
type CustomJwtDeployRequestBody,
} from '#src/utils/custom-jwt/index.js';
import { type CloudConnectionLibrary } from './cloud-connection.js';
export const createJwtCustomizerLibrary = (
queries: Queries,
logtoConfigs: LogtoConfigLibrary,
cloudConnection: CloudConnectionLibrary,
userLibrary: UserLibrary,
scopeLibrary: ScopeLibrary
) => {
@ -20,6 +36,7 @@ export const createJwtCustomizerLibrary = (
} = queries;
const { findUserRoles } = userLibrary;
const { attachResourceToScopes } = scopeLibrary;
const { getJwtCustomizers } = logtoConfigs;
/**
* We does not include org roles' scopes for the following reason:
@ -65,7 +82,81 @@ export const createJwtCustomizerLibrary = (
return jwtCustomizerUserContextGuard.parse(userContext);
};
/**
* This method is used to deploy the give JWT customizer scripts to the cloud worker service.
*
* @remarks Since cloud worker service deploy all the JWT customizer scripts at once,
* and the latest JWT customizer updates needs to be deployed ahead before saving it to the database,
* we need to merge the input payload with the existing JWT customizer scripts.
*
* @params payload - The latest JWT customizer payload needs to be deployed.
* @params payload.key - The tokenType of the JWT customizer.
* @params payload.value - JWT customizer value
* @params payload.useCase - The use case of JWT customizer script, can be either `test` or `production`.
*/
const deployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(payload: {
key: T;
value: JwtCustomizerType[T];
useCase: 'test' | 'production';
}) => {
const [client, jwtCustomizers] = await Promise.all([
cloudConnection.getClient(),
getJwtCustomizers(),
]);
const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers);
const newCustomizerScripts: CustomJwtDeployRequestBody = {
/**
* There are at most 4 custom JWT scripts in the `CustomJwtDeployRequestBody`-typed object,
* and can be indexed by `data[CustomJwtType][UseCase]`.
*
* Per our design, each script will be deployed as a API endpoint in the Cloudflare
* worker service. A production script will be deployed to `/api/custom-jwt`
* endpoint and a test script will be deployed to `/api/custom-jwt/test` endpoint.
*
* If the current use case is `test`, then the script should be deployed to a `/test` endpoint;
* otherwise, the script should be deployed to the `/api/custom-jwt` endpoint and overwrite
* previous handler of the API endpoint.
*/
[payload.key]: { [payload.useCase]: payload.value.script },
};
await client.put(`/api/services/custom-jwt/worker`, {
body: deepmerge(customizerScriptsFromDatabase, newCustomizerScripts),
});
};
const undeployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(key: T) => {
const [client, jwtCustomizers] = await Promise.all([
cloudConnection.getClient(),
getJwtCustomizers(),
]);
assert(jwtCustomizers[key], new RequestError({ code: 'entity.not_exists', key }));
// Undeploy the worker directly if the only JWT customizer is being deleted.
if (Object.entries(jwtCustomizers).length === 1) {
await client.delete(`/api/services/custom-jwt/worker`);
return;
}
// Remove the JWT customizer script (of given `key`) from the existing JWT customizer scripts and redeploy.
const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers);
const newCustomizerScripts: CustomJwtDeployRequestBody = {
[key]: {
production: undefined,
test: undefined,
},
};
await client.put(`/api/services/custom-jwt/worker`, {
body: deepmerge(customizerScriptsFromDatabase, newCustomizerScripts),
});
};
return {
getUserContext,
deployJwtCustomizerScript,
undeployJwtCustomizerScript,
};
};

View file

@ -8,20 +8,12 @@ import {
jwtCustomizerConfigGuard,
logtoOidcConfigGuard,
} from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import chalk from 'chalk';
import deepmerge from 'deepmerge';
import { ZodError, z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
import { consoleLog } from '#src/utils/console.js';
import {
getJwtCustomizerScripts,
type CustomJwtDeployRequestBody,
} from '#src/utils/custom-jwt/index.js';
import { type CloudConnectionLibrary } from './cloud-connection.js';
export type LogtoConfigLibrary = ReturnType<typeof createLogtoConfigLibrary>;
@ -137,85 +129,6 @@ export const createLogtoConfigLibrary = ({
return updatedRow.value;
};
/**
* This method is used to deploy the give JWT customizer scripts to the cloud worker service.
*
* @remarks Since cloud worker service deploy all the JWT customizer scripts at once,
* and the latest JWT customizer updates needs to be deployed ahead before saving it to the database,
* we need to merge the input payload with the existing JWT customizer scripts.
*
* @params payload - The latest JWT customizer payload needs to be deployed.
* @params payload.key - The tokenType of the JWT customizer.
* @params payload.value - JWT customizer value
* @params payload.useCase - The use case of JWT customizer script, can be either `test` or `production`.
*/
const deployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(
cloudConnection: CloudConnectionLibrary,
payload: {
key: T;
value: JwtCustomizerType[T];
useCase: 'test' | 'production';
}
) => {
const [client, jwtCustomizers] = await Promise.all([
cloudConnection.getClient(),
getJwtCustomizers(),
]);
const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers);
const newCustomizerScripts: CustomJwtDeployRequestBody = {
/**
* There are at most 4 custom JWT scripts in the `CustomJwtDeployRequestBody`-typed object,
* and can be indexed by `data[CustomJwtType][UseCase]`.
*
* Per our design, each script will be deployed as a API endpoint in the Cloudflare
* worker service. A production script will be deployed to `/api/custom-jwt`
* endpoint and a test script will be deployed to `/api/custom-jwt/test` endpoint.
*
* If the current use case is `test`, then the script should be deployed to a `/test` endpoint;
* otherwise, the script should be deployed to the `/api/custom-jwt` endpoint and overwrite
* previous handler of the API endpoint.
*/
[payload.key]: { [payload.useCase]: payload.value.script },
};
await client.put(`/api/services/custom-jwt/worker`, {
body: deepmerge(customizerScriptsFromDatabase, newCustomizerScripts),
});
};
const undeployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(
cloudConnection: CloudConnectionLibrary,
key: T
) => {
const [client, jwtCustomizers] = await Promise.all([
cloudConnection.getClient(),
getJwtCustomizers(),
]);
assert(jwtCustomizers[key], new RequestError({ code: 'entity.not_exists', key }));
// Undeploy the worker directly if the only JWT customizer is being deleted.
if (Object.entries(jwtCustomizers).length === 1) {
await client.delete(`/api/services/custom-jwt/worker`);
return;
}
// Remove the JWT customizer script (of given `key`) from the existing JWT customizer scripts and redeploy.
const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers);
const newCustomizerScripts: CustomJwtDeployRequestBody = {
[key]: {
production: undefined,
test: undefined,
},
};
await client.put(`/api/services/custom-jwt/worker`, {
body: deepmerge(customizerScriptsFromDatabase, newCustomizerScripts),
});
};
return {
getOidcConfigs,
getCloudConnectionData,
@ -223,7 +136,5 @@ export const createLogtoConfigLibrary = ({
getJwtCustomizer,
getJwtCustomizers,
updateJwtCustomizer,
deployJwtCustomizerScript,
undeployJwtCustomizerScript,
};
};

View file

@ -29,7 +29,12 @@ describe('configs JWT customizer routes', () => {
undefined,
{ logtoConfigs: logtoConfigQueries },
undefined,
undefined,
{
jwtCustomizers: {
deployJwtCustomizerScript: jest.fn(),
undeployJwtCustomizerScript: jest.fn(),
},
},
mockLogtoConfigsLibrary
);
@ -55,14 +60,11 @@ describe('configs JWT customizer routes', () => {
.put(`/configs/jwt-customizer/access-token`)
.send(mockJwtCustomizerConfigForAccessToken.value);
expect(mockLogtoConfigsLibrary.deployJwtCustomizerScript).toHaveBeenCalledWith(
tenantContext.cloudConnection,
{
key: LogtoJwtTokenKey.AccessToken,
value: mockJwtCustomizerConfigForAccessToken.value,
useCase: 'production',
}
);
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({
key: LogtoJwtTokenKey.AccessToken,
value: mockJwtCustomizerConfigForAccessToken.value,
useCase: 'production',
});
expect(mockLogtoConfigsLibrary.upsertJwtCustomizer).toHaveBeenCalledWith(
LogtoJwtTokenKey.AccessToken,
@ -100,14 +102,11 @@ describe('configs JWT customizer routes', () => {
.patch('/configs/jwt-customizer/access-token')
.send(mockJwtCustomizerConfigForAccessToken.value);
expect(mockLogtoConfigsLibrary.deployJwtCustomizerScript).toHaveBeenCalledWith(
tenantContext.cloudConnection,
{
key: LogtoJwtTokenKey.AccessToken,
value: mockJwtCustomizerConfigForAccessToken.value,
useCase: 'production',
}
);
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({
key: LogtoJwtTokenKey.AccessToken,
value: mockJwtCustomizerConfigForAccessToken.value,
useCase: 'production',
});
expect(mockLogtoConfigsLibrary.updateJwtCustomizer).toHaveBeenCalledWith(
LogtoJwtTokenKey.AccessToken,
@ -141,8 +140,7 @@ describe('configs JWT customizer routes', () => {
it('DELETE /configs/jwt-customizer/:tokenType should delete the record', async () => {
const response = await routeRequester.delete('/configs/jwt-customizer/client-credentials');
expect(mockLogtoConfigsLibrary.undeployJwtCustomizerScript).toHaveBeenCalledWith(
tenantContext.cloudConnection,
expect(tenantContext.libraries.jwtCustomizers.undeployJwtCustomizerScript).toHaveBeenCalledWith(
LogtoJwtTokenKey.ClientCredentials
);
expect(logtoConfigQueries.deleteJwtCustomizer).toHaveBeenCalledWith(
@ -165,14 +163,11 @@ describe('configs JWT customizer routes', () => {
const response = await routeRequester.post('/configs/jwt-customizer/test').send(payload);
expect(mockLogtoConfigsLibrary.deployJwtCustomizerScript).toHaveBeenCalledWith(
tenantContext.cloudConnection,
{
key: LogtoJwtTokenKey.ClientCredentials,
value: payload,
useCase: 'test',
}
);
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith({
key: LogtoJwtTokenKey.ClientCredentials,
value: payload,
useCase: 'test',
});
expect(mockCloudClient.post).toHaveBeenCalledWith('/api/services/custom-jwt', {
body: payload,

View file

@ -38,14 +38,9 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
]: RouterInitArgs<T>
) {
const { getRowsByKeys, deleteJwtCustomizer } = queries.logtoConfigs;
const {
upsertJwtCustomizer,
getJwtCustomizer,
getJwtCustomizers,
updateJwtCustomizer,
deployJwtCustomizerScript,
undeployJwtCustomizerScript,
} = logtoConfigs;
const { upsertJwtCustomizer, getJwtCustomizer, getJwtCustomizers, updateJwtCustomizer } =
logtoConfigs;
const { deployJwtCustomizerScript, undeployJwtCustomizerScript } = libraries.jwtCustomizers;
router.put(
'/configs/jwt-customizer/:tokenTypePath',
@ -83,7 +78,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
// Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully.
if (!isIntegrationTest) {
await deployJwtCustomizerScript(cloudConnection, {
await deployJwtCustomizerScript({
key,
value: body,
useCase: 'production',
@ -127,7 +122,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
// Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully.
if (!isIntegrationTest) {
await deployJwtCustomizerScript(cloudConnection, {
await deployJwtCustomizerScript({
key,
value: body,
useCase: 'production',
@ -199,7 +194,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
// Undeploy the script first to avoid the case where the JWT customizer was deleted from DB but worker script not updated successfully.
if (!isIntegrationTest) {
await undeployJwtCustomizerScript(cloudConnection, tokenKey);
await undeployJwtCustomizerScript(tokenKey);
}
await deleteJwtCustomizer(tokenKey);
@ -224,7 +219,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
const { body } = ctx.guard;
// Deploy the test script
await deployJwtCustomizerScript(cloudConnection, {
await deployJwtCustomizerScript({
key:
body.tokenType === LogtoJwtTokenKeyType.AccessToken
? LogtoJwtTokenKey.AccessToken

View file

@ -4,6 +4,7 @@ import type { ConnectorLibrary } from '#src/libraries/connector.js';
import { createDomainLibrary } from '#src/libraries/domain.js';
import { createHookLibrary } from '#src/libraries/hook/index.js';
import { createJwtCustomizerLibrary } from '#src/libraries/jwt-customizer.js';
import type { LogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { OrganizationInvitationLibrary } from '#src/libraries/organization-invitation.js';
import { createPasscodeLibrary } from '#src/libraries/passcode.js';
import { createPhraseLibrary } from '#src/libraries/phrase.js';
@ -25,7 +26,14 @@ export default class Libraries {
hooks = createHookLibrary(this.queries);
scopes = createScopeLibrary(this.queries);
socials = createSocialLibrary(this.queries, this.connectors);
jwtCustomizers = createJwtCustomizerLibrary(this.queries, this.users, this.scopes);
jwtCustomizers = createJwtCustomizerLibrary(
this.queries,
this.logtoConfigs,
this.cloudConnection,
this.users,
this.scopes
);
passcodes = createPasscodeLibrary(this.queries, this.connectors);
applications = createApplicationLibrary(this.queries);
verificationStatuses = createVerificationStatusLibrary(this.queries);
@ -52,6 +60,7 @@ export default class Libraries {
private readonly queries: Queries,
// Explicitly passing connector library to eliminate dependency issue
private readonly connectors: ConnectorLibrary,
private readonly cloudConnection: CloudConnectionLibrary
private readonly cloudConnection: CloudConnectionLibrary,
private readonly logtoConfigs: LogtoConfigLibrary
) {}
}

View file

@ -62,7 +62,13 @@ export default class Tenant implements TenantContext {
public readonly logtoConfigs = createLogtoConfigLibrary(queries),
public readonly cloudConnection = createCloudConnectionLibrary(logtoConfigs),
public readonly connectors = createConnectorLibrary(queries, cloudConnection),
public readonly libraries = new Libraries(id, queries, connectors, cloudConnection),
public readonly libraries = new Libraries(
id,
queries,
connectors,
cloudConnection,
logtoConfigs
),
public readonly sentinel = new BasicSentinel(envSet.pool)
) {
const isAdminTenant = id === adminTenantId;

View file

@ -12,8 +12,6 @@ export const mockLogtoConfigsLibrary: jest.Mocked<LogtoConfigLibrary> = {
getJwtCustomizer: jest.fn(),
getJwtCustomizers: jest.fn(),
updateJwtCustomizer: jest.fn(),
deployJwtCustomizerScript: jest.fn(),
undeployJwtCustomizerScript: jest.fn(),
};
export const mockCloudClient = new Client<typeof router>({ baseUrl: 'http://localhost:3001' });

View file

@ -84,7 +84,13 @@ export class MockTenant implements TenantContext {
...createConnectorLibrary(this.queries, this.cloudConnection),
...connectorsOverride,
};
this.libraries = new Libraries(this.id, this.queries, this.connectors, this.cloudConnection);
this.libraries = new Libraries(
this.id,
this.queries,
this.connectors,
this.cloudConnection,
this.logtoConfigs
);
this.setPartial('libraries', librariesOverride);
this.sentinel = new MockSentinel();
}