diff --git a/.scripts/compare-database.js b/.scripts/compare-database.js index 6d46f56ef..e8786c3b2 100644 --- a/.scripts/compare-database.js +++ b/.scripts/compare-database.js @@ -176,6 +176,7 @@ const queryDatabaseData = async (database) => { 'id', 'resource_id', 'role_id', + 'application_id', 'scope_id', 'created_at', 'updated_at', diff --git a/packages/cli/src/commands/database/seed/tables.ts b/packages/cli/src/commands/database/seed/tables.ts index e728e897c..938c32204 100644 --- a/packages/cli/src/commands/database/seed/tables.ts +++ b/packages/cli/src/commands/database/seed/tables.ts @@ -12,6 +12,10 @@ import { createAdminTenantSignInExperience, createDefaultAdminConsoleApplication, createCloudApi, + createTenantApplicationRole, + createTenantMachineToMachineApplication, + createAdminTenantApplicationRole, + CloudScope, } from '@logto/schemas'; import { Hooks, Tenants } from '@logto/schemas/models'; import type { DatabaseTransactionConnection } from 'slonik'; @@ -128,7 +132,27 @@ export const seedTables = async ( await seedAdminData(connection, createMeApiInAdminTenant()); const [cloudData, ...cloudAdditionalScopes] = createCloudApi(); + const applicationRole = createTenantApplicationRole(); await seedAdminData(connection, cloudData, ...cloudAdditionalScopes); + await connection.query(insertInto(applicationRole, 'roles')); + await assignScopesToRole( + connection, + adminTenantId, + applicationRole.id, + ...cloudAdditionalScopes + .filter(({ name }) => name === CloudScope.SendSms || name === CloudScope.SendEmail) + .map(({ id }) => id) + ); + + // Add M2M app for default tenant + const defaultTenantApplication = createTenantMachineToMachineApplication(defaultTenantId); + await connection.query(insertInto(defaultTenantApplication, 'applications')); + await connection.query( + insertInto( + createAdminTenantApplicationRole(defaultTenantApplication.id, applicationRole.id), + 'applications_roles' + ) + ); // Assign all cloud API scopes to role `admin:admin` await assignScopesToRole( diff --git a/packages/cloud/src/libraries/tenants.ts b/packages/cloud/src/libraries/tenants.ts index 3f784f44b..b25086c33 100644 --- a/packages/cloud/src/libraries/tenants.ts +++ b/packages/cloud/src/libraries/tenants.ts @@ -5,6 +5,9 @@ import { import { generateStandardId } from '@logto/core-kit'; import type { LogtoOidcConfigType, TenantInfo, TenantModel } from '@logto/schemas'; import { + createAdminTenantApplicationRole, + AdminTenantRole, + createTenantMachineToMachineApplication, LogtoOidcConfigKey, LogtoConfigs, SignInExperiences, @@ -18,7 +21,9 @@ import { createTenantMetadata } from '@logto/shared'; import type { ZodType } from 'zod'; import { z } from 'zod'; +import { createApplicationsQueries } from '#src/queries/application.js'; import type { Queries } from '#src/queries/index.js'; +import { createRolesQuery } from '#src/queries/roles.js'; import { createTenantsQueries } from '#src/queries/tenants.js'; import { createUsersQueries } from '#src/queries/users.js'; import { getDatabaseName } from '#src/queries/utils.js'; @@ -61,6 +66,8 @@ export class TenantsLibrary { const transaction = await this.queries.client.transaction(); const tenants = createTenantsQueries(transaction); const users = createUsersQueries(transaction); + const applications = createApplicationsQueries(transaction); + const roles = createRolesQuery(transaction); /* --- Start --- */ await transaction.start(); @@ -79,6 +86,16 @@ export class TenantsLibrary { userId: forUserId, roleId: adminDataInAdminTenant.role.id, }); + // Create M2M App + const m2mRoleId = await roles.findRoleIdByName( + AdminTenantRole.TenantApplication, + adminTenantId + ); + const m2mApplication = createTenantMachineToMachineApplication(tenantId); + await applications.insertApplication(m2mApplication); + await applications.assignRoleToApplication( + createAdminTenantApplicationRole(m2mApplication.id, m2mRoleId) + ); // Create initial configs await Promise.all([ diff --git a/packages/cloud/src/queries/application.ts b/packages/cloud/src/queries/application.ts new file mode 100644 index 000000000..40c9de13c --- /dev/null +++ b/packages/cloud/src/queries/application.ts @@ -0,0 +1,17 @@ +import type { CreateApplication, CreateApplicationsRole } from '@logto/schemas'; +import type { PostgreSql } from '@withtyped/postgres'; +import type { Queryable } from '@withtyped/server'; + +import { insertInto } from '#src/utils/query.js'; + +export type ApplicationsQuery = ReturnType; + +export const createApplicationsQueries = (client: Queryable) => { + const insertApplication = async (data: CreateApplication) => + client.query(insertInto(data, 'applications')); + + const assignRoleToApplication = async (data: CreateApplicationsRole) => + client.query(insertInto(data, 'applications_roles')); + + return { insertApplication, assignRoleToApplication }; +}; diff --git a/packages/cloud/src/queries/index.ts b/packages/cloud/src/queries/index.ts index 094da1f1f..5765089d1 100644 --- a/packages/cloud/src/queries/index.ts +++ b/packages/cloud/src/queries/index.ts @@ -3,6 +3,7 @@ import { createQueryClient } from '@withtyped/postgres'; import { EnvSet } from '#src/env-set/index.js'; import { parseDsn } from '#src/utils/postgres.js'; +import { createApplicationsQueries } from './application.js'; import { createTenantsQueries } from './tenants.js'; import { createUsersQueries } from './users.js'; @@ -12,4 +13,5 @@ export class Queries { public readonly client = createQueryClient(parseDsn(EnvSet.global.dbUrl)); public readonly tenants = createTenantsQueries(this.client); public readonly users = createUsersQueries(this.client); + public readonly applications = createApplicationsQueries(this.client); } diff --git a/packages/cloud/src/queries/roles.ts b/packages/cloud/src/queries/roles.ts new file mode 100644 index 000000000..3cb1e4e09 --- /dev/null +++ b/packages/cloud/src/queries/roles.ts @@ -0,0 +1,23 @@ +import type { PostgreSql } from '@withtyped/postgres'; +import { sql } from '@withtyped/postgres'; +import type { Queryable } from '@withtyped/server'; + +export type RolesQuery = ReturnType; + +export const createRolesQuery = (client: Queryable) => { + const findRoleIdByName = async (roleName: string, tenantId: string) => { + const { rows } = await client.query<{ id: string }>(sql` + select id from roles + where name=${roleName} + and tenant_id=${tenantId} + `); + + if (!rows[0]) { + throw new Error(`Role ${roleName} not found.`); + } + + return rows[0].id; + }; + + return { findRoleIdByName }; +}; diff --git a/packages/schemas/alterations/next-1678425761-m2m-app-for-tenants.ts b/packages/schemas/alterations/next-1678425761-m2m-app-for-tenants.ts new file mode 100644 index 000000000..66c19291e --- /dev/null +++ b/packages/schemas/alterations/next-1678425761-m2m-app-for-tenants.ts @@ -0,0 +1,179 @@ +import { generateStandardId } from '@logto/core-kit'; +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const adminTenantId = 'admin'; +const adminRoleName = 'admin:admin'; + +const alteration: AlterationScript = { + up: async (pool) => { + const scopeIds = { + sms: generateStandardId(), + email: generateStandardId(), + }; + const { id: resourceId } = await pool.one<{ id: string }>(sql` + select id from resources + where tenant_id = ${adminTenantId} + and indicator = 'https://cloud.logto.io/api' + `); + + // Insert scopes + await pool.query(sql` + insert into scopes (tenant_id, id, name, description, resource_id) + values ( + ${adminTenantId}, + ${scopeIds.sms}, + 'send:sms', + 'Allow sending SMS. This scope is only available to M2M application.', + ${resourceId} + ), ( + ${adminTenantId}, + ${scopeIds.email}, + 'send:email', + 'Allow sending emails. This scope is only available to M2M application.', + ${resourceId} + ); + `); + + // Insert role + const roleId = generateStandardId(); + await pool.query(sql` + insert into roles (tenant_id, id, name, description) + values ( + ${adminTenantId}, + ${roleId}, + 'tenantApplication', + 'The role for M2M applications that represent a user tenant and send requests to Logto Cloud.' + ); + `); + + await pool.query(sql` + insert into roles_scopes (tenant_id, id, role_id, scope_id) + values ( + ${adminTenantId}, + ${generateStandardId()}, + ${roleId}, + ${scopeIds.sms} + ), ( + ${adminTenantId}, + ${generateStandardId()}, + ${roleId}, + ${scopeIds.email} + ); + `); + + // Insert new scopes to admin role + const { id: adminRoleId } = await pool.one<{ id: string }>(sql` + select id from roles + where name = ${adminRoleName} + and tenant_id = ${adminTenantId} + `); + await pool.query(sql` + insert into roles_scopes (tenant_id, id, role_id, scope_id) + values ( + ${adminTenantId}, + ${generateStandardId()}, + ${adminRoleId}, + ${scopeIds.sms} + ), ( + ${adminTenantId}, + ${generateStandardId()}, + ${adminRoleId}, + ${scopeIds.email} + ); + `); + + // Insert m2m applications for each tenant (except admin tenant) + const { rows } = await pool.query<{ id: string }>(sql` + select id from tenants + `); + await Promise.all( + rows.map(async (row) => { + const tenantId = row.id; + + if (tenantId === adminTenantId) { + return; + } + + const applicationId = generateStandardId(); + const description = `Machine to machine application for tenant ${tenantId}`; + const oidcClientMetadata = { redirectUris: [], postLogoutRedirectUris: [] }; + const customClientMetadata = { tenantId }; + + await pool.query(sql` + insert into applications (tenant_id, id, name, description, secret, type, oidc_client_metadata, custom_client_metadata) + values ( + 'admin', + ${applicationId}, + 'Cloud Service', + ${description}, + ${generateStandardId()}, + 'MachineToMachine', + ${JSON.stringify(oidcClientMetadata)}, + ${JSON.stringify(customClientMetadata)} + ); + `); + + await pool.query(sql` + insert into applications_roles (tenant_id, id, role_id, application_id) + values ( + 'admin', + ${generateStandardId()}, + ${roleId}, + ${applicationId} + ); + `); + }) + ); + }, + down: async (pool) => { + const role = await pool.one<{ id: string }>(sql` + select id from roles + where tenant_id = ${adminTenantId} + and name='tenantApplication' + `); + const { rows: applications } = await pool.query<{ id: string }>(sql` + select application_id as id from applications_roles + where tenant_id = ${adminTenantId} + and role_id = ${role.id} + `); + + await pool.query(sql` + delete from applications_roles + where tenant_id = ${adminTenantId} + and role_id = ${role.id}; + `); + await pool.query(sql` + delete from roles_scopes + where tenant_id = ${adminTenantId} + and role_id = ${role.id}; + `); + await pool.query(sql` + delete from roles + where tenant_id = ${adminTenantId} + and id = ${role.id}; + `); + + if (applications.length > 0) { + await pool.query(sql` + delete from applications + where tenant_id = ${adminTenantId} + and id in (${sql.join( + applications.map(({ id }) => id), + sql`, ` + )}); + `); + } + await pool.query(sql` + delete from scopes + using resources + where resources.id = scopes.resource_id + and scopes.tenant_id = ${adminTenantId} + and resources.indicator = 'https://cloud.logto.io/api' + and (scopes.name='send:sms' or scopes.name='send:email'); + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index 7c0798cf8..37b23f3ff 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -67,12 +67,14 @@ export enum CustomClientMetadataKey { CorsAllowedOrigins = 'corsAllowedOrigins', IdTokenTtl = 'idTokenTtl', RefreshTokenTtl = 'refreshTokenTtl', + TenantId = 'tenantId', } export const customClientMetadataGuard = z.object({ [CustomClientMetadataKey.CorsAllowedOrigins]: z.string().url().array().optional(), [CustomClientMetadataKey.IdTokenTtl]: z.number().optional(), [CustomClientMetadataKey.RefreshTokenTtl]: z.number().optional(), + [CustomClientMetadataKey.TenantId]: z.string().optional(), }); export type CustomClientMetadata = z.infer; diff --git a/packages/schemas/src/seeds/application.ts b/packages/schemas/src/seeds/application.ts index 06394998e..94b95c460 100644 --- a/packages/schemas/src/seeds/application.ts +++ b/packages/schemas/src/seeds/application.ts @@ -1,6 +1,10 @@ import { generateStandardId } from '@logto/core-kit'; -import type { Application, CreateApplication } from '../db-entries/index.js'; +import type { + Application, + CreateApplication, + CreateApplicationsRole, +} from '../db-entries/index.js'; import { ApplicationType } from '../db-entries/index.js'; import { adminTenantId } from './tenant.js'; @@ -35,3 +39,34 @@ export const createDefaultAdminConsoleApplication = (): Readonly => + Object.freeze({ + tenantId: adminTenantId, + id: generateStandardId(), + name: 'Cloud Service', + description: `Machine to machine application for tenant ${tenantId}`, + secret: generateStandardId(), + type: ApplicationType.MachineToMachine, + oidcClientMetadata: { + redirectUris: [], + postLogoutRedirectUris: [], + }, + customClientMetadata: { + tenantId, + }, + }); + +/** Create role for "tenant application (M2M)" in admin tenant */ +export const createAdminTenantApplicationRole = ( + applicationId: string, + roleId: string +): Readonly => + Object.freeze({ + id: generateStandardId(), + tenantId: adminTenantId, + applicationId, + roleId, + }); diff --git a/packages/schemas/src/seeds/cloud-api.ts b/packages/schemas/src/seeds/cloud-api.ts index 43242522a..66c5a0fc4 100644 --- a/packages/schemas/src/seeds/cloud-api.ts +++ b/packages/schemas/src/seeds/cloud-api.ts @@ -1,6 +1,6 @@ import { generateStandardId } from '@logto/core-kit'; -import type { CreateScope } from '../index.js'; +import type { CreateScope, Role } from '../index.js'; import { AdminTenantRole } from '../types/index.js'; import type { UpdateAdminData } from './management-api.js'; import { adminTenantId } from './tenant.js'; @@ -11,6 +11,8 @@ export const cloudApiIndicator = 'https://cloud.logto.io/api'; export enum CloudScope { CreateTenant = 'create:tenant', ManageTenant = 'manage:tenant', + SendSms = 'send:sms', + SendEmail = 'send:email', } export const createCloudApi = (): Readonly<[UpdateAdminData, ...CreateScope[]]> => { @@ -41,5 +43,21 @@ export const createCloudApi = (): Readonly<[UpdateAdminData, ...CreateScope[]]> CloudScope.ManageTenant, 'Allow managing existing tenants, including create without limitation, update, and delete.' ), + buildScope( + CloudScope.SendEmail, + 'Allow sending emails. This scope is only available to M2M application.' + ), + buildScope( + CloudScope.SendSms, + 'Allow sending SMS. This scope is only available to M2M application.' + ), ]); }; + +export const createTenantApplicationRole = (): Readonly => ({ + tenantId: adminTenantId, + id: generateStandardId(), + name: AdminTenantRole.TenantApplication, + description: + 'The role for M2M applications that represent a user tenant and send requests to Logto Cloud.', +}); diff --git a/packages/schemas/src/types/user.ts b/packages/schemas/src/types/user.ts index 9c5deecee..f44c45397 100644 --- a/packages/schemas/src/types/user.ts +++ b/packages/schemas/src/types/user.ts @@ -36,6 +36,8 @@ export enum AdminTenantRole { Admin = 'admin', /** Common user role in admin tenant. */ User = 'user', + /** The role for machine to machine applications that represent a user tenant and send requests to Logto Cloud. */ + TenantApplication = 'tenantApplication', } export enum PredefinedScope {