From 86ecd79b36909748faeef7bb73cd5245ce96cd1b Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 20 Feb 2023 14:49:08 +0800 Subject: [PATCH] refactor(cloud): wrap create tenant in transaction (#3151) --- packages/cloud/package.json | 2 +- packages/cloud/src/consts/rbac.ts | 3 ++ packages/cloud/src/index.ts | 10 ++-- packages/cloud/src/libraries/tenants.ts | 52 ++++++++++++-------- packages/cloud/src/middleware/with-auth.ts | 10 ++++ packages/cloud/src/queries/tenants.ts | 5 +- packages/cloud/src/queries/users.ts | 5 +- packages/cloud/src/queries/utils.ts | 5 +- packages/cloud/src/routes/tenants.ts | 15 ++++-- packages/schemas/src/seeds/management-api.ts | 2 +- 10 files changed, 70 insertions(+), 39 deletions(-) create mode 100644 packages/cloud/src/consts/rbac.ts diff --git a/packages/cloud/package.json b/packages/cloud/package.json index c5a759c47..816b70e3a 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -1,7 +1,7 @@ { "name": "@logto/cloud", "version": "0.1.0", - "description": "☁️ Logto Cloud service.", + "description": "Logto Cloud service.", "main": "build/index.js", "author": "Silverhand Inc. ", "license": "Elastic-2.0", diff --git a/packages/cloud/src/consts/rbac.ts b/packages/cloud/src/consts/rbac.ts new file mode 100644 index 000000000..0b3f962a2 --- /dev/null +++ b/packages/cloud/src/consts/rbac.ts @@ -0,0 +1,3 @@ +export enum CloudScope { + CreateTenant = 'create:tenant', +} diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index ae9cc0050..a8dd8d9df 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -4,13 +4,13 @@ import createServer, { compose, withRequest } from '@withtyped/server'; import dotenv from 'dotenv'; import { findUp } from 'find-up'; -import withAuth from './middleware/with-auth.js'; -import withHttpProxy from './middleware/with-http-proxy.js'; -import withPathname from './middleware/with-pathname.js'; -import withSpa from './middleware/with-spa.js'; - dotenv.config({ path: await findUp('.env', {}) }); +const { default: withAuth } = await import('./middleware/with-auth.js'); +const { default: withHttpProxy } = await import('./middleware/with-http-proxy.js'); +const { default: withPathname } = await import('./middleware/with-pathname.js'); +const { default: withSpa } = await import('./middleware/with-spa.js'); + const { EnvSet } = await import('./env-set/index.js'); const { default: router } = await import('./routes/index.js'); diff --git a/packages/cloud/src/libraries/tenants.ts b/packages/cloud/src/libraries/tenants.ts index 18baae4e3..bdc6b2745 100644 --- a/packages/cloud/src/libraries/tenants.ts +++ b/packages/cloud/src/libraries/tenants.ts @@ -14,6 +14,8 @@ import type { ZodType } from 'zod'; import { z } from 'zod'; import type { Queries } from '#src/queries/index.js'; +import { createTenantsQueries } from '#src/queries/tenants.js'; +import { createUsersQueries } from '#src/queries/users.js'; import { getDatabaseName } from '#src/queries/utils.js'; import { insertInto } from '#src/utils/query.js'; import { getTenantIdFromManagementApiIndicator } from '#src/utils/tenant.js'; @@ -28,12 +30,11 @@ export const tenantInfoGuard: ZodType = z.object({ indicator: z.string(), }); -export const createTenantsLibrary = (queries: Queries) => { - const { getManagementApiLikeIndicatorsForUser, insertTenant, createTenantRole, insertAdminData } = - queries.tenants; - const { assignRoleToUser } = queries.users; +export class TenantsLibrary { + constructor(public readonly queries: Queries) {} - const getAvailableTenants = async (userId: string): Promise => { + async getAvailableTenants(userId: string): Promise { + const { getManagementApiLikeIndicatorsForUser } = this.queries.tenants; const { rows } = await getManagementApiLikeIndicatorsForUser(userId); return rows @@ -42,24 +43,30 @@ export const createTenantsLibrary = (queries: Queries) => { indicator, })) .filter((tenant): tenant is TenantInfo => Boolean(tenant.id)); - }; + } - const createNewTenant = async (forUserId: string): Promise => { - const { client } = queries; - const databaseName = await getDatabaseName(client); + async createNewTenant(forUserId: string): Promise { + const databaseName = await getDatabaseName(this.queries.client); const { id: tenantId, parentRole, role, password } = createTenantMetadata(databaseName); - // TODO: @gao wrap into transaction // Init tenant const tenantModel: TenantModel = { id: tenantId, dbUser: role, dbUserPassword: password }; - await insertTenant(tenantModel); - await createTenantRole(parentRole, role, password); + const transaction = await this.queries.client.transaction(); + const tenants = createTenantsQueries(transaction); + const users = createUsersQueries(transaction); + + // Start + await transaction.start(); + + // Init tenant + await tenants.insertTenant(tenantModel); + await tenants.createTenantRole(parentRole, role, password); // Create admin data set (resource, roles, etc.) const adminDataInAdminTenant = createAdminDataInAdminTenant(tenantId); - await insertAdminData(adminDataInAdminTenant); - await insertAdminData(createAdminData(tenantId)); - await assignRoleToUser({ + await tenants.insertAdminData(adminDataInAdminTenant); + await tenants.insertAdminData(createAdminData(tenantId)); + await users.assignRoleToUser({ id: generateStandardId(), tenantId: adminTenantId, userId: forUserId, @@ -68,12 +75,15 @@ export const createTenantsLibrary = (queries: Queries) => { // Create initial configs await Promise.all([ - client.query(insertInto(createDefaultAdminConsoleConfig(tenantId), LogtoConfigs.table)), - client.query(insertInto(createDefaultSignInExperience(tenantId), SignInExperiences.table)), + transaction.query(insertInto(createDefaultAdminConsoleConfig(tenantId), LogtoConfigs.table)), + transaction.query( + insertInto(createDefaultSignInExperience(tenantId), SignInExperiences.table) + ), ]); - return { id: tenantId, indicator: adminDataInAdminTenant.resource.indicator }; - }; + // End + await transaction.end(); - return { getAvailableTenants, createNewTenant }; -}; + return { id: tenantId, indicator: adminDataInAdminTenant.resource.indicator }; + } +} diff --git a/packages/cloud/src/middleware/with-auth.ts b/packages/cloud/src/middleware/with-auth.ts index b64aa4d74..ac2c96302 100644 --- a/packages/cloud/src/middleware/with-auth.ts +++ b/packages/cloud/src/middleware/with-auth.ts @@ -8,6 +8,8 @@ import { RequestError } from '@withtyped/server'; import { createRemoteJWKSet, jwtVerify } from 'jose'; import { z } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; + const bearerTokenIdentifier = 'Bearer'; export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => { @@ -56,6 +58,14 @@ export default function withAuth({ })(); return async (context: InputContext, next: NextFunction>) => { + const userId = context.request.headers['development-user-id']?.toString(); + + if (!EnvSet.isProduction && userId) { + console.log(`Found dev user ID ${userId}, skip token validation.`); + + return next({ ...context, auth: { id: userId, scopes: expectScopes } }); + } + const [getKey, issuer] = await getJwkSet; const { diff --git a/packages/cloud/src/queries/tenants.ts b/packages/cloud/src/queries/tenants.ts index 411840bd5..ea379dfaa 100644 --- a/packages/cloud/src/queries/tenants.ts +++ b/packages/cloud/src/queries/tenants.ts @@ -8,14 +8,15 @@ import { PredefinedScope, CreateRolesScope, } from '@logto/schemas'; -import type { PostgresQueryClient } from '@withtyped/postgres'; +import type { PostgreSql } from '@withtyped/postgres'; import { dangerousRaw, id, sql } from '@withtyped/postgres'; +import type { Queryable } from '@withtyped/server'; import { insertInto } from '#src/utils/query.js'; export type TenantsQueries = ReturnType; -export const createTenantsQueries = (client: PostgresQueryClient) => { +export const createTenantsQueries = (client: Queryable) => { const getManagementApiLikeIndicatorsForUser = async (userId: string) => client.query<{ indicator: string }>(sql` select resources.indicator from roles diff --git a/packages/cloud/src/queries/users.ts b/packages/cloud/src/queries/users.ts index 2da144c71..eed64ca8f 100644 --- a/packages/cloud/src/queries/users.ts +++ b/packages/cloud/src/queries/users.ts @@ -1,11 +1,12 @@ import type { UsersRole } from '@logto/schemas'; -import type { PostgresQueryClient } from '@withtyped/postgres'; +import type { PostgreSql } from '@withtyped/postgres'; +import type { Queryable } from '@withtyped/server'; import { insertInto } from '#src/utils/query.js'; export type UsersQueries = ReturnType; -export const createUsersQueries = (client: PostgresQueryClient) => { +export const createUsersQueries = (client: Queryable) => { const assignRoleToUser = async (data: UsersRole) => client.query(insertInto(data, 'users_roles')); return { assignRoleToUser }; diff --git a/packages/cloud/src/queries/utils.ts b/packages/cloud/src/queries/utils.ts index 0d534ef88..428313212 100644 --- a/packages/cloud/src/queries/utils.ts +++ b/packages/cloud/src/queries/utils.ts @@ -1,8 +1,9 @@ -import type { PostgresQueryClient } from '@withtyped/postgres'; +import type { PostgreSql } from '@withtyped/postgres'; import { sql } from '@withtyped/postgres'; +import type { Queryable } from '@withtyped/server'; import { RequestError } from '@withtyped/server'; -export const getDatabaseName = async (client: PostgresQueryClient) => { +export const getDatabaseName = async (client: Queryable) => { const { rows: [first], } = await client.query<{ currentDatabase: string }>(sql` diff --git a/packages/cloud/src/routes/tenants.ts b/packages/cloud/src/routes/tenants.ts index f0207db26..0c9b04736 100644 --- a/packages/cloud/src/routes/tenants.ts +++ b/packages/cloud/src/routes/tenants.ts @@ -1,21 +1,26 @@ import { createRouter, RequestError } from '@withtyped/server'; -import { createTenantsLibrary, tenantInfoGuard } from '#src/libraries/tenants.js'; +import { CloudScope } from '#src/consts/rbac.js'; +import { tenantInfoGuard, TenantsLibrary } from '#src/libraries/tenants.js'; import type { WithAuthContext } from '#src/middleware/with-auth.js'; import { Queries } from '#src/queries/index.js'; -const { getAvailableTenants, createNewTenant } = createTenantsLibrary(Queries.default); +const library = new TenantsLibrary(Queries.default); export const tenants = createRouter('/tenants') .get('/', { response: tenantInfoGuard.array() }, async (context, next) => { - return next({ ...context, json: await getAvailableTenants(context.auth.id) }); + return next({ ...context, json: await library.getAvailableTenants(context.auth.id) }); }) .post('/', { response: tenantInfoGuard }, async (context, next) => { - const tenants = await getAvailableTenants(context.auth.id); + if (!context.auth.scopes.includes(CloudScope.CreateTenant)) { + throw new RequestError('Forbidden due to lack of permission.', 403); + } + + const tenants = await library.getAvailableTenants(context.auth.id); if (tenants.length > 0) { throw new RequestError('The user already has a tenant.', 409); } - return next({ ...context, json: await createNewTenant(context.auth.id) }); + return next({ ...context, json: await library.createNewTenant(context.auth.id) }); }); diff --git a/packages/schemas/src/seeds/management-api.ts b/packages/schemas/src/seeds/management-api.ts index b5f9c2a88..13e18f08e 100644 --- a/packages/schemas/src/seeds/management-api.ts +++ b/packages/schemas/src/seeds/management-api.ts @@ -14,7 +14,7 @@ export type AdminData = { const defaultResourceId = 'management-api'; const defaultScopeAllId = 'management-api-all'; -// Consider combine this with `createAdminData()` +// Consider combining this with `createAdminData()` /** The fixed Management API Resource for `default` tenant. */ export const defaultManagementApi = Object.freeze({ resource: {