mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(cloud): wrap create tenant in transaction (#3151)
This commit is contained in:
parent
0f3e256a87
commit
86ecd79b36
10 changed files with 70 additions and 39 deletions
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@logto/cloud",
|
"name": "@logto/cloud",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "☁️ Logto Cloud service.",
|
"description": "Logto Cloud service.",
|
||||||
"main": "build/index.js",
|
"main": "build/index.js",
|
||||||
"author": "Silverhand Inc. <contact@silverhand.io>",
|
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||||
"license": "Elastic-2.0",
|
"license": "Elastic-2.0",
|
||||||
|
|
3
packages/cloud/src/consts/rbac.ts
Normal file
3
packages/cloud/src/consts/rbac.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export enum CloudScope {
|
||||||
|
CreateTenant = 'create:tenant',
|
||||||
|
}
|
|
@ -4,13 +4,13 @@ import createServer, { compose, withRequest } from '@withtyped/server';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { findUp } from 'find-up';
|
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', {}) });
|
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 { EnvSet } = await import('./env-set/index.js');
|
||||||
const { default: router } = await import('./routes/index.js');
|
const { default: router } = await import('./routes/index.js');
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,8 @@ import type { ZodType } from 'zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { Queries } from '#src/queries/index.js';
|
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 { getDatabaseName } from '#src/queries/utils.js';
|
||||||
import { insertInto } from '#src/utils/query.js';
|
import { insertInto } from '#src/utils/query.js';
|
||||||
import { getTenantIdFromManagementApiIndicator } from '#src/utils/tenant.js';
|
import { getTenantIdFromManagementApiIndicator } from '#src/utils/tenant.js';
|
||||||
|
@ -28,12 +30,11 @@ export const tenantInfoGuard: ZodType<TenantInfo> = z.object({
|
||||||
indicator: z.string(),
|
indicator: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createTenantsLibrary = (queries: Queries) => {
|
export class TenantsLibrary {
|
||||||
const { getManagementApiLikeIndicatorsForUser, insertTenant, createTenantRole, insertAdminData } =
|
constructor(public readonly queries: Queries) {}
|
||||||
queries.tenants;
|
|
||||||
const { assignRoleToUser } = queries.users;
|
|
||||||
|
|
||||||
const getAvailableTenants = async (userId: string): Promise<TenantInfo[]> => {
|
async getAvailableTenants(userId: string): Promise<TenantInfo[]> {
|
||||||
|
const { getManagementApiLikeIndicatorsForUser } = this.queries.tenants;
|
||||||
const { rows } = await getManagementApiLikeIndicatorsForUser(userId);
|
const { rows } = await getManagementApiLikeIndicatorsForUser(userId);
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
@ -42,24 +43,30 @@ export const createTenantsLibrary = (queries: Queries) => {
|
||||||
indicator,
|
indicator,
|
||||||
}))
|
}))
|
||||||
.filter((tenant): tenant is TenantInfo => Boolean(tenant.id));
|
.filter((tenant): tenant is TenantInfo => Boolean(tenant.id));
|
||||||
};
|
}
|
||||||
|
|
||||||
const createNewTenant = async (forUserId: string): Promise<TenantInfo> => {
|
async createNewTenant(forUserId: string): Promise<TenantInfo> {
|
||||||
const { client } = queries;
|
const databaseName = await getDatabaseName(this.queries.client);
|
||||||
const databaseName = await getDatabaseName(client);
|
|
||||||
const { id: tenantId, parentRole, role, password } = createTenantMetadata(databaseName);
|
const { id: tenantId, parentRole, role, password } = createTenantMetadata(databaseName);
|
||||||
|
|
||||||
// TODO: @gao wrap into transaction
|
|
||||||
// Init tenant
|
// Init tenant
|
||||||
const tenantModel: TenantModel = { id: tenantId, dbUser: role, dbUserPassword: password };
|
const tenantModel: TenantModel = { id: tenantId, dbUser: role, dbUserPassword: password };
|
||||||
await insertTenant(tenantModel);
|
const transaction = await this.queries.client.transaction();
|
||||||
await createTenantRole(parentRole, role, password);
|
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.)
|
// Create admin data set (resource, roles, etc.)
|
||||||
const adminDataInAdminTenant = createAdminDataInAdminTenant(tenantId);
|
const adminDataInAdminTenant = createAdminDataInAdminTenant(tenantId);
|
||||||
await insertAdminData(adminDataInAdminTenant);
|
await tenants.insertAdminData(adminDataInAdminTenant);
|
||||||
await insertAdminData(createAdminData(tenantId));
|
await tenants.insertAdminData(createAdminData(tenantId));
|
||||||
await assignRoleToUser({
|
await users.assignRoleToUser({
|
||||||
id: generateStandardId(),
|
id: generateStandardId(),
|
||||||
tenantId: adminTenantId,
|
tenantId: adminTenantId,
|
||||||
userId: forUserId,
|
userId: forUserId,
|
||||||
|
@ -68,12 +75,15 @@ export const createTenantsLibrary = (queries: Queries) => {
|
||||||
|
|
||||||
// Create initial configs
|
// Create initial configs
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
client.query(insertInto(createDefaultAdminConsoleConfig(tenantId), LogtoConfigs.table)),
|
transaction.query(insertInto(createDefaultAdminConsoleConfig(tenantId), LogtoConfigs.table)),
|
||||||
client.query(insertInto(createDefaultSignInExperience(tenantId), SignInExperiences.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 };
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,8 @@ import { RequestError } from '@withtyped/server';
|
||||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
|
|
||||||
const bearerTokenIdentifier = 'Bearer';
|
const bearerTokenIdentifier = 'Bearer';
|
||||||
|
|
||||||
export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
|
export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
|
||||||
|
@ -56,6 +58,14 @@ export default function withAuth<InputContext extends RequestContext>({
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return async (context: InputContext, next: NextFunction<WithAuthContext<InputContext>>) => {
|
return async (context: InputContext, next: NextFunction<WithAuthContext<InputContext>>) => {
|
||||||
|
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 [getKey, issuer] = await getJwkSet;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
|
@ -8,14 +8,15 @@ import {
|
||||||
PredefinedScope,
|
PredefinedScope,
|
||||||
CreateRolesScope,
|
CreateRolesScope,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import type { PostgresQueryClient } from '@withtyped/postgres';
|
import type { PostgreSql } from '@withtyped/postgres';
|
||||||
import { dangerousRaw, id, sql } from '@withtyped/postgres';
|
import { dangerousRaw, id, sql } from '@withtyped/postgres';
|
||||||
|
import type { Queryable } from '@withtyped/server';
|
||||||
|
|
||||||
import { insertInto } from '#src/utils/query.js';
|
import { insertInto } from '#src/utils/query.js';
|
||||||
|
|
||||||
export type TenantsQueries = ReturnType<typeof createTenantsQueries>;
|
export type TenantsQueries = ReturnType<typeof createTenantsQueries>;
|
||||||
|
|
||||||
export const createTenantsQueries = (client: PostgresQueryClient) => {
|
export const createTenantsQueries = (client: Queryable<PostgreSql>) => {
|
||||||
const getManagementApiLikeIndicatorsForUser = async (userId: string) =>
|
const getManagementApiLikeIndicatorsForUser = async (userId: string) =>
|
||||||
client.query<{ indicator: string }>(sql`
|
client.query<{ indicator: string }>(sql`
|
||||||
select resources.indicator from roles
|
select resources.indicator from roles
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import type { UsersRole } from '@logto/schemas';
|
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';
|
import { insertInto } from '#src/utils/query.js';
|
||||||
|
|
||||||
export type UsersQueries = ReturnType<typeof createUsersQueries>;
|
export type UsersQueries = ReturnType<typeof createUsersQueries>;
|
||||||
|
|
||||||
export const createUsersQueries = (client: PostgresQueryClient) => {
|
export const createUsersQueries = (client: Queryable<PostgreSql>) => {
|
||||||
const assignRoleToUser = async (data: UsersRole) => client.query(insertInto(data, 'users_roles'));
|
const assignRoleToUser = async (data: UsersRole) => client.query(insertInto(data, 'users_roles'));
|
||||||
|
|
||||||
return { assignRoleToUser };
|
return { assignRoleToUser };
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import type { PostgresQueryClient } from '@withtyped/postgres';
|
import type { PostgreSql } from '@withtyped/postgres';
|
||||||
import { sql } from '@withtyped/postgres';
|
import { sql } from '@withtyped/postgres';
|
||||||
|
import type { Queryable } from '@withtyped/server';
|
||||||
import { RequestError } from '@withtyped/server';
|
import { RequestError } from '@withtyped/server';
|
||||||
|
|
||||||
export const getDatabaseName = async (client: PostgresQueryClient) => {
|
export const getDatabaseName = async (client: Queryable<PostgreSql>) => {
|
||||||
const {
|
const {
|
||||||
rows: [first],
|
rows: [first],
|
||||||
} = await client.query<{ currentDatabase: string }>(sql`
|
} = await client.query<{ currentDatabase: string }>(sql`
|
||||||
|
|
|
@ -1,21 +1,26 @@
|
||||||
import { createRouter, RequestError } from '@withtyped/server';
|
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 type { WithAuthContext } from '#src/middleware/with-auth.js';
|
||||||
import { Queries } from '#src/queries/index.js';
|
import { Queries } from '#src/queries/index.js';
|
||||||
|
|
||||||
const { getAvailableTenants, createNewTenant } = createTenantsLibrary(Queries.default);
|
const library = new TenantsLibrary(Queries.default);
|
||||||
|
|
||||||
export const tenants = createRouter<WithAuthContext, '/tenants'>('/tenants')
|
export const tenants = createRouter<WithAuthContext, '/tenants'>('/tenants')
|
||||||
.get('/', { response: tenantInfoGuard.array() }, async (context, next) => {
|
.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) => {
|
.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) {
|
if (tenants.length > 0) {
|
||||||
throw new RequestError('The user already has a tenant.', 409);
|
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) });
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,7 +14,7 @@ export type AdminData = {
|
||||||
const defaultResourceId = 'management-api';
|
const defaultResourceId = 'management-api';
|
||||||
const defaultScopeAllId = 'management-api-all';
|
const defaultScopeAllId = 'management-api-all';
|
||||||
|
|
||||||
// Consider combine this with `createAdminData()`
|
// Consider combining this with `createAdminData()`
|
||||||
/** The fixed Management API Resource for `default` tenant. */
|
/** The fixed Management API Resource for `default` tenant. */
|
||||||
export const defaultManagementApi = Object.freeze({
|
export const defaultManagementApi = Object.freeze({
|
||||||
resource: {
|
resource: {
|
||||||
|
|
Loading…
Reference in a new issue