mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
refactor(cli): seed organization data
This commit is contained in:
parent
3e3fa20241
commit
30e4e103c2
7 changed files with 424 additions and 38 deletions
|
@ -17,6 +17,9 @@ import {
|
|||
Roles,
|
||||
type Role,
|
||||
UsersRoles,
|
||||
LogtoConfigs,
|
||||
SignInExperiences,
|
||||
Applications,
|
||||
} from '@logto/schemas';
|
||||
import { Tenants } from '@logto/schemas/models';
|
||||
import { convertToIdentifiers, generateStandardId } from '@logto/shared';
|
||||
|
@ -31,6 +34,7 @@ import { consoleLog, getPathInModule } from '../../../utils.js';
|
|||
|
||||
import { appendAdminConsoleRedirectUris, seedTenantCloudServiceApplication } from './cloud.js';
|
||||
import { seedOidcConfigs } from './oidc-config.js';
|
||||
import { seedTenantOrganizations } from './tenant-organizations.js';
|
||||
import { assignScopesToRole, createTenant, seedAdminData } from './tenant.js';
|
||||
|
||||
const getExplicitOrder = (query: string) => {
|
||||
|
@ -139,7 +143,7 @@ export const seedTables = async (
|
|||
|
||||
// Create tenant application role
|
||||
const applicationRole = createTenantApplicationRole();
|
||||
await connection.query(insertInto(applicationRole, 'roles'));
|
||||
await connection.query(insertInto(applicationRole, Roles.table));
|
||||
await assignScopesToRole(
|
||||
connection,
|
||||
adminTenantId,
|
||||
|
@ -161,16 +165,22 @@ export const seedTables = async (
|
|||
await seedTenantCloudServiceApplication(connection, defaultTenantId);
|
||||
|
||||
await Promise.all([
|
||||
connection.query(insertInto(createDefaultAdminConsoleConfig(defaultTenantId), 'logto_configs')),
|
||||
connection.query(insertInto(createDefaultAdminConsoleConfig(adminTenantId), 'logto_configs')),
|
||||
connection.query(
|
||||
insertInto(createDefaultSignInExperience(defaultTenantId, isCloud), 'sign_in_experiences')
|
||||
insertInto(createDefaultAdminConsoleConfig(defaultTenantId), LogtoConfigs.table)
|
||||
),
|
||||
connection.query(insertInto(createAdminTenantSignInExperience(), 'sign_in_experiences')),
|
||||
connection.query(insertInto(createDefaultAdminConsoleApplication(), 'applications')),
|
||||
connection.query(
|
||||
insertInto(createDefaultAdminConsoleConfig(adminTenantId), LogtoConfigs.table)
|
||||
),
|
||||
connection.query(
|
||||
insertInto(createDefaultSignInExperience(defaultTenantId, isCloud), SignInExperiences.table)
|
||||
),
|
||||
connection.query(insertInto(createAdminTenantSignInExperience(), SignInExperiences.table)),
|
||||
connection.query(insertInto(createDefaultAdminConsoleApplication(), Applications.table)),
|
||||
updateDatabaseTimestamp(connection, latestTimestamp),
|
||||
]);
|
||||
|
||||
await seedTenantOrganizations(connection, isCloud);
|
||||
|
||||
consoleLog.succeed('Seed data');
|
||||
};
|
||||
|
||||
|
|
161
packages/cli/src/commands/database/seed/tenant-organizations.ts
Normal file
161
packages/cli/src/commands/database/seed/tenant-organizations.ts
Normal file
|
@ -0,0 +1,161 @@
|
|||
import {
|
||||
defaultTenantId,
|
||||
adminTenantId,
|
||||
Roles,
|
||||
Applications,
|
||||
OrganizationRoles,
|
||||
TenantRole,
|
||||
getTenantScope,
|
||||
OrganizationScopes,
|
||||
TenantScope,
|
||||
getTenantRole,
|
||||
OrganizationRoleScopeRelations,
|
||||
tenantRoleScopes,
|
||||
getTenantOrganizationCreateData,
|
||||
Organizations,
|
||||
Scopes,
|
||||
Resources,
|
||||
PredefinedScope,
|
||||
RolesScopes,
|
||||
ApplicationsRoles,
|
||||
} from '@logto/schemas';
|
||||
import { getMapiProxyM2mApp, getMapiProxyRole } from '@logto/schemas/lib/types/mapi-proxy.js';
|
||||
import { convertToIdentifiers, generateStandardId } from '@logto/shared';
|
||||
import type { DatabaseTransactionConnection } from 'slonik';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
import { insertInto } from '../../../database.js';
|
||||
import { consoleLog } from '../../../utils.js';
|
||||
|
||||
/**
|
||||
* Seed initial data in the admin tenant for tenant organizations:
|
||||
*
|
||||
* - Organization roles and scopes for tenant management.
|
||||
* - Create tenant organizations for the initial tenants (`default` and `admin`).
|
||||
*
|
||||
* If it is a cloud deployment, it will also seed the following:
|
||||
*
|
||||
* - Machine-to-machine roles for Management API proxy.
|
||||
* - Assign the corresponding Management API scopes to the machine-to-machine roles.
|
||||
* - Machine-to-machine applications for Management API proxy.
|
||||
* - Assign the roles to the corresponding applications.
|
||||
*/
|
||||
export const seedTenantOrganizations = async (
|
||||
connection: DatabaseTransactionConnection,
|
||||
isCloud: boolean
|
||||
) => {
|
||||
const tenantIds = [defaultTenantId, adminTenantId];
|
||||
|
||||
// Init organization template
|
||||
await Promise.all([
|
||||
connection.query(
|
||||
insertInto(
|
||||
Object.values(TenantRole).map((role) => getTenantRole(role)),
|
||||
OrganizationRoles.table
|
||||
)
|
||||
),
|
||||
connection.query(
|
||||
insertInto(
|
||||
Object.values(TenantScope).map((scope) => getTenantScope(scope)),
|
||||
OrganizationScopes.table
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
// Link organization roles and scopes
|
||||
await connection.query(
|
||||
insertInto(
|
||||
Object.entries(tenantRoleScopes).flatMap(([role, scopes]) =>
|
||||
scopes.map((scope) => ({
|
||||
tenantId: adminTenantId,
|
||||
// eslint-disable-next-line no-restricted-syntax -- `Object.entries` converts the enum to string
|
||||
organizationRoleId: getTenantRole(role as TenantRole).id,
|
||||
organizationScopeId: getTenantScope(scope).id,
|
||||
}))
|
||||
),
|
||||
OrganizationRoleScopeRelations.table
|
||||
)
|
||||
);
|
||||
consoleLog.succeed('Initialized tenant organization template');
|
||||
|
||||
// Init tenant organizations
|
||||
await connection.query(
|
||||
insertInto(
|
||||
tenantIds.map((id) => getTenantOrganizationCreateData(id)),
|
||||
Organizations.table
|
||||
)
|
||||
);
|
||||
consoleLog.succeed('Created tenant organizations');
|
||||
|
||||
if (!isCloud) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Init Management API proxy roles
|
||||
await connection.query(
|
||||
insertInto(
|
||||
tenantIds.map((tenantId) => getMapiProxyRole(tenantId)),
|
||||
Roles.table
|
||||
)
|
||||
);
|
||||
consoleLog.succeed('Created Management API proxy roles');
|
||||
|
||||
// Prepare Management API scopes
|
||||
const scopes = convertToIdentifiers(Scopes);
|
||||
const resources = convertToIdentifiers(Resources);
|
||||
/** Scopes with the name {@link PredefinedScope.All} in all Management API resources. */
|
||||
const allScopes = await connection.any<{ id: string; indicator: string }>(sql`
|
||||
select ${scopes.fields.id}, ${resources.fields.indicator}
|
||||
from ${resources.table}
|
||||
join ${scopes.table} on ${scopes.fields.resourceId} = ${resources.fields.id}
|
||||
where ${resources.fields.indicator} like 'https://%.logto.app/api'
|
||||
and ${scopes.fields.name} = ${PredefinedScope.All}
|
||||
and ${resources.fields.tenantId} = ${adminTenantId}
|
||||
`);
|
||||
const assertScopeId = (forTenantId: string) => {
|
||||
const scope = allScopes.find(
|
||||
(scope) => scope.indicator === `https://${forTenantId}.logto.app/api`
|
||||
);
|
||||
if (!scope) {
|
||||
throw new Error(`Cannot find Management API scope for tenant '${forTenantId}'.`);
|
||||
}
|
||||
return scope.id;
|
||||
};
|
||||
|
||||
// Assign Management API scopes to the proxy roles
|
||||
await connection.query(
|
||||
insertInto(
|
||||
tenantIds.map((tenantId) => ({
|
||||
tenantId: adminTenantId,
|
||||
id: generateStandardId(),
|
||||
roleId: getMapiProxyRole(tenantId).id,
|
||||
scopeId: assertScopeId(tenantId),
|
||||
})),
|
||||
RolesScopes.table
|
||||
)
|
||||
);
|
||||
consoleLog.succeed('Assigned Management API scopes to the proxy roles');
|
||||
|
||||
// Create machine-to-machine applications for Management API proxy
|
||||
await connection.query(
|
||||
insertInto(
|
||||
tenantIds.map((tenantId) => getMapiProxyM2mApp(tenantId)),
|
||||
Applications.table
|
||||
)
|
||||
);
|
||||
consoleLog.succeed('Created machine-to-machine applications for Management API proxy');
|
||||
|
||||
// Assign the proxy roles to the applications
|
||||
await connection.query(
|
||||
insertInto(
|
||||
tenantIds.map((tenantId) => ({
|
||||
tenantId: adminTenantId,
|
||||
id: generateStandardId(),
|
||||
applicationId: getMapiProxyM2mApp(tenantId).id,
|
||||
roleId: getMapiProxyRole(tenantId).id,
|
||||
})),
|
||||
ApplicationsRoles.table
|
||||
)
|
||||
);
|
||||
consoleLog.succeed('Assigned the proxy roles to the applications');
|
||||
};
|
|
@ -65,8 +65,19 @@ export const createPoolAndDatabaseIfNeeded = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
export const insertInto = <T extends SchemaLike<string>>(object: T, table: string) => {
|
||||
const keys = Object.keys(object);
|
||||
/**
|
||||
* Build an `insert into` query from the given payload. If the payload is an array, it will insert
|
||||
* multiple rows.
|
||||
*/
|
||||
export const insertInto = <T extends SchemaLike<string>>(payload: T | T[], table: string) => {
|
||||
const first = Array.isArray(payload) ? payload[0] : payload;
|
||||
|
||||
if (!first) {
|
||||
throw new Error('Payload cannot be empty');
|
||||
}
|
||||
|
||||
const keys = Object.keys(first);
|
||||
const values = Array.isArray(payload) ? payload : [payload];
|
||||
|
||||
return sql`
|
||||
insert into ${sql.identifier([table])}
|
||||
|
@ -74,9 +85,15 @@ export const insertInto = <T extends SchemaLike<string>>(object: T, table: strin
|
|||
keys.map((key) => sql.identifier([decamelize(key)])),
|
||||
sql`, `
|
||||
)})
|
||||
values (${sql.join(
|
||||
values ${sql.join(
|
||||
values.map(
|
||||
(object) =>
|
||||
sql`(${sql.join(
|
||||
keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)),
|
||||
sql`, `
|
||||
)})
|
||||
)})`
|
||||
),
|
||||
sql`, `
|
||||
)}
|
||||
`;
|
||||
};
|
||||
|
|
|
@ -23,3 +23,4 @@ export * from './mfa.js';
|
|||
export * from './organization.js';
|
||||
export * from './sso-connector.js';
|
||||
export * from './tenant.js';
|
||||
export * from './tenant-organization.js';
|
||||
|
|
60
packages/schemas/src/types/mapi-proxy.ts
Normal file
60
packages/schemas/src/types/mapi-proxy.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* @fileoverview
|
||||
* Mapi (Management API) proxy is an endpoint in Logto Cloud that proxies the requests to the
|
||||
* corresponding Management API. It has the following benefits:
|
||||
*
|
||||
* - When we migrate the tenant management from API resources to tenant organizations, we can
|
||||
* migrate Console to use the mapi proxy endpoint by changing only the base URL.
|
||||
* - It decouples the access control of Cloud user collaboration from the machine-to-machine access
|
||||
* control of the Management API.
|
||||
* - The mapi proxy endpoint shares the same domain with Logto Cloud, so it can be used in the
|
||||
* browser without CORS.
|
||||
*
|
||||
* This module provides utilities to manage mapi proxy.
|
||||
*/
|
||||
|
||||
import { generateStandardSecret } from '@logto/shared';
|
||||
|
||||
import {
|
||||
RoleType,
|
||||
type Role,
|
||||
type CreateApplication,
|
||||
ApplicationType,
|
||||
} from '../db-entries/index.js';
|
||||
import { adminTenantId } from '../index.js';
|
||||
|
||||
/**
|
||||
* Given a tenant ID, return the role data for the mapi proxy.
|
||||
*
|
||||
* It follows a convention to generate all the fields which can be used across the system. See
|
||||
* source code for details.
|
||||
*/
|
||||
export const getMapiProxyRole = (tenantId: string): Readonly<Role> =>
|
||||
Object.freeze({
|
||||
tenantId: adminTenantId,
|
||||
id: `m-${tenantId}`,
|
||||
name: `machine:mapi:${tenantId}`,
|
||||
description: `Machine-to-machine role for accessing Management API of tenant '${tenantId}'.`,
|
||||
type: RoleType.MachineToMachine,
|
||||
});
|
||||
|
||||
/**
|
||||
* Given a tenant ID, return the application create data for the mapi proxy. The proxy will use the
|
||||
* application to access the Management API.
|
||||
*
|
||||
* It follows a convention to generate all the fields which can be used across the system. See
|
||||
* source code for details.
|
||||
*/
|
||||
export const getMapiProxyM2mApp = (tenantId: string): Readonly<CreateApplication> =>
|
||||
Object.freeze({
|
||||
tenantId: adminTenantId,
|
||||
id: `m-${tenantId}`,
|
||||
secret: generateStandardSecret(32),
|
||||
name: `Management API access for ${tenantId}`,
|
||||
description: `Machine-to-machine app for accessing Management API of tenant '${tenantId}'.`,
|
||||
type: ApplicationType.MachineToMachine,
|
||||
oidcClientMetadata: {
|
||||
redirectUris: [],
|
||||
postLogoutRedirectUris: [],
|
||||
},
|
||||
});
|
164
packages/schemas/src/types/tenant-organization.ts
Normal file
164
packages/schemas/src/types/tenant-organization.ts
Normal file
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* @fileoverview
|
||||
* Tenant organizations are organizations in the admin tenant that represent tenants. They are
|
||||
* created when a tenant is created, and are used to define the roles and scopes for the users in
|
||||
* the tenant.
|
||||
*
|
||||
* This module provides utilities to manage tenant organizations.
|
||||
*/
|
||||
|
||||
import {
|
||||
type CreateOrganization,
|
||||
type OrganizationRole,
|
||||
type OrganizationScope,
|
||||
} from '../db-entries/index.js';
|
||||
import { adminTenantId } from '../seeds/tenant.js';
|
||||
|
||||
/** Given a tenant ID, return the corresponding organization ID in the admin tenant. */
|
||||
export const getTenantOrganizationId = (tenantId: string) => `t-${tenantId}`;
|
||||
|
||||
/**
|
||||
* Given a tenant ID, return the organization create data for the admin tenant. It follows a
|
||||
* convention to generate the organization ID and name which can be used across the system.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const tenantId = 'test-tenant';
|
||||
* const createData = getCreateData(tenantId);
|
||||
*
|
||||
* expect(createData).toEqual({
|
||||
* tenantId: 'admin',
|
||||
* id: 't-test-tenant',
|
||||
* name: 'Tenant test-tenant',
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @see {@link getId} for the convention of generating the organization ID.
|
||||
*/
|
||||
export const getTenantOrganizationCreateData = (tenantId: string): Readonly<CreateOrganization> =>
|
||||
Object.freeze({
|
||||
tenantId: adminTenantId,
|
||||
id: getTenantOrganizationId(tenantId),
|
||||
name: `Tenant ${tenantId}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Scope names in organization template for managing tenants.
|
||||
*
|
||||
* @remarks
|
||||
* Should sync JSDoc descriptions with {@link tenantScopeDescriptions}.
|
||||
*/
|
||||
export enum TenantScope {
|
||||
/** Read the tenant data. */
|
||||
ReadData = 'read:data',
|
||||
/** Write the tenant data, including creating and updating the tenant. */
|
||||
WriteData = 'write:data',
|
||||
/** Delete data of the tenant. */
|
||||
DeleteData = 'delete:data',
|
||||
/** Invite members to the tenant. */
|
||||
InviteMember = 'invite:member',
|
||||
/** Remove members from the tenant. */
|
||||
RemoveMember = 'remove:member',
|
||||
/** Update the role of a member in the tenant. */
|
||||
UpdateMemberRole = 'update:member:role',
|
||||
/** Manage the tenant settings, including name, billing, etc. */
|
||||
ManageTenant = 'manage:tenant',
|
||||
}
|
||||
|
||||
const allTenantScopes = Object.freeze(Object.values(TenantScope));
|
||||
|
||||
/**
|
||||
* Given a tenant scope, return the corresponding organization scope data in the admin tenant.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const scope = TenantScope.ReadData; // 'read:data'
|
||||
* const scopeData = getTenantScope(scope);
|
||||
*
|
||||
* expect(scopeData).toEqual({
|
||||
* tenantId: 'admin',
|
||||
* id: 'read-data',
|
||||
* name: 'read:data',
|
||||
* description: 'Read the tenant data.',
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @see {@link tenantScopeDescriptions} for scope descriptions of each scope.
|
||||
*/
|
||||
export const getTenantScope = (scope: TenantScope): Readonly<OrganizationScope> =>
|
||||
Object.freeze({
|
||||
tenantId: adminTenantId,
|
||||
id: scope.replaceAll(':', '-'),
|
||||
name: scope,
|
||||
description: tenantScopeDescriptions[scope],
|
||||
});
|
||||
|
||||
const tenantScopeDescriptions: Readonly<Record<TenantScope, string>> = Object.freeze({
|
||||
[TenantScope.ReadData]: 'Read the tenant data.',
|
||||
[TenantScope.WriteData]: 'Write the tenant data, including creating and updating the tenant.',
|
||||
[TenantScope.DeleteData]: 'Delete data of the tenant.',
|
||||
[TenantScope.InviteMember]: 'Invite members to the tenant.',
|
||||
[TenantScope.RemoveMember]: 'Remove members from the tenant.',
|
||||
[TenantScope.UpdateMemberRole]: 'Update the role of a member in the tenant.',
|
||||
[TenantScope.ManageTenant]: 'Manage the tenant settings, including name, billing, etc.',
|
||||
});
|
||||
|
||||
/**
|
||||
* Role names in organization template for managing tenants.
|
||||
*
|
||||
* @remarks
|
||||
* Should sync JSDoc descriptions with {@link tenantRoleDescriptions}.
|
||||
*/
|
||||
export enum TenantRole {
|
||||
/** Owner of the tenant, who has all permissions. */
|
||||
Owner = 'owner',
|
||||
/** Admin of the tenant, who has all permissions except managing the tenant settings. */
|
||||
Admin = 'admin',
|
||||
/** Member of the tenant, who has limited permissions on reading and writing the tenant data. */
|
||||
Member = 'member',
|
||||
}
|
||||
|
||||
const tenantRoleDescriptions: Readonly<Record<TenantRole, string>> = Object.freeze({
|
||||
[TenantRole.Owner]: 'Owner of the tenant, who has all permissions.',
|
||||
[TenantRole.Admin]:
|
||||
'Admin of the tenant, who has all permissions except managing the tenant settings.',
|
||||
[TenantRole.Member]:
|
||||
'Member of the tenant, who has limited permissions on reading and writing the tenant data.',
|
||||
});
|
||||
|
||||
/**
|
||||
* Given a tenant role, return the corresponding organization role data in the admin tenant.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const role = TenantRole.Member; // 'member'
|
||||
* const roleData = getTenantRole(role);
|
||||
*
|
||||
* expect(roleData).toEqual({
|
||||
* tenantId: 'admin',
|
||||
* id: 'member',
|
||||
* name: 'member',
|
||||
* description: 'Member of the tenant, who has limited permissions on reading and writing the tenant data.',
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @see {@link tenantRoleDescriptions} for scope descriptions of each role.
|
||||
*/
|
||||
export const getTenantRole = (role: TenantRole): Readonly<OrganizationRole> =>
|
||||
Object.freeze({
|
||||
tenantId: adminTenantId,
|
||||
id: role,
|
||||
name: role,
|
||||
description: tenantRoleDescriptions[role],
|
||||
});
|
||||
|
||||
/**
|
||||
* The dictionary of tenant roles and their corresponding scopes.
|
||||
* @see {TenantRole} for scope descriptions of each role.
|
||||
*/
|
||||
export const tenantRoleScopes: Readonly<Record<TenantRole, Readonly<TenantScope[]>>> =
|
||||
Object.freeze({
|
||||
[TenantRole.Owner]: allTenantScopes,
|
||||
[TenantRole.Admin]: allTenantScopes.filter((scope) => scope !== TenantScope.ManageTenant),
|
||||
[TenantRole.Member]: [TenantScope.ReadData, TenantScope.WriteData, TenantScope.InviteMember],
|
||||
});
|
|
@ -6,30 +6,3 @@ export enum TenantTag {
|
|||
/* A production tenant must have an associated subscription plan, even if it's a free plan. */
|
||||
Production = 'production',
|
||||
}
|
||||
|
||||
/** Tenant roles that are used in organization template of admin tenant. */
|
||||
export enum TenantRole {
|
||||
/* A tenant admin can manage all resources in the tenant. */
|
||||
Admin = 'admin',
|
||||
/* A tenant member can manage resources without deleting them. */
|
||||
Member = 'member',
|
||||
}
|
||||
|
||||
/** Tenant scopes that are used in organization template of admin tenant. */
|
||||
export enum TenantScope {
|
||||
/** Read tenant data. */
|
||||
ReadTenant = 'read:tenant',
|
||||
/** Create or update tenant data. */
|
||||
WriteTenant = 'write:tenant',
|
||||
/** Delete tenant data. */
|
||||
DeleteTenant = 'delete:tenant',
|
||||
/** Invite a new member to the tenant. */
|
||||
InviteMember = 'invite:member',
|
||||
/** Remove a member from the tenant. */
|
||||
RemoveMember = 'remove:member',
|
||||
/** Update a member's role in the tenant. */
|
||||
UpdateMemberRole = 'update:member:role',
|
||||
}
|
||||
|
||||
/** The prefix that applies to all organization IDs that are created to represent a tenant. */
|
||||
export const tenantOrganizationIdPrefix = 't-';
|
||||
|
|
Loading…
Reference in a new issue