diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 4059d2c78..a5a378046 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -54,7 +54,7 @@ jobs: strategy: fail-fast: false matrix: - target: [api, ui, ui-cloud] + target: [api, api-cloud, ui, ui-cloud] needs: package runs-on: ubuntu-latest diff --git a/packages/cli/src/commands/database/seed/tenant.ts b/packages/cli/src/commands/database/seed/tenant.ts index 6e8e653c2..b1a3e14a6 100644 --- a/packages/cli/src/commands/database/seed/tenant.ts +++ b/packages/cli/src/commands/database/seed/tenant.ts @@ -33,10 +33,12 @@ export const seedAdminData = async ( data: AdminData | UpdateAdminData, ...additionalScopes: CreateScope[] ) => { - const { resource, scope, role } = data; + const { resource, scopes, role } = data; assert( - resource.tenantId === scope.tenantId && scope.tenantId === role.tenantId, + scopes.every( + (scope) => resource.tenantId === scope.tenantId && scope.tenantId === role.tenantId + ), new Error('All data should have the same tenant ID') ); @@ -58,19 +60,24 @@ export const seedAdminData = async ( }; await pool.query(insertInto(resource, 'resources')); - await pool.query(insertInto(scope, 'scopes')); - await Promise.all(additionalScopes.map(async (scope) => pool.query(insertInto(scope, 'scopes')))); + await Promise.all( + [...scopes, ...additionalScopes].map(async (scope) => pool.query(insertInto(scope, 'scopes'))) + ); const roleId = await processRole(); - await pool.query( - insertInto( - { - id: generateStandardId(), - roleId, - scopeId: scope.id, - tenantId: resource.tenantId, - } satisfies CreateRolesScope, - 'roles_scopes' + await Promise.all( + scopes.map(async ({ id }) => + pool.query( + insertInto( + { + id: generateStandardId(), + roleId, + scopeId: id, + tenantId: resource.tenantId, + } satisfies CreateRolesScope, + 'roles_scopes' + ) + ) ) ); }; diff --git a/packages/cloud/src/queries/tenants.ts b/packages/cloud/src/queries/tenants.ts index 1af2fe8e6..4cca6eb1d 100644 --- a/packages/cloud/src/queries/tenants.ts +++ b/packages/cloud/src/queries/tenants.ts @@ -44,30 +44,38 @@ export const createTenantsQueries = (client: Queryable) => { `); const insertAdminData = async (data: AdminData) => { - const { resource, scope, role } = data; + const { resource, scopes, role } = data; assert( - resource.tenantId && scope.tenantId && role.tenantId, + resource.tenantId && scopes.every(({ tenantId }) => tenantId) && role.tenantId, new Error('Tenant ID cannot be empty.') ); assert( - resource.tenantId === scope.tenantId && scope.tenantId === role.tenantId, + scopes.every( + (scope) => resource.tenantId === scope.tenantId && scope.tenantId === role.tenantId + ), new Error('All data should have the same tenant ID.') ); await client.query(insertInto(resource, 'resources')); - await client.query(insertInto(scope, 'scopes')); + await Promise.all(scopes.map(async (scope) => client.query(insertInto(scope, 'scopes')))); await client.query(insertInto(role, 'roles')); - await client.query( - insertInto( - { - id: generateStandardId(), - roleId: role.id, - scopeId: scope.id, - tenantId: resource.tenantId, - } satisfies CreateRolesScope, - 'roles_scopes' + + const { tenantId } = resource; + await Promise.all( + scopes.map(async ({ id }) => + client.query( + insertInto( + { + id: generateStandardId(), + roleId: role.id, + scopeId: id, + tenantId, + } satisfies CreateRolesScope, + 'roles_scopes' + ) + ) ) ); }; diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx index b1fd56348..71a5e95d0 100644 --- a/packages/console/src/App.tsx +++ b/packages/console/src/App.tsx @@ -55,7 +55,8 @@ function Content() { PredefinedScope.All, ...conditionalArray( isCloud && cloudApi.scopes.CreateTenant, - isCloud && cloudApi.scopes.ManageTenant + isCloud && cloudApi.scopes.ManageTenant, + isCloud && cloudApi.scopes.ManageTenantSelf ), ], [] diff --git a/packages/core/src/middleware/koa-auth/index.test.ts b/packages/core/src/middleware/koa-auth/index.test.ts index 83768b9d8..18c8ee3aa 100644 --- a/packages/core/src/middleware/koa-auth/index.test.ts +++ b/packages/core/src/middleware/koa-auth/index.test.ts @@ -20,9 +20,12 @@ mockEsm('./utils.js', () => ({ const { jwtVerify } = mockEsm('jose', () => ({ createLocalJWKSet: jest.fn(), - jwtVerify: jest - .fn() - .mockReturnValue({ payload: { sub: 'fooUser', scope: defaultManagementApi.scope.name } }), + jwtVerify: jest.fn().mockReturnValue({ + payload: { + sub: 'fooUser', + scope: defaultManagementApi.scopes.map((scope) => scope.name).join(' '), + }, + }), })); const audience = defaultManagementApi.resource.indicator; diff --git a/packages/core/src/middleware/koa-auth/index.ts b/packages/core/src/middleware/koa-auth/index.ts index 4befefc53..38fffc659 100644 --- a/packages/core/src/middleware/koa-auth/index.ts +++ b/packages/core/src/middleware/koa-auth/index.ts @@ -60,7 +60,11 @@ export const verifyBearerTokenFromRequest = async ( if ((!isProduction || isIntegrationTest) && userId) { consoleLog.warn(`Found dev user ID ${userId}, skip token validation.`); - return { sub: userId, clientId: undefined, scopes: [defaultManagementApi.scope.name] }; + return { + sub: userId, + clientId: undefined, + scopes: defaultManagementApi.scopes.map(({ name }) => name), + }; } const getKeysAndIssuer = async (): Promise<[JWK[], string[]]> => { diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 5d1cf13e6..1938d64a7 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -12,8 +12,9 @@ "scripts": { "build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap", "test:only": "NODE_OPTIONS=--experimental-vm-modules jest", - "test": "pnpm build && pnpm test:api && pnpm test:ui", + "test": "pnpm build && pnpm test:api && pnpm test:api-cloud && pnpm test:ui", "test:api": "pnpm test:only -i ./lib/tests/api/", + "test:api-cloud": "pnpm test:only -i ./lib/tests/api-cloud/", "test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/ui/", "test:ui-cloud": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/ui-cloud/", "lint": "eslint --ext .ts src", diff --git a/packages/integration-tests/src/api/api.ts b/packages/integration-tests/src/api/api.ts index d9ba4fcc6..56fee3291 100644 --- a/packages/integration-tests/src/api/api.ts +++ b/packages/integration-tests/src/api/api.ts @@ -1,6 +1,6 @@ import { got } from 'got'; -import { logtoConsoleUrl, logtoUrl } from '#src/constants.js'; +import { logtoConsoleUrl, logtoUrl, logtoCloudUrl } from '#src/constants.js'; const api = got.extend({ prefixUrl: new URL('/api', logtoUrl), @@ -24,3 +24,7 @@ export const authedAdminTenantApi = adminTenantApi.extend({ 'development-user-id': 'integration-test-admin-user', }, }); + +export const cloudApi = got.extend({ + prefixUrl: new URL('/api', logtoCloudUrl), +}); diff --git a/packages/integration-tests/src/api/tenant.ts b/packages/integration-tests/src/api/tenant.ts new file mode 100644 index 000000000..934851352 --- /dev/null +++ b/packages/integration-tests/src/api/tenant.ts @@ -0,0 +1,21 @@ +import type { TenantInfo } from '@logto/schemas'; + +import { cloudApi } from './api.js'; + +export const createTenant = async (accessToken: string) => { + return cloudApi + .extend({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + .post('tenants') + .json(); +}; + +export const getTenants = async (accessToken: string) => { + return cloudApi + .extend({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + .get('tenants') + .json(); +}; diff --git a/packages/integration-tests/src/helpers/admin-tenant.ts b/packages/integration-tests/src/helpers/admin-tenant.ts index 663b21a7e..f2da45e49 100644 --- a/packages/integration-tests/src/helpers/admin-tenant.ts +++ b/packages/integration-tests/src/helpers/admin-tenant.ts @@ -2,15 +2,21 @@ // Since they are just different in URLs import type { LogtoConfig } from '@logto/node'; -import type { Role, User } from '@logto/schemas'; import { + cloudApiIndicator, + CloudScope, PredefinedScope, adminTenantId, defaultTenantId, getManagementApiResourceIndicator, + getManagementApiAdminName, adminConsoleApplicationId, InteractionEvent, + AdminTenantRole, + type Role, + type User, } from '@logto/schemas'; +import { conditionalArray } from '@silverhand/essentials'; import { authedAdminTenantApi as api, adminTenantApi } from '#src/api/api.js'; import type { InteractionPayload } from '#src/api/interaction.js'; @@ -25,7 +31,7 @@ export const createResponseWithCode = (statusCode: number) => ({ response: { statusCode }, }); -export const createUserWithAllRoles = async () => { +const createUserWithRoles = async (roleNames: string[]) => { const username = generateUsername(); const password = generatePassword(); const user = await api @@ -37,16 +43,24 @@ export const createUserWithAllRoles = async () => { // Should have roles for default tenant Management API and admin tenant Me API const roles = await api.get('roles').json(); await Promise.all( - roles.map(async ({ id }) => - api.post(`roles/${id}/users`, { - json: { userIds: [user.id] }, - }) - ) + roles + .filter(({ name }) => roleNames.includes(name)) + .map(async ({ id }) => + api.post(`roles/${id}/users`, { + json: { userIds: [user.id] }, + }) + ) ); return [user, { username, password }] as const; }; +export const createUserWithAllRoles = async () => { + const allRoles = await api.get('roles').json(); + const allRoleNames = allRoles.map(({ name }) => name); + return createUserWithRoles(allRoleNames); +}; + export const deleteUser = async (id: string) => { await api.delete(`users/${id}`); }; @@ -86,7 +100,7 @@ export const initClientAndSignIn = async ( return client; }; -export const createUserAndSignInWithClient = async () => { +export const createUserWithAllRolesAndSignInToClient = async () => { const [{ id }, { username, password }] = await createUserWithAllRoles(); const client = await initClientAndSignIn(username, password, { resources: [resourceDefault, resourceMe], @@ -95,3 +109,20 @@ export const createUserAndSignInWithClient = async () => { return { id, client }; }; + +export const createUserAndSignInToCloudClient = async ( + userRoleType: AdminTenantRole.User | AdminTenantRole.Admin +) => { + const [{ id }, { username, password }] = await createUserWithRoles( + conditionalArray( + AdminTenantRole.User, + userRoleType === AdminTenantRole.Admin && getManagementApiAdminName(adminTenantId) + ) + ); + const client = await initClientAndSignIn(username, password, { + resources: [cloudApiIndicator], + scopes: Object.values(CloudScope), + }); + + return { id, client }; +}; diff --git a/packages/integration-tests/src/tests/api-cloud/tenant.test.ts b/packages/integration-tests/src/tests/api-cloud/tenant.test.ts new file mode 100644 index 000000000..b33d72608 --- /dev/null +++ b/packages/integration-tests/src/tests/api-cloud/tenant.test.ts @@ -0,0 +1,56 @@ +import { + cloudApiIndicator, + CloudScope, + AdminTenantRole, + type Resource, + type Scope, + type Role, +} from '@logto/schemas'; + +import { authedAdminTenantApi } from '#src/api/api.js'; +import { createTenant, getTenants } from '#src/api/tenant.js'; +import { createUserAndSignInToCloudClient } from '#src/helpers/admin-tenant.js'; + +describe('Tenant APIs', () => { + it('should be able to create multiple tenants for `admin` role', async () => { + const { client } = await createUserAndSignInToCloudClient(AdminTenantRole.Admin); + const accessToken = await client.getAccessToken(cloudApiIndicator); + const tenant1 = await createTenant(accessToken); + const tenant2 = await createTenant(accessToken); + expect(tenant1).toHaveProperty('id'); + expect(tenant2).toHaveProperty('id'); + const tenants = await getTenants(accessToken); + expect(tenants.length).toBeGreaterThan(2); + expect(tenants.find((tenant) => tenant.id === tenant1.id)).toBeDefined(); + expect(tenants.find((tenant) => tenant.id === tenant2.id)).toBeDefined(); + }); + + it('should create only one tenant for `user` role', async () => { + const { client } = await createUserAndSignInToCloudClient(AdminTenantRole.User); + const accessToken = await client.getAccessToken(cloudApiIndicator); + const tenant1 = await createTenant(accessToken); + await expect(createTenant(accessToken)).rejects.toThrow(); + expect(tenant1).toHaveProperty('id'); + const tenants = await getTenants(accessToken); + expect(tenants.length).toEqual(1); + expect(tenants.find((tenant) => tenant.id === tenant1.id)).toBeDefined(); + }); + + it('`user` role should have `CloudScope.ManageTenantSelf` scope', async () => { + const resources = await authedAdminTenantApi.get('resources').json(); + const cloudApiResource = resources.find(({ indicator }) => indicator === cloudApiIndicator); + expect(cloudApiResource).toBeDefined(); + const scopes = await authedAdminTenantApi + .get(`resources/${cloudApiResource!.id}/scopes`) + .json(); + const manageOwnTenantScope = scopes.find((scope) => scope.name === CloudScope.ManageTenantSelf); + expect(manageOwnTenantScope).toBeDefined(); + const roles = await authedAdminTenantApi.get('roles').json(); + const userRole = roles.find(({ name }) => name === 'user'); + expect(userRole).toBeDefined(); + const roleScopes = await authedAdminTenantApi + .get(`roles/${userRole!.id}/scopes`) + .json(); + expect(roleScopes.find(({ id }) => id === manageOwnTenantScope!.id)).toBeDefined(); + }); +}); diff --git a/packages/integration-tests/src/tests/api/me.test.ts b/packages/integration-tests/src/tests/api/me.test.ts index 9db7fc4f8..07c67df4e 100644 --- a/packages/integration-tests/src/tests/api/me.test.ts +++ b/packages/integration-tests/src/tests/api/me.test.ts @@ -3,7 +3,7 @@ import { got } from 'got'; import { logtoConsoleUrl, logtoUrl } from '#src/constants.js'; import { createResponseWithCode, - createUserAndSignInWithClient, + createUserWithAllRolesAndSignInToClient, deleteUser, resourceDefault, resourceMe, @@ -22,7 +22,7 @@ describe('me', () => { }); it('should only recognize the access token with correct resource and scope', async () => { - const { id, client } = await createUserAndSignInWithClient(); + const { id, client } = await createUserWithAllRolesAndSignInToClient(); await expect( got.get(logtoConsoleUrl + '/me/custom-data', { @@ -40,7 +40,7 @@ describe('me', () => { }); it('should be able to update custom data', async () => { - const { id, client } = await createUserAndSignInWithClient(); + const { id, client } = await createUserWithAllRolesAndSignInToClient(); const headers = { authorization: `Bearer ${await client.getAccessToken(resourceMe)}` }; const data = await got diff --git a/packages/integration-tests/src/tests/api/oidc/get-access-token.test.ts b/packages/integration-tests/src/tests/api/oidc/get-access-token.test.ts index 6acf8b53b..c59ec14c5 100644 --- a/packages/integration-tests/src/tests/api/oidc/get-access-token.test.ts +++ b/packages/integration-tests/src/tests/api/oidc/get-access-token.test.ts @@ -29,7 +29,7 @@ describe('get access token', () => { it('can sign in and getAccessToken with admin user', async () => { const client = new MockClient({ resources: [defaultManagementApi.resource.indicator], - scopes: [defaultManagementApi.scope.name], + scopes: defaultManagementApi.scopes.map(({ name }) => name), }); await client.initSession(); await client.successSend(putInteraction, { @@ -42,7 +42,7 @@ describe('get access token', () => { expect(accessToken).not.toBeNull(); expect(getAccessTokenPayload(accessToken)).toHaveProperty( 'scope', - defaultManagementApi.scope.name + defaultManagementApi.scopes.map(({ name }) => name).join(' ') ); // Request for invalid resource should throw @@ -52,7 +52,7 @@ describe('get access token', () => { it('can sign in and getAccessToken with guest user', async () => { const client = new MockClient({ resources: [defaultManagementApi.resource.indicator], - scopes: [defaultManagementApi.scope.name], + scopes: defaultManagementApi.scopes.map(({ name }) => name), }); await client.initSession(); await client.successSend(putInteraction, { @@ -65,7 +65,7 @@ describe('get access token', () => { expect(getAccessTokenPayload(accessToken)).not.toHaveProperty( 'scope', - defaultManagementApi.scope.name + defaultManagementApi.scopes.map(({ name }) => name).join(' ') ); }); diff --git a/packages/integration-tests/src/tests/api/resource.scope.test.ts b/packages/integration-tests/src/tests/api/resource.scope.test.ts index e17581c38..1d8214fec 100644 --- a/packages/integration-tests/src/tests/api/resource.scope.test.ts +++ b/packages/integration-tests/src/tests/api/resource.scope.test.ts @@ -9,7 +9,7 @@ describe('scopes', () => { it('should get management api resource scopes successfully', async () => { const scopes = await getScopes(defaultManagementApi.resource.id); - expect(scopes[0]).toMatchObject(defaultManagementApi.scope); + expect(scopes[0]).toMatchObject(expect.objectContaining(defaultManagementApi.scopes[0])); }); it('should create scope successfully', async () => { diff --git a/packages/schemas/alterations/next-1684837981-add-manage-tenant-self-scope-to-user-role.ts b/packages/schemas/alterations/next-1684837981-add-manage-tenant-self-scope-to-user-role.ts new file mode 100644 index 000000000..2969562dc --- /dev/null +++ b/packages/schemas/alterations/next-1684837981-add-manage-tenant-self-scope-to-user-role.ts @@ -0,0 +1,56 @@ +import { generateStandardId } from '@logto/shared/universal'; +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const adminTenantId = 'admin'; + +const alteration: AlterationScript = { + up: async (pool) => { + // Get `resourceId` of the admin tenant's resource whose indicator is `https://cloud.logto.io/api`. + const { id: resourceId } = await pool.one<{ id: string }>(sql` + select id from resources + where tenant_id = ${adminTenantId} + and indicator = 'https://cloud.logto.io/api' + `); + + // Get `roleId` of the admin tenant's role whose name is `user`. + const { id: roleId } = await pool.one<{ id: string }>(sql` + select id from roles + where tenant_id = ${adminTenantId} + and name = 'user'; + `); + + // Insert `manage:tenant:self` scope. + const scopeId = generateStandardId(); + await pool.query(sql` + insert into scopes (tenant_id, id, name, description, resource_id) + values ( + ${adminTenantId}, + ${scopeId}, + 'manage:tenant:self', + 'Allow managing tenant itself, including update and delete.', + ${resourceId} + ); + `); + // Assign `manage:tenant:self` scope to `user` role. + await pool.query(sql` + insert into roles_scopes (tenant_id, id, role_id, scope_id) + values ( + ${adminTenantId}, + ${generateStandardId()}, + ${roleId}, + ${scopeId} + ); + `); + }, + down: async (pool) => { + // Delete `manage:tenant:self` scope. + // No need to delete `roles_scopes` because it will be cascade deleted. + await pool.query(sql` + delete from scopes + where tenant_id = ${adminTenantId} and name = 'manage:tenant:self'; + `); + }, +}; +export default alteration; diff --git a/packages/schemas/src/seeds/cloud-api.ts b/packages/schemas/src/seeds/cloud-api.ts index c51d520e0..ead646c28 100644 --- a/packages/schemas/src/seeds/cloud-api.ts +++ b/packages/schemas/src/seeds/cloud-api.ts @@ -10,8 +10,12 @@ import { adminTenantId } from './tenant.js'; export const cloudApiIndicator = 'https://cloud.logto.io/api'; export enum CloudScope { + /** The user can create a user tenant. */ CreateTenant = 'create:tenant', + /** The user can perform arbitrary operations on any tenant. */ ManageTenant = 'manage:tenant', + /** The user can update or delete its own tenants. */ + ManageTenantSelf = 'manage:tenant:self', SendSms = 'send:sms', SendEmail = 'send:email', } @@ -34,7 +38,13 @@ export const createCloudApi = (): Readonly<[UpdateAdminData, ...CreateScope[]]> indicator: cloudApiIndicator, name: `Logto Cloud API`, }, - scope: buildScope(CloudScope.CreateTenant, 'Allow creating new tenants.'), + scopes: [ + buildScope(CloudScope.CreateTenant, 'Allow creating new tenants.'), + buildScope( + CloudScope.ManageTenantSelf, + 'Allow managing tenant itself, including update and delete.' + ), + ], role: { tenantId: adminTenantId, name: AdminTenantRole.User, diff --git a/packages/schemas/src/seeds/management-api.ts b/packages/schemas/src/seeds/management-api.ts index d952bb544..fab2b1ebf 100644 --- a/packages/schemas/src/seeds/management-api.ts +++ b/packages/schemas/src/seeds/management-api.ts @@ -7,7 +7,7 @@ import { adminTenantId, defaultTenantId } from './tenant.js'; export type AdminData = { resource: CreateResource; - scope: CreateScope; + scopes: CreateScope[]; role: CreateRole; }; @@ -35,15 +35,17 @@ export const defaultManagementApi = Object.freeze({ indicator: `https://${defaultTenantId}.logto.app/api`, name: 'Logto Management API', }, - scope: { - tenantId: defaultTenantId, - /** @deprecated You should not rely on this constant. Change to something else. */ - id: defaultScopeAllId, - name: PredefinedScope.All, - description: 'Default scope for Management API, allows all permissions.', - /** @deprecated You should not rely on this constant. Change to something else. */ - resourceId: defaultResourceId, - }, + scopes: [ + { + tenantId: defaultTenantId, + /** @deprecated You should not rely on this constant. Change to something else. */ + id: defaultScopeAllId, + name: PredefinedScope.All, + description: 'Default scope for Management API, allows all permissions.', + /** @deprecated You should not rely on this constant. Change to something else. */ + resourceId: defaultResourceId, + }, + ], role: { tenantId: defaultTenantId, /** @deprecated You should not rely on this constant. Change to something else. */ @@ -79,13 +81,15 @@ export const createAdminData = (tenantId: string): AdminData => { indicator: getManagementApiResourceIndicator(tenantId), name: `Logto Management API`, }, - scope: { - tenantId, - id: generateStandardId(), - name: PredefinedScope.All, - description: 'Default scope for Management API, allows all permissions.', - resourceId, - }, + scopes: [ + { + tenantId, + id: generateStandardId(), + name: PredefinedScope.All, + description: 'Default scope for Management API, allows all permissions.', + resourceId, + }, + ], role: { tenantId, id: generateStandardId(), @@ -106,13 +110,15 @@ export const createAdminDataInAdminTenant = (tenantId: string): AdminData => { indicator: getManagementApiResourceIndicator(tenantId), name: `Logto Management API for tenant ${tenantId}`, }, - scope: { - tenantId: adminTenantId, - id: generateStandardId(), - name: PredefinedScope.All, - description: 'Default scope for Management API, allows all permissions.', - resourceId, - }, + scopes: [ + { + tenantId: adminTenantId, + id: generateStandardId(), + name: PredefinedScope.All, + description: 'Default scope for Management API, allows all permissions.', + resourceId, + }, + ], role: { tenantId: adminTenantId, id: generateStandardId(), @@ -132,13 +138,15 @@ export const createMeApiInAdminTenant = (): AdminData => { indicator: getManagementApiResourceIndicator(adminTenantId, 'me'), name: `Logto Me API`, }, - scope: { - tenantId: adminTenantId, - id: generateStandardId(), - name: PredefinedScope.All, - description: 'Default scope for Me API, allows all permissions.', - resourceId, - }, + scopes: [ + { + tenantId: adminTenantId, + id: generateStandardId(), + name: PredefinedScope.All, + description: 'Default scope for Me API, allows all permissions.', + resourceId, + }, + ], role: { tenantId: adminTenantId, id: generateStandardId(),