mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
Merge pull request #5105 from logto-io/gao-tenant-org-alteration
refactor(schemas,core,cli): init tenant organizations
This commit is contained in:
commit
ca68a2a547
13 changed files with 828 additions and 59 deletions
|
@ -5,7 +5,6 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- master
|
||||
- "push-action/**"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
@ -19,8 +18,7 @@ jobs:
|
|||
has-alteration-changes: ${{ steps.changes-detection.outputs.has-alteration-changes }}
|
||||
|
||||
steps:
|
||||
- name: checkout head
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
|
18
.github/workflows/main.yml
vendored
18
.github/workflows/main.yml
vendored
|
@ -132,7 +132,7 @@ jobs:
|
|||
# ** Setup up-to-date databases and compare (test `up`) **
|
||||
- name: Setup fresh database
|
||||
working-directory: ./fresh
|
||||
run: pnpm cli db seed
|
||||
run: pnpm cli db seed --test
|
||||
env:
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/fresh
|
||||
|
||||
|
@ -144,6 +144,14 @@ jobs:
|
|||
env:
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/alteration
|
||||
|
||||
# FIXME: Last version of CLI doesn't support test data seeding. Here's a temporary workaround.
|
||||
# We will remove this step when a new version of CLI is released.
|
||||
- name: Setup alteration database test data
|
||||
working-directory: ./fresh
|
||||
run: pnpm cli db seed --legacy-test-data
|
||||
env:
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/alteration
|
||||
|
||||
- name: Run alteration scripts
|
||||
working-directory: ./fresh
|
||||
run: pnpm cli db alt deploy next
|
||||
|
@ -165,6 +173,14 @@ jobs:
|
|||
env:
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/old
|
||||
|
||||
# FIXME: Last version of CLI doesn't support test data seeding. Here's a temporary workaround.
|
||||
# We will remove this step when a new version of CLI is released.
|
||||
- name: Setup old database test data
|
||||
working-directory: ./fresh
|
||||
run: pnpm cli db seed --legacy-test-data
|
||||
env:
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/old
|
||||
|
||||
- name: Revert fresh database to old
|
||||
working-directory: ./fresh
|
||||
run: pnpm cli db alt r v$(echo ${{ steps.version.outputs.current }} | cut -d@ -f3)
|
||||
|
|
|
@ -7,6 +7,15 @@ const omitArray = (arrayOfObjects, ...keys) => arrayOfObjects.map((value) => omi
|
|||
const schemas = ['cloud', 'public'];
|
||||
const schemasArray = `(${schemas.map((schema) => `'${schema}'`).join(', ')})`;
|
||||
|
||||
const tryCompare = (a, b) => {
|
||||
try {
|
||||
assert.deepStrictEqual(a, b);
|
||||
} catch (error) {
|
||||
console.error(error.toString());
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
const queryDatabaseManifest = async (database) => {
|
||||
const pool = new pg.Pool({ database, user: 'postgres', password: 'postgres' });
|
||||
|
||||
|
@ -150,7 +159,7 @@ const manifests = [
|
|||
await queryDatabaseManifest(database2),
|
||||
];
|
||||
|
||||
assert.deepStrictEqual(...manifests);
|
||||
tryCompare(...manifests);
|
||||
|
||||
const autoCompare = (a, b) => {
|
||||
if (typeof a !== typeof b) {
|
||||
|
@ -200,7 +209,4 @@ const queryDatabaseData = async (database) => {
|
|||
|
||||
console.log('Compare database data between', database1, 'and', database2);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
await queryDatabaseData(database1),
|
||||
await queryDatabaseData(database2),
|
||||
);
|
||||
tryCompare(await queryDatabaseData(database1), await queryDatabaseData(database2));
|
||||
|
|
|
@ -36,9 +36,15 @@ export const seedByPool = async (pool: DatabasePool, cloud = false, test = false
|
|||
});
|
||||
};
|
||||
|
||||
const seedLegacyTestData = async (pool: DatabasePool) => {
|
||||
return pool.transaction(async (connection) => {
|
||||
await seedTest(connection, true);
|
||||
});
|
||||
};
|
||||
|
||||
const seed: CommandModule<
|
||||
Record<string, unknown>,
|
||||
{ swe?: boolean; cloud?: boolean; test?: boolean }
|
||||
{ swe?: boolean; cloud?: boolean; test?: boolean; 'legacy-test-data'?: boolean }
|
||||
> = {
|
||||
command: 'seed [type]',
|
||||
describe: 'Create database then seed tables and data',
|
||||
|
@ -56,10 +62,28 @@ const seed: CommandModule<
|
|||
.option('test', {
|
||||
describe: 'Seed additional test data',
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('legacy-test-data', {
|
||||
describe:
|
||||
'Seed test data only for legacy Logto versions (<=1.12.0), this option conflicts with others',
|
||||
type: 'boolean',
|
||||
}),
|
||||
handler: async ({ swe, cloud, test }) => {
|
||||
handler: async ({ swe, cloud, test, legacyTestData }) => {
|
||||
const pool = await createPoolAndDatabaseIfNeeded();
|
||||
|
||||
if (legacyTestData) {
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
if (swe || cloud || test) {
|
||||
throw new Error(
|
||||
'The `legacy-test-data` option conflicts with other options, please use it alone.'
|
||||
);
|
||||
}
|
||||
|
||||
await seedLegacyTestData(pool);
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (swe && (await doesConfigsTableExist(pool))) {
|
||||
consoleLog.info('Seeding skipped');
|
||||
await pool.end();
|
||||
|
@ -76,8 +100,10 @@ const seed: CommandModule<
|
|||
' Nothing has changed since the seeding process was in a transaction.\n' +
|
||||
' Try to fix the error and seed again.'
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
await pool.end();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -17,7 +17,16 @@ import {
|
|||
Roles,
|
||||
type Role,
|
||||
UsersRoles,
|
||||
LogtoConfigs,
|
||||
SignInExperiences,
|
||||
Applications,
|
||||
OrganizationUserRelations,
|
||||
getTenantOrganizationId,
|
||||
Users,
|
||||
OrganizationRoleUserRelations,
|
||||
TenantRole,
|
||||
} from '@logto/schemas';
|
||||
import { getTenantRole } from '@logto/schemas';
|
||||
import { Tenants } from '@logto/schemas/models';
|
||||
import { convertToIdentifiers, generateStandardId } from '@logto/shared';
|
||||
import type { DatabaseTransactionConnection } from 'slonik';
|
||||
|
@ -31,6 +40,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 +149,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 +171,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);
|
||||
|
||||
consoleLog.succeed('Seed data');
|
||||
};
|
||||
|
||||
|
@ -189,7 +205,7 @@ export const seedCloud = async (connection: DatabaseTransactionConnection) => {
|
|||
* - `test-1` will be assigned the management roles for both `default` and `admin` tenant.
|
||||
* - `test-2` will be assigned the management role for `default` tenant.
|
||||
*/
|
||||
export const seedTest = async (connection: DatabaseTransactionConnection) => {
|
||||
export const seedTest = async (connection: DatabaseTransactionConnection, forLegacy = false) => {
|
||||
const roles = convertToIdentifiers(Roles);
|
||||
const getManagementRole = async (tenantId: string) =>
|
||||
connection.one<Role>(sql`
|
||||
|
@ -207,25 +223,66 @@ export const seedTest = async (connection: DatabaseTransactionConnection) => {
|
|||
)
|
||||
);
|
||||
|
||||
const userIds = Object.freeze(['test-1', 'test-2'] as const);
|
||||
await Promise.all([
|
||||
connection.query(
|
||||
insertInto({ id: 'test-1', username: 'test1', tenantId: adminTenantId }, 'users')
|
||||
insertInto({ id: userIds[0], username: 'test1', tenantId: adminTenantId }, Users.table)
|
||||
),
|
||||
connection.query(
|
||||
insertInto({ id: 'test-2', username: 'test2', tenantId: adminTenantId }, 'users')
|
||||
insertInto({ id: userIds[1], username: 'test2', tenantId: adminTenantId }, Users.table)
|
||||
),
|
||||
]);
|
||||
|
||||
consoleLog.succeed('Created test users');
|
||||
|
||||
const adminTenantRole = await getManagementRole(adminTenantId);
|
||||
const defaultTenantRole = await getManagementRole(defaultTenantId);
|
||||
|
||||
await Promise.all([
|
||||
assignRoleToUser('test-1', adminTenantRole.id),
|
||||
assignRoleToUser('test-1', defaultTenantRole.id),
|
||||
assignRoleToUser('test-2', defaultTenantRole.id),
|
||||
assignRoleToUser(userIds[0], adminTenantRole.id),
|
||||
assignRoleToUser(userIds[0], defaultTenantRole.id),
|
||||
assignRoleToUser(userIds[1], defaultTenantRole.id),
|
||||
]);
|
||||
consoleLog.succeed('Assigned tenant management roles to the test users');
|
||||
|
||||
// This check is for older versions (<=v.12.0) that don't have tenant organization initialized.
|
||||
if (forLegacy) {
|
||||
consoleLog.warn(
|
||||
'Tenant organization is not enabled in legacy Logto versions, skip seeding tenant organization data'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const addOrganizationMembership = async (userId: string, tenantId: string) =>
|
||||
connection.query(
|
||||
insertInto(
|
||||
{ userId, organizationId: getTenantOrganizationId(tenantId), tenantId: adminTenantId },
|
||||
OrganizationUserRelations.table
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
addOrganizationMembership(userIds[0], adminTenantId),
|
||||
addOrganizationMembership(userIds[0], defaultTenantId),
|
||||
addOrganizationMembership(userIds[1], defaultTenantId),
|
||||
]);
|
||||
|
||||
consoleLog.succeed('Assigned tenant management roles to the test users');
|
||||
const assignOrganizationRole = async (userId: string, tenantId: string, tenantRole: TenantRole) =>
|
||||
connection.query(
|
||||
insertInto(
|
||||
{
|
||||
userId,
|
||||
organizationRoleId: getTenantRole(tenantRole).id,
|
||||
organizationId: getTenantOrganizationId(tenantId),
|
||||
tenantId: adminTenantId,
|
||||
},
|
||||
OrganizationRoleUserRelations.table
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
assignOrganizationRole(userIds[0], adminTenantId, TenantRole.Owner),
|
||||
assignOrganizationRole(userIds[0], defaultTenantId, TenantRole.Owner),
|
||||
assignOrganizationRole(userIds[1], defaultTenantId, TenantRole.Owner),
|
||||
]);
|
||||
consoleLog.succeed('Assigned tenant organization membership and roles to the test users');
|
||||
};
|
||||
|
|
155
packages/cli/src/commands/database/seed/tenant-organizations.ts
Normal file
155
packages/cli/src/commands/database/seed/tenant-organizations.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
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`).
|
||||
*
|
||||
* The following data are used for Logto Cloud, we seed them anyway for the sake of simplicity:
|
||||
*
|
||||
* - 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) => {
|
||||
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');
|
||||
|
||||
/* === Cloud-specific data === */
|
||||
// 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, true);
|
||||
const resources = convertToIdentifiers(Resources, true);
|
||||
/** 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(
|
||||
keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)),
|
||||
values ${sql.join(
|
||||
values.map(
|
||||
(object) =>
|
||||
sql`(${sql.join(
|
||||
keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)),
|
||||
sql`, `
|
||||
)})`
|
||||
),
|
||||
sql`, `
|
||||
)})
|
||||
)}
|
||||
`;
|
||||
};
|
||||
|
|
296
packages/schemas/alterations/next-1702544178-sync-tenant-orgs.ts
Normal file
296
packages/schemas/alterations/next-1702544178-sync-tenant-orgs.ts
Normal file
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* @fileoverview A preparation for the update of using organizations to manage tenants in the admin
|
||||
* tenant. This script will do the following in the admin tenant:
|
||||
*
|
||||
* 1. Create organization template roles and scopes.
|
||||
* 2. Create organizations for existing tenants.
|
||||
* 3. Add membership records and assign organization roles to existing users.
|
||||
* 4. Create machine-to-machine Management API role for each tenant.
|
||||
* 5. Create the corresponding machine-to-machine app for each tenant, and assign the Management API role to it.
|
||||
*
|
||||
* The `down` script will revert the changes.
|
||||
*
|
||||
* NOTE: In order to avoid unnecessary dirty data, it's recommended disabling the registration of
|
||||
* new tenants before running this script and deploying the changes.
|
||||
*/
|
||||
|
||||
import { ConsoleLog, generateStandardId } from '@logto/shared';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
import { type AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
const adminTenantId = 'admin';
|
||||
const consoleLog = new ConsoleLog();
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (transaction) => {
|
||||
consoleLog.info('=== Sync tenant organizations ===');
|
||||
|
||||
consoleLog.info('Create organization template roles and scopes');
|
||||
await transaction.query(sql`
|
||||
insert into public.organization_roles (id, tenant_id, name, description)
|
||||
values
|
||||
('owner', ${adminTenantId}, 'owner', 'Owner of the tenant, who has all permissions.'),
|
||||
('admin', ${adminTenantId}, 'admin', 'Admin of the tenant, who has all permissions except managing the tenant settings.'),
|
||||
('member', ${adminTenantId}, 'member', 'Member of the tenant, who has limited permissions on reading and writing the tenant data.');
|
||||
`);
|
||||
await transaction.query(sql`
|
||||
insert into public.organization_scopes (id, tenant_id, name, description)
|
||||
values
|
||||
('read-data', ${adminTenantId}, 'read:data', 'Read the tenant data.'),
|
||||
('write-data', ${adminTenantId}, 'write:data', 'Write the tenant data, including creating and updating the tenant.'),
|
||||
('delete-data', ${adminTenantId}, 'delete:data', 'Delete data of the tenant.'),
|
||||
('invite-member', ${adminTenantId}, 'invite:member', 'Invite members to the tenant.'),
|
||||
('remove-member', ${adminTenantId}, 'remove:member', 'Remove members from the tenant.'),
|
||||
('update-member-role', ${adminTenantId}, 'update:member:role', 'Update the role of a member in the tenant.'),
|
||||
('manage-tenant', ${adminTenantId}, 'manage:tenant', 'Manage the tenant settings, including name, billing, etc.');
|
||||
`);
|
||||
await transaction.query(sql`
|
||||
insert into public.organization_role_scope_relations (tenant_id, organization_role_id, organization_scope_id)
|
||||
values
|
||||
(${adminTenantId}, 'owner', 'read-data'),
|
||||
(${adminTenantId}, 'owner', 'write-data'),
|
||||
(${adminTenantId}, 'owner', 'delete-data'),
|
||||
(${adminTenantId}, 'owner', 'invite-member'),
|
||||
(${adminTenantId}, 'owner', 'remove-member'),
|
||||
(${adminTenantId}, 'owner', 'update-member-role'),
|
||||
(${adminTenantId}, 'owner', 'manage-tenant'),
|
||||
(${adminTenantId}, 'admin', 'read-data'),
|
||||
(${adminTenantId}, 'admin', 'write-data'),
|
||||
(${adminTenantId}, 'admin', 'delete-data'),
|
||||
(${adminTenantId}, 'admin', 'invite-member'),
|
||||
(${adminTenantId}, 'admin', 'remove-member'),
|
||||
(${adminTenantId}, 'admin', 'update-member-role'),
|
||||
(${adminTenantId}, 'member', 'read-data'),
|
||||
(${adminTenantId}, 'member', 'write-data'),
|
||||
(${adminTenantId}, 'member', 'invite-member')
|
||||
`);
|
||||
|
||||
consoleLog.info('Create organizations for existing tenants');
|
||||
const tenants = await transaction.any<{ id: string }>(sql`
|
||||
select id
|
||||
from public.tenants;
|
||||
`);
|
||||
await transaction.query(sql`
|
||||
insert into public.organizations (id, tenant_id, name)
|
||||
values
|
||||
${sql.join(
|
||||
tenants.map(
|
||||
(tenant) => sql`(${`t-${tenant.id}`}, ${adminTenantId}, ${`Tenant ${tenant.id}`})`
|
||||
),
|
||||
sql`, `
|
||||
)};
|
||||
`);
|
||||
|
||||
const usersRoles = await transaction.any<{ userId: string; roleName: string }>(sql`
|
||||
select
|
||||
public.users.id as "userId",
|
||||
public.roles.name as "roleName"
|
||||
from public.users
|
||||
join public.users_roles on public.users_roles.user_id = public.users.id
|
||||
join public.roles on public.roles.id = public.users_roles.role_id
|
||||
where public.roles.tenant_id = ${adminTenantId}
|
||||
and public.roles.name like '%:admin';
|
||||
`);
|
||||
|
||||
if (usersRoles.length === 0) {
|
||||
consoleLog.warn(
|
||||
'No existing admin users found, skip adding membership records for tenant organizations.'
|
||||
);
|
||||
} else {
|
||||
consoleLog.info('Add membership records and assign organization roles to existing users');
|
||||
|
||||
// Add membership records
|
||||
await transaction.query(sql`
|
||||
insert into public.organization_user_relations (tenant_id, organization_id, user_id)
|
||||
values
|
||||
${sql.join(
|
||||
usersRoles.map(
|
||||
(userRole) =>
|
||||
sql`(${adminTenantId}, ${`t-${userRole.roleName.slice(0, -6)}`}, ${
|
||||
userRole.userId
|
||||
})`
|
||||
),
|
||||
sql`, `
|
||||
)};
|
||||
`);
|
||||
// We treat all existing users as the owner of the tenant
|
||||
await transaction.query(sql`
|
||||
insert into public.organization_role_user_relations (tenant_id, organization_id, user_id, organization_role_id)
|
||||
values
|
||||
${sql.join(
|
||||
usersRoles.map(
|
||||
(userRole) =>
|
||||
sql`
|
||||
(
|
||||
${adminTenantId},
|
||||
${`t-${userRole.roleName.slice(0, -6)}`},
|
||||
${userRole.userId},
|
||||
'owner'
|
||||
)
|
||||
`
|
||||
),
|
||||
sql`, `
|
||||
)};
|
||||
`);
|
||||
}
|
||||
|
||||
consoleLog.info('Create machine-to-machine Management API role for each tenant');
|
||||
await transaction.query(sql`
|
||||
insert into public.roles (id, tenant_id, name, description, type)
|
||||
values
|
||||
${sql.join(
|
||||
tenants.map(
|
||||
(tenant) =>
|
||||
sql`
|
||||
(
|
||||
${`m-${tenant.id}`},
|
||||
${adminTenantId},
|
||||
${`machine:mapi:${tenant.id}`},
|
||||
${`Machine-to-machine role for accessing Management API of tenant '${tenant.id}'.`},
|
||||
'MachineToMachine'
|
||||
)
|
||||
`
|
||||
),
|
||||
sql`, `
|
||||
)};
|
||||
`);
|
||||
|
||||
const managementApiScopes = await transaction.any<{ id: string; indicator: string }>(sql`
|
||||
select public.scopes.id, public.resources.indicator
|
||||
from public.resources
|
||||
join public.scopes on public.scopes.resource_id = public.resources.id
|
||||
where public.resources.indicator like 'https://%.logto.app/api'
|
||||
and public.scopes.name = 'all'
|
||||
and public.resources.tenant_id = ${adminTenantId};
|
||||
`);
|
||||
|
||||
const assertScopeId = (forTenantId: string) => {
|
||||
const scope = managementApiScopes.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;
|
||||
};
|
||||
|
||||
// Insert role - scope relations
|
||||
await transaction.query(sql`
|
||||
insert into public.roles_scopes (tenant_id, id, role_id, scope_id)
|
||||
values
|
||||
${sql.join(
|
||||
tenants.map(
|
||||
(tenant) =>
|
||||
sql`
|
||||
(
|
||||
${adminTenantId},
|
||||
${generateStandardId()},
|
||||
${`m-${tenant.id}`},
|
||||
${assertScopeId(tenant.id)}
|
||||
)
|
||||
`
|
||||
),
|
||||
sql`, `
|
||||
)};
|
||||
`);
|
||||
|
||||
consoleLog.info(
|
||||
'Create the corresponding machine-to-machine app for each tenant, and assign the Management API role to it'
|
||||
);
|
||||
await transaction.query(sql`
|
||||
insert into public.applications (id, tenant_id, secret, name, description, type, oidc_client_metadata)
|
||||
values
|
||||
${sql.join(
|
||||
tenants.map(
|
||||
(tenant) =>
|
||||
sql`
|
||||
(
|
||||
${`m-${tenant.id}`},
|
||||
${adminTenantId},
|
||||
${generateStandardId(32)},
|
||||
${`Management API access for ${tenant.id}`},
|
||||
${`Machine-to-machine app for accessing Management API of tenant '${tenant.id}'.`},
|
||||
'MachineToMachine',
|
||||
${sql.jsonb({
|
||||
redirectUris: [],
|
||||
postLogoutRedirectUris: [],
|
||||
})}
|
||||
)
|
||||
`
|
||||
),
|
||||
sql`, `
|
||||
)};
|
||||
`);
|
||||
await transaction.query(sql`
|
||||
insert into public.applications_roles (tenant_id, id, application_id, role_id)
|
||||
values
|
||||
${sql.join(
|
||||
tenants.map(
|
||||
(tenant) =>
|
||||
sql`
|
||||
(
|
||||
${adminTenantId},
|
||||
${generateStandardId()},
|
||||
${`m-${tenant.id}`},
|
||||
${`m-${tenant.id}`}
|
||||
)
|
||||
`
|
||||
),
|
||||
sql`, `
|
||||
)};
|
||||
`);
|
||||
|
||||
consoleLog.info('=== Sync tenant organizations done ===');
|
||||
},
|
||||
down: async (transaction) => {
|
||||
consoleLog.info('=== Revert sync tenant organizations ===');
|
||||
|
||||
consoleLog.info('Remove machine-to-machine apps');
|
||||
await transaction.query(sql`
|
||||
delete from public.applications
|
||||
where public.applications.tenant_id = ${adminTenantId}
|
||||
and public.applications.id like 'm-%';
|
||||
`);
|
||||
|
||||
consoleLog.info('Remove machine-to-machine roles');
|
||||
await transaction.query(sql`
|
||||
delete from public.roles
|
||||
where public.roles.tenant_id = ${adminTenantId}
|
||||
and public.roles.id like 'm-%';
|
||||
`);
|
||||
|
||||
consoleLog.info('Remove organizations');
|
||||
await transaction.query(sql`
|
||||
delete from public.organizations
|
||||
where public.organizations.tenant_id = ${adminTenantId}
|
||||
and public.organizations.id like 't-%';
|
||||
`);
|
||||
|
||||
consoleLog.info('Remove organization roles');
|
||||
await transaction.query(sql`
|
||||
delete from public.organization_roles
|
||||
where public.organization_roles.tenant_id = ${adminTenantId}
|
||||
and public.organization_roles.id in ('owner', 'admin', 'member');
|
||||
`);
|
||||
|
||||
consoleLog.info('Remove organization scopes');
|
||||
await transaction.query(sql`
|
||||
delete from public.organization_scopes
|
||||
where public.organization_scopes.tenant_id = ${adminTenantId}
|
||||
and public.organization_scopes.id in (
|
||||
'read-data',
|
||||
'write-data',
|
||||
'delete-data',
|
||||
'invite-member',
|
||||
'remove-member',
|
||||
'update-member-role',
|
||||
'manage-tenant'
|
||||
);
|
||||
`);
|
||||
|
||||
consoleLog.info('=== Revert sync tenant organizations done ===');
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -62,7 +62,7 @@ export const createTenantMachineToMachineApplication = (
|
|||
},
|
||||
});
|
||||
|
||||
/** Create role for "tenant application (M2M)" in admin tenant */
|
||||
/** Create an entry to assign a role to an application in the admin tenant. */
|
||||
export const createAdminTenantApplicationRole = (
|
||||
applicationId: string,
|
||||
roleId: string
|
||||
|
|
|
@ -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…
Add table
Reference in a new issue