diff --git a/packages/core/src/libraries/role-scope.ts b/packages/core/src/libraries/role-scope.ts new file mode 100644 index 000000000..7c29d9ce0 --- /dev/null +++ b/packages/core/src/libraries/role-scope.ts @@ -0,0 +1,64 @@ +import { isManagementApi, RoleType } from '@logto/schemas'; + +import RequestError from '#src/errors/RequestError/index.js'; +import type Queries from '#src/tenants/Queries.js'; +import assertThat from '#src/utils/assert-that.js'; + +export const createRoleScopeLibrary = (queries: Queries) => { + const { + roles: { findRoleById }, + scopes: { findScopeById }, + resources: { findResourceById }, + rolesScopes: { findRolesScopesByRoleId }, + } = queries; + + const validateRoleScopeAssignment = async ( + scopeIds: string[], + roleId: string, + options: { skipScopeExistenceCheck?: boolean } = {} + ) => { + // No need to validate if no scopes are being assigned. + if (scopeIds.length === 0) { + return; + } + + const { skipScopeExistenceCheck } = options; + const role = await findRoleById(roleId); + + // Make sure all scopes have not been assigned to the role. + // The check can be skipped if the role is newly created. + if (!skipScopeExistenceCheck) { + const rolesScopes = await findRolesScopesByRoleId(roleId); + + for (const scopeId of scopeIds) { + assertThat( + !rolesScopes.some(({ scopeId: _scopeId }) => _scopeId === scopeId), + new RequestError({ + code: 'role.scope_exists', + status: 422, + scopeId, + }) + ); + } + } + + await Promise.all( + scopeIds.map(async (scopeId) => { + // 1. Make sure the `scopeId` is valid. + const { resourceId } = await findScopeById(scopeId); + // 2. Make sure management API scopes can not be assigned to user roles. + if (role.type === RoleType.User) { + const { indicator } = await findResourceById(resourceId); + assertThat( + !isManagementApi(indicator), + 'role.management_api_scopes_not_assignable_to_user_role' + ); + } + }) + ); + }; + + return { + validateRoleScopeAssignment, + }; +}; diff --git a/packages/core/src/routes/role.scope.test.ts b/packages/core/src/routes/role.scope.test.ts index f51a117d5..3a73b375f 100644 --- a/packages/core/src/routes/role.scope.test.ts +++ b/packages/core/src/routes/role.scope.test.ts @@ -55,6 +55,11 @@ const users = { findUserById: jest.fn(), }; +const rolesScopesLibrary = { + validateRoleScopeAssignment: jest.fn(), +}; +const { validateRoleScopeAssignment } = rolesScopesLibrary; + const roleRoutes = await pickDefault(import('./role.scope.js')); const tenantContext = new MockTenant( @@ -69,6 +74,7 @@ const tenantContext = new MockTenant( undefined, { quota: createMockQuotaLibrary(), + roleScopes: rolesScopesLibrary, } ); @@ -94,13 +100,13 @@ describe('role scope routes', () => { }); it('POST /roles/:id/scopes', async () => { - findRoleById.mockResolvedValueOnce(mockAdminUserRole); findRolesScopesByRoleId.mockResolvedValue([]); findScopesByIds.mockResolvedValueOnce([]); const response = await roleRequester.post(`/roles/${mockAdminUserRole.id}/scopes`).send({ scopeIds: [mockScope.id], }); expect(response.status).toEqual(200); + expect(validateRoleScopeAssignment).toHaveBeenCalledWith([mockScope.id], mockAdminUserRole.id); expect(insertRolesScopes).toHaveBeenCalledWith([ { id: mockId, roleId: mockAdminUserRole.id, scopeId: mockScope.id }, ]); diff --git a/packages/core/src/routes/role.scope.ts b/packages/core/src/routes/role.scope.ts index 951c75e74..8cbafdf39 100644 --- a/packages/core/src/routes/role.scope.ts +++ b/packages/core/src/routes/role.scope.ts @@ -13,20 +13,18 @@ import { parseSearchParamsForSearch } from '#src/utils/search.js'; import type { AuthedRouter, RouterInitArgs } from './types.js'; export default function roleScopeRoutes( - ...[ - router, - { - queries, - libraries: { quota }, - }, - ]: RouterInitArgs + ...[router, { queries, libraries }]: RouterInitArgs ) { const { resources: { findResourcesByIds }, rolesScopes: { deleteRolesScope, findRolesScopesByRoleId, insertRolesScopes }, roles: { findRoleById }, - scopes: { findScopeById, findScopesByIds, countScopesByScopeIds, searchScopesByScopeIds }, + scopes: { findScopesByIds, countScopesByScopeIds, searchScopesByScopeIds }, } = queries; + const { + quota, + roleScopes: { validateRoleScopeAssignment }, + } = libraries; const attachResourceToScopes = async (scopes: readonly Scope[]): Promise => { const resources = await findResourcesByIds(scopes.map(({ resourceId }) => resourceId)); @@ -103,7 +101,7 @@ export default function roleScopeRoutes( params: object({ id: string().min(1) }), body: object({ scopeIds: string().min(1).array().nonempty() }), response: Scopes.guard.array(), - status: [200, 404, 422], + status: [200, 400, 404, 422], }), async (ctx, next) => { const { @@ -111,24 +109,9 @@ export default function roleScopeRoutes( body: { scopeIds }, } = ctx.guard; - await findRoleById(id); - await quota.guardKey('scopesPerRoleLimit', id); - const rolesScopes = await findRolesScopesByRoleId(id); - - for (const scopeId of scopeIds) { - assertThat( - !rolesScopes.some(({ scopeId: _scopeId }) => _scopeId === scopeId), - new RequestError({ - code: 'role.scope_exists', - status: 422, - scopeId, - }) - ); - } - - await Promise.all(scopeIds.map(async (scopeId) => findScopeById(scopeId))); + await validateRoleScopeAssignment(scopeIds, id); await insertRolesScopes( scopeIds.map((scopeId) => ({ id: generateStandardId(), roleId: id, scopeId })) ); diff --git a/packages/core/src/routes/role.test.ts b/packages/core/src/routes/role.test.ts index a19395dbc..933e6b77f 100644 --- a/packages/core/src/routes/role.test.ts +++ b/packages/core/src/routes/role.test.ts @@ -35,7 +35,6 @@ const { findRoleByRoleName, findRoleById, deleteRoleById } = roles; const scopes = { findScopeById: jest.fn(), }; -const { findScopeById } = scopes; const rolesScopes = { insertRolesScopes: jest.fn(), @@ -63,6 +62,11 @@ const applicationsRoles = { }; const { countApplicationsRolesByRoleId, findApplicationsRolesByRoleId } = applicationsRoles; +const rolesScopesLibrary = { + validateRoleScopeAssignment: jest.fn(), +}; +const { validateRoleScopeAssignment } = rolesScopesLibrary; + const roleRoutes = await pickDefault(import('./role.js')); const tenantContext = new MockTenant( @@ -77,7 +81,7 @@ const tenantContext = new MockTenant( applications, }, undefined, - { quota: createMockQuotaLibrary() } + { quota: createMockQuotaLibrary(), roleScopes: rolesScopesLibrary } ); describe('role routes', () => { @@ -127,7 +131,9 @@ describe('role routes', () => { expect(response.status).toEqual(200); expect(response.body).toEqual(mockAdminUserRole); expect(findRoleByRoleName).toHaveBeenCalled(); - expect(findScopeById).toHaveBeenCalledWith(mockScope.id); + expect(validateRoleScopeAssignment).toHaveBeenCalledWith([mockScope.id], response.body.id, { + skipScopeExistenceCheck: true, + }); expect(insertRolesScopes).toHaveBeenCalled(); }); diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index c684409f9..a837e903b 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -16,10 +16,7 @@ import roleUserRoutes from './role.user.js'; import type { AuthedRouter, RouterInitArgs } from './types.js'; export default function roleRoutes(...[router, tenant]: RouterInitArgs) { - const { - queries, - libraries: { quota }, - } = tenant; + const { queries, libraries } = tenant; const { rolesScopes: { insertRolesScopes }, roles: { @@ -31,7 +28,6 @@ export default function roleRoutes(...[router, tenant]: insertRole, updateRoleById, }, - scopes: { findScopeById }, users: { findUsersByIds }, usersRoles: { countUsersRolesByRoleId, findUsersRolesByRoleId, findUsersRolesByUserId }, applications: { findApplicationsByIds }, @@ -41,6 +37,10 @@ export default function roleRoutes(...[router, tenant]: findApplicationsRolesByApplicationId, }, } = queries; + const { + quota, + roleScopes: { validateRoleScopeAssignment }, + } = libraries; router.use('/roles(/.*)?', koaRoleRlsErrorHandler()); @@ -136,7 +136,7 @@ export default function roleRoutes(...[router, tenant]: body: Roles.createGuard .omit({ id: true }) .extend({ scopeIds: z.string().min(1).array().optional() }), - status: [200, 422], + status: [200, 400, 404, 422], // Throws 404 when invalid `scopeId(s)` are provided. response: Roles.guard, }), async (ctx, next) => { @@ -165,7 +165,8 @@ export default function roleRoutes(...[router, tenant]: }); if (scopeIds) { - await Promise.all(scopeIds.map(async (scopeId) => findScopeById(scopeId))); + // Skip scope existence check because the role is newly created. + await validateRoleScopeAssignment(scopeIds, role.id, { skipScopeExistenceCheck: true }); await insertRolesScopes( scopeIds.map((scopeId) => ({ id: generateStandardId(), roleId: role.id, scopeId })) ); diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts index 7030b97e4..b14f68d4e 100644 --- a/packages/core/src/tenants/Libraries.ts +++ b/packages/core/src/tenants/Libraries.ts @@ -8,6 +8,7 @@ import { createPasscodeLibrary } from '#src/libraries/passcode.js'; import { createPhraseLibrary } from '#src/libraries/phrase.js'; import { createProtectedAppLibrary } from '#src/libraries/protected-app.js'; import { createQuotaLibrary } from '#src/libraries/quota.js'; +import { createRoleScopeLibrary } from '#src/libraries/role-scope.js'; import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js'; import { createSocialLibrary } from '#src/libraries/social.js'; import { createSsoConnectorLibrary } from '#src/libraries/sso-connector.js'; @@ -24,6 +25,7 @@ export default class Libraries { passcodes = createPasscodeLibrary(this.queries, this.connectors); applications = createApplicationLibrary(this.queries); verificationStatuses = createVerificationStatusLibrary(this.queries); + roleScopes = createRoleScopeLibrary(this.queries); domains = createDomainLibrary(this.queries); protectedApps = createProtectedAppLibrary(this.queries); quota = createQuotaLibrary(this.queries, this.cloudConnection, this.connectors); 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 971774c9c..7bd2a9569 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 @@ -1,12 +1,13 @@ import path from 'node:path'; import { fetchTokenByRefreshToken } from '@logto/js'; -import { defaultManagementApi, InteractionEvent, RoleType } from '@logto/schemas'; +import { InteractionEvent, type Resource, RoleType } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import fetch from 'node-fetch'; -import { putInteraction } from '#src/api/index.js'; +import { createResource, putInteraction } from '#src/api/index.js'; import { assignUsersToRole, createRole } from '#src/api/role.js'; +import { createScope } from '#src/api/scope.js'; import MockClient, { defaultConfig } from '#src/client/index.js'; import { logtoUrl } from '#src/constants.js'; import { processSession } from '#src/helpers/client.js'; @@ -18,24 +19,35 @@ describe('get access token', () => { const username = generateUsername(); const password = generatePassword(); const guestUsername = generateUsername(); + const testApiResourceInfo: Pick = { + name: 'test-api-resource', + indicator: 'https://foo.logto.io/api', + }; + const testApiScopeNames = ['read', 'write', 'delete', 'update']; beforeAll(async () => { await createUserByAdmin(guestUsername, password); const user = await createUserByAdmin(username, password); - const { scopes } = defaultManagementApi; - const defaultManagementApiUserRole = await createRole({ - name: 'management-api-user-role', + const testApiResource = await createResource( + testApiResourceInfo.name, + testApiResourceInfo.indicator + ); + const testApiScopes = await Promise.all( + testApiScopeNames.map(async (name) => createScope(testApiResource.id, name)) + ); + const testApiUserRole = await createRole({ + name: 'test-api-user-role', type: RoleType.User, - scopeIds: scopes.map(({ id }) => id), + scopeIds: testApiScopes.map(({ id }) => id), }); - await assignUsersToRole([user.id], defaultManagementApiUserRole.id); + await assignUsersToRole([user.id], testApiUserRole.id); await enableAllPasswordSignInMethods(); }); it('can sign in and getAccessToken with admin user', async () => { const client = new MockClient({ - resources: [defaultManagementApi.resource.indicator], - scopes: defaultManagementApi.scopes.map(({ name }) => name), + resources: [testApiResourceInfo.indicator], + scopes: testApiScopeNames, }); await client.initSession(); await client.successSend(putInteraction, { @@ -44,12 +56,9 @@ describe('get access token', () => { }); const { redirectTo } = await client.submitInteraction(); await processSession(client, redirectTo); - const accessToken = await client.getAccessToken(defaultManagementApi.resource.indicator); + const accessToken = await client.getAccessToken(testApiResourceInfo.indicator); expect(accessToken).not.toBeNull(); - expect(getAccessTokenPayload(accessToken)).toHaveProperty( - 'scope', - defaultManagementApi.scopes.map(({ name }) => name).join(' ') - ); + expect(getAccessTokenPayload(accessToken)).toHaveProperty('scope', testApiScopeNames.join(' ')); // Request for invalid resource should throw void expect(client.getAccessToken('api.foo.com')).rejects.toThrow(); @@ -57,8 +66,8 @@ describe('get access token', () => { it('can sign in and getAccessToken with guest user', async () => { const client = new MockClient({ - resources: [defaultManagementApi.resource.indicator], - scopes: defaultManagementApi.scopes.map(({ name }) => name), + resources: [testApiResourceInfo.indicator], + scopes: testApiScopeNames, }); await client.initSession(); await client.successSend(putInteraction, { @@ -67,16 +76,16 @@ describe('get access token', () => { }); const { redirectTo } = await client.submitInteraction(); await processSession(client, redirectTo); - const accessToken = await client.getAccessToken(defaultManagementApi.resource.indicator); + const accessToken = await client.getAccessToken(testApiResourceInfo.indicator); expect(getAccessTokenPayload(accessToken)).not.toHaveProperty( 'scope', - defaultManagementApi.scopes.map(({ name }) => name).join(' ') + testApiScopeNames.join(' ') ); }); it('can sign in and get multiple Access Tokens by the same Refresh Token within refreshTokenReuseInterval', async () => { - const client = new MockClient({ resources: [defaultManagementApi.resource.indicator] }); + const client = new MockClient({ resources: [testApiResourceInfo.indicator] }); await client.initSession(); @@ -99,7 +108,7 @@ describe('get access token', () => { clientId: defaultConfig.appId, tokenEndpoint: path.join(logtoUrl, '/oidc/token'), refreshToken, - resource: defaultManagementApi.resource.indicator, + resource: testApiResourceInfo.indicator, }, async (...args: Parameters): Promise => { const response = await fetch(...args); diff --git a/packages/integration-tests/src/tests/api/role.scope.test.ts b/packages/integration-tests/src/tests/api/role.scope.test.ts index 2491cfe3d..c154ff17b 100644 --- a/packages/integration-tests/src/tests/api/role.scope.test.ts +++ b/packages/integration-tests/src/tests/api/role.scope.test.ts @@ -84,6 +84,16 @@ describe('roles scopes', () => { expect(response instanceof HTTPError && response.response.statusCode).toBe(422); }); + it('should fail if try to assign management API scope(s) to user role', async () => { + // Create `RoleType.User` role by default if `type` is not specified. + const userRole = await createRole({}); + const response = await assignScopesToRole( + [defaultManagementApi.scopes[0]!.id], + userRole.id + ).catch((error: unknown) => error); + expect(response instanceof HTTPError && response.response.statusCode).toBe(400); + }); + it('should remove scope from role successfully', async () => { const role = await createRole({}); const resource = await createResource(); diff --git a/packages/integration-tests/src/tests/api/role.test.ts b/packages/integration-tests/src/tests/api/role.test.ts index bd7a401c9..0526866e6 100644 --- a/packages/integration-tests/src/tests/api/role.test.ts +++ b/packages/integration-tests/src/tests/api/role.test.ts @@ -1,3 +1,4 @@ +import { defaultManagementApi } from '@logto/schemas'; import { HTTPError } from 'got'; import { createResource } from '#src/api/resource.js'; @@ -57,6 +58,14 @@ describe('roles', () => { expect(response instanceof HTTPError && response.response.statusCode).toBe(403); }); + it('should fail when try to create role with management API scope(s)', async () => { + const response = await createRole({ scopeIds: [defaultManagementApi.scopes[0]!.id] }).catch( + (error: unknown) => error + ); + + expect(response instanceof HTTPError && response.response.statusCode).toBe(400); + }); + it('should get role detail successfully', async () => { const createdRole = await createRole({}); const role = await getRole(createdRole.id); diff --git a/packages/phrases/src/locales/de/errors/role.ts b/packages/phrases/src/locales/de/errors/role.ts index 2755aedb5..a6bb3af3c 100644 --- a/packages/phrases/src/locales/de/errors/role.ts +++ b/packages/phrases/src/locales/de/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: 'Dieser Rollenname {{name}} wird bereits verwendet.', scope_exists: 'Die Scope-ID {{scopeId}} wurde bereits zu dieser Rolle hinzugefügt.', + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: 'Die Benutzer-ID {{userId}} wurde bereits zu dieser Rolle hinzugefügt.', application_exists: 'Die Anwendungs-ID {{applicationId}} wurde bereits zu dieser Rolle hinzugefügt.', diff --git a/packages/phrases/src/locales/en/errors/role.ts b/packages/phrases/src/locales/en/errors/role.ts index b3a9da651..947069839 100644 --- a/packages/phrases/src/locales/en/errors/role.ts +++ b/packages/phrases/src/locales/en/errors/role.ts @@ -1,6 +1,8 @@ const role = { name_in_use: 'This role name {{name}} is already in use', scope_exists: 'The scope id {{scopeId}} has already been added to this role', + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: 'The user id {{userId}} is already been added to this role', application_exists: 'The application id {{applicationId}} is already been added to this role', default_role_missing: diff --git a/packages/phrases/src/locales/es/errors/role.ts b/packages/phrases/src/locales/es/errors/role.ts index 8426be374..8bb37d408 100644 --- a/packages/phrases/src/locales/es/errors/role.ts +++ b/packages/phrases/src/locales/es/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: 'Este nombre de rol {{name}} ya está en uso', scope_exists: 'El id de alcance {{scopeId}} ya ha sido agregado a este rol', + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: 'El id de usuario {{userId}} ya ha sido agregado a este rol', application_exists: 'El id de aplicación {{applicationId}} ya ha sido agregado a este rol', default_role_missing: diff --git a/packages/phrases/src/locales/fr/errors/role.ts b/packages/phrases/src/locales/fr/errors/role.ts index 7def0d02a..9e2ae01de 100644 --- a/packages/phrases/src/locales/fr/errors/role.ts +++ b/packages/phrases/src/locales/fr/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: 'Ce nom de rôle {{name}} est déjà utilisé', scope_exists: "L'identifiant de portée {{scopeId}} a déjà été ajouté à ce rôle", + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: "L'identifiant d'utilisateur {{userId}} a déjà été ajouté à ce rôle", application_exists: "L'identifiant d'application {{applicationId}} a déjà été ajouté à ce rôle", default_role_missing: diff --git a/packages/phrases/src/locales/it/errors/role.ts b/packages/phrases/src/locales/it/errors/role.ts index e5c0bcbb5..c9360485f 100644 --- a/packages/phrases/src/locales/it/errors/role.ts +++ b/packages/phrases/src/locales/it/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: 'Il nome di ruolo {{name}} è già in uso', scope_exists: "L'identificatore di ambito {{scopeId}} è già stato aggiunto a questo ruolo", + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: "L'identificatore di utente {{userId}} è già stato aggiunto a questo ruolo", application_exists: "L'ID dell'applicazione {{applicationId}} è già stato aggiunto a questo ruolo", diff --git a/packages/phrases/src/locales/ja/errors/role.ts b/packages/phrases/src/locales/ja/errors/role.ts index 582cc68a0..1819b4565 100644 --- a/packages/phrases/src/locales/ja/errors/role.ts +++ b/packages/phrases/src/locales/ja/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: 'このロール名{{name}}はすでに使用されています', scope_exists: 'スコープID {{scopeId}}はすでにこのロールに追加されています', + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: 'ユーザーID{{userId}}はすでにこのロールに追加されています', application_exists: 'アプリケーション ID {{applicationId}} はすでにこのロールに追加されています', default_role_missing: diff --git a/packages/phrases/src/locales/ko/errors/role.ts b/packages/phrases/src/locales/ko/errors/role.ts index f83d50df7..ec3d9bbaa 100644 --- a/packages/phrases/src/locales/ko/errors/role.ts +++ b/packages/phrases/src/locales/ko/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: '역할 이름 {{name}}이/가 이미 사용 중이에요.', scope_exists: '범위 ID {{scopeId}}이/가 이미 이 역할에 추가되어 있어요.', + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: '사용자 ID {{userId}}이/가 이미 이 역할에 추가되어 있어요.', application_exists: '애플리케이션 ID {{applicationId}} 가 이미 이 역할에 추가되어 있어요.', default_role_missing: diff --git a/packages/phrases/src/locales/pl-pl/errors/role.ts b/packages/phrases/src/locales/pl-pl/errors/role.ts index 8179b2de2..7951a0e59 100644 --- a/packages/phrases/src/locales/pl-pl/errors/role.ts +++ b/packages/phrases/src/locales/pl-pl/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: 'Ta nazwa roli {{name}} jest już w użyciu', scope_exists: 'Identyfikator zakresu {{scopeId}} został już dodany do tej roli', + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: 'Identyfikator użytkownika {{userId}} został już dodany do tej roli', application_exists: 'Identyfikator aplikacji {{applicationId}} został już dodany do tej roli', default_role_missing: diff --git a/packages/phrases/src/locales/pt-br/errors/role.ts b/packages/phrases/src/locales/pt-br/errors/role.ts index 48009dbb0..6d2879c62 100644 --- a/packages/phrases/src/locales/pt-br/errors/role.ts +++ b/packages/phrases/src/locales/pt-br/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: 'Este nome de papel {{name}} já está em uso', scope_exists: 'O id de escopo {{scopeId}} já foi adicionado a este papel', + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: 'O id de usuário {{userId}} já foi adicionado a este papel', application_exists: 'O id do aplicativo {{applicationId}} já foi adicionado a este papel', default_role_missing: diff --git a/packages/phrases/src/locales/pt-pt/errors/role.ts b/packages/phrases/src/locales/pt-pt/errors/role.ts index 2c5511aee..5d93c8947 100644 --- a/packages/phrases/src/locales/pt-pt/errors/role.ts +++ b/packages/phrases/src/locales/pt-pt/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: 'Este nome de função {{name}} já está em uso', scope_exists: 'O id do escopo {{scopeId}} já foi adicionado a esta função', + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: 'O id do usuário {{userId}} já foi adicionado a esta função', application_exists: 'O id de aplicação {{applicationId}} já foi adicionado a esta função', default_role_missing: diff --git a/packages/phrases/src/locales/ru/errors/role.ts b/packages/phrases/src/locales/ru/errors/role.ts index e91c92cc9..c859f6fc5 100644 --- a/packages/phrases/src/locales/ru/errors/role.ts +++ b/packages/phrases/src/locales/ru/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: 'Это имя роли {{name}} уже используется', scope_exists: 'Идентификатор области действия {{scopeId}} уже был добавлен в эту роль', + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: 'Идентификатор пользователя {{userId}} уже был добавлен в эту роль', application_exists: 'Идентификатор приложения {{applicationId}} уже был добавлен в эту роль', default_role_missing: diff --git a/packages/phrases/src/locales/tr-tr/errors/role.ts b/packages/phrases/src/locales/tr-tr/errors/role.ts index 16c628d16..c7f7c1fb9 100644 --- a/packages/phrases/src/locales/tr-tr/errors/role.ts +++ b/packages/phrases/src/locales/tr-tr/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: 'Bu rol adı {{name}} zaten kullanımda', scope_exists: 'Bu kapsam kimliği {{scopeId}} zaten bu role eklendi', + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: 'Bu kullanıcı kimliği {{userId}} zaten bu role eklendi', application_exists: 'Bu uygulama kimliği {{applicationId}} zaten bu role eklendi', default_role_missing: diff --git a/packages/phrases/src/locales/zh-cn/errors/role.ts b/packages/phrases/src/locales/zh-cn/errors/role.ts index 4d7007e9e..efccda959 100644 --- a/packages/phrases/src/locales/zh-cn/errors/role.ts +++ b/packages/phrases/src/locales/zh-cn/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: '此角色名称 {{name}} 已被使用', scope_exists: '作用域 ID {{scopeId}} 已添加到此角色', + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: '用户 ID {{userId}} 已添加到此角色', application_exists: '应用程序 ID {{applicationId}} 已添加到此角色', default_role_missing: '某些默认角色名称在数据库中不存在,请确保先创建角色', diff --git a/packages/phrases/src/locales/zh-hk/errors/role.ts b/packages/phrases/src/locales/zh-hk/errors/role.ts index f048b9db7..cdc50d324 100644 --- a/packages/phrases/src/locales/zh-hk/errors/role.ts +++ b/packages/phrases/src/locales/zh-hk/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: '此角色名稱 {{name}} 已被使用', scope_exists: '作用域 ID {{scopeId}} 已添加到此角色', + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: '用戶 ID {{userId}} 已添加到此角色', application_exists: '應用程式 ID {{applicationId}} 已添加到此角色', default_role_missing: '某些默認角色名稱在數據庫中不存在,請確保先創建角色', diff --git a/packages/phrases/src/locales/zh-tw/errors/role.ts b/packages/phrases/src/locales/zh-tw/errors/role.ts index b96236408..49c84e0a4 100644 --- a/packages/phrases/src/locales/zh-tw/errors/role.ts +++ b/packages/phrases/src/locales/zh-tw/errors/role.ts @@ -1,6 +1,9 @@ const role = { name_in_use: '此角色名稱 {{name}} 已被使用', scope_exists: '作用域 ID {{scopeId}} 已添加到此角色', + /** UNTRANSLATED */ + management_api_scopes_not_assignable_to_user_role: + 'Cannot assign management API scopes to a user role.', user_exists: '用戶 ID {{userId}} 已添加到此角色', application_exists: '已經將應用程式 ID {{applicationId}} 添加到此角色', default_role_missing: '某些預設角色名稱在資料庫中不存在,請確保先創建角色', diff --git a/packages/schemas/alterations/next-1708916601-remove-management-api-scopes-assigned-to-user-role.ts b/packages/schemas/alterations/next-1708916601-remove-management-api-scopes-assigned-to-user-role.ts new file mode 100644 index 000000000..2794a15b8 --- /dev/null +++ b/packages/schemas/alterations/next-1708916601-remove-management-api-scopes-assigned-to-user-role.ts @@ -0,0 +1,47 @@ +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +enum RoleType { + User = 'User', +} + +const getManagementApiResourceIndicator = (tenantId: string) => `https://${tenantId}.logto.app/api`; + +// Remove management API scopes assigned to user roles, in case they were assigned by management API and bypassed the constraints in admin console. +const alteration: AlterationScript = { + up: async (pool) => { + const { rows } = await pool.query<{ + rolesScopesId: string; + indicator: string; + tenantId: string; + }>(sql` + select + roles_scopes.id as "rolesScopesId", + roles_scopes.tenant_id as "tenantId", + resources.indicator as indicator from roles_scopes + join roles + on roles_scopes.role_id = roles.id and roles_scopes.tenant_id = roles.tenant_id + join scopes on + roles_scopes.scope_id = scopes.id and roles_scopes.tenant_id = scopes.tenant_id + join resources on + scopes.resource_id = resources.id and scopes.tenant_id = resources.tenant_id + where roles.type = ${RoleType.User}; + `); + const rolesScopesIdsToRemove = rows + .filter( + ({ indicator, tenantId }) => indicator === getManagementApiResourceIndicator(tenantId) + ) + .map(({ rolesScopesId }) => rolesScopesId); + if (rolesScopesIdsToRemove.length > 0) { + await pool.query(sql` + delete from roles_scopes where id in (${sql.join(rolesScopesIdsToRemove, sql`, `)}); + `); + } + }, + down: async (pool) => { + // It cannot be reverted automatically. + }, +}; + +export default alteration;