0
Fork 0
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:
Gao Sun 2023-12-15 00:31:19 +08:00
parent 3e3fa20241
commit 30e4e103c2
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
7 changed files with 424 additions and 38 deletions

View file

@ -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');
};

View 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');
};

View file

@ -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`, `
)}
`;
};

View file

@ -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';

View 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: [],
},
});

View 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],
});

View file

@ -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-';