0
Fork 0
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:
Gao Sun 2023-12-19 23:06:41 +08:00 committed by GitHub
commit ca68a2a547
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 828 additions and 59 deletions

View file

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

View file

@ -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)

View file

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

View file

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

View file

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

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

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

View 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;

View file

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

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