mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
Merge pull request #3333 from logto-io/gao-log-5603-hide-role-for-user-tenant
refactor: hide internal roles for user tenants
This commit is contained in:
commit
310c543f93
40 changed files with 288 additions and 88 deletions
|
@ -57,6 +57,7 @@
|
|||
"ora": "^6.1.2",
|
||||
"p-limit": "^4.0.0",
|
||||
"p-retry": "^5.1.2",
|
||||
"pg-protocol": "^1.6.0",
|
||||
"roarr": "^7.11.0",
|
||||
"semver": "^7.3.8",
|
||||
"slonik": "^30.0.0",
|
||||
|
|
|
@ -2,9 +2,9 @@ import type { SchemaLike } from '@logto/schemas';
|
|||
import { convertToPrimitiveOrSql } from '@logto/shared';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import decamelize from 'decamelize';
|
||||
import { DatabaseError } from 'pg-protocol';
|
||||
import { createPool, parseDsn, sql, stringifyDsn } from 'slonik';
|
||||
import { createInterceptors } from 'slonik-interceptor-preset';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ConfigKey, getCliConfigWithPrompt, log } from './utils.js';
|
||||
|
||||
|
@ -36,11 +36,9 @@ export const createPoolAndDatabaseIfNeeded = async () => {
|
|||
try {
|
||||
return await createPoolFromConfig();
|
||||
} catch (error: unknown) {
|
||||
const result = z.object({ code: z.string() }).safeParse(error);
|
||||
|
||||
// Database does not exist, try to create one
|
||||
// https://www.postgresql.org/docs/14/errcodes-appendix.html
|
||||
if (!(result.success && result.data.code === '3D000')) {
|
||||
if (!(error instanceof DatabaseError && error.code === '3D000')) {
|
||||
log.error(error);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { AlterationStateKey, Systems } from '@logto/schemas';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import { DatabaseError } from 'pg-protocol';
|
||||
import { createMockPool, createMockQueryResult, sql } from 'slonik';
|
||||
|
||||
import type { QueryType } from '../test-utils.js';
|
||||
|
@ -21,7 +22,11 @@ const systemsTableExists = async () => createMockQueryResult([{ regclass: true }
|
|||
|
||||
describe('getCurrentDatabaseAlterationTimestamp()', () => {
|
||||
it('returns 0 if query failed (table not found)', async () => {
|
||||
mockQuery.mockImplementationOnce(systemsTableExists).mockRejectedValueOnce({ code: '42P01' });
|
||||
const error = new DatabaseError('test', 0, 'noData');
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
error.code = '42P01';
|
||||
|
||||
mockQuery.mockImplementationOnce(systemsTableExists).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(getCurrentDatabaseAlterationTimestamp(pool)).resolves.toBe(0);
|
||||
});
|
||||
|
|
|
@ -2,9 +2,10 @@ import type { AlterationState, System, SystemKey } from '@logto/schemas';
|
|||
import { systemGuards, Systems, AlterationStateKey } from '@logto/schemas';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import type { Nullable } from '@silverhand/essentials';
|
||||
import { DatabaseError } from 'pg-protocol';
|
||||
import type { CommonQueryMethods, DatabaseTransactionConnection } from 'slonik';
|
||||
import { sql } from 'slonik';
|
||||
import { z } from 'zod';
|
||||
import type { z } from 'zod';
|
||||
|
||||
const { fields, table } = convertToIdentifiers(Systems);
|
||||
|
||||
|
@ -37,11 +38,9 @@ export const getCurrentDatabaseAlterationTimestamp = async (pool: CommonQueryMet
|
|||
|
||||
return (parsed.success && parsed.data.timestamp) || 0;
|
||||
} catch (error: unknown) {
|
||||
const result = z.object({ code: z.string() }).safeParse(error);
|
||||
|
||||
// Relation does not exist, treat as 0
|
||||
// https://www.postgresql.org/docs/14/errcodes-appendix.html
|
||||
if (result.success && result.data.code === '42P01') {
|
||||
if (error instanceof DatabaseError && error.code === '42P01') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import type { Role, ScopeResponse } from '@logto/schemas';
|
||||
import { internalRolePrefix } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -70,9 +71,15 @@ const CreateRoleForm = ({ onClose }: Props) => {
|
|||
<TextInput
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
{...register('name', { required: true })}
|
||||
{...register('name', {
|
||||
required: true,
|
||||
validate: (name) =>
|
||||
name.startsWith(internalRolePrefix)
|
||||
? t('errors.create_internal_role_violation')
|
||||
: true,
|
||||
})}
|
||||
placeholder={t('roles.role_name_placeholder')}
|
||||
hasError={Boolean(errors.name)}
|
||||
errorMessage={errors.name?.message}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField isRequired title="roles.role_description">
|
||||
|
|
|
@ -65,6 +65,7 @@
|
|||
"nanoid": "^4.0.0",
|
||||
"oidc-provider": "^8.0.0",
|
||||
"p-retry": "^5.1.2",
|
||||
"pg-protocol": "^1.6.0",
|
||||
"roarr": "^7.11.0",
|
||||
"semver": "^7.3.8",
|
||||
"slonik": "^30.0.0",
|
||||
|
|
32
packages/core/src/middleware/koa-role-rls-error-handler.ts
Normal file
32
packages/core/src/middleware/koa-role-rls-error-handler.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import type { MiddlewareType } from 'koa';
|
||||
import { DatabaseError } from 'pg-protocol';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
|
||||
/**
|
||||
* Provide a user-friendly error message for Row Level Security errors.
|
||||
* The message is dedicated for providing a hint of internal roles update violation.
|
||||
*
|
||||
* Since it's hard to detect the specific violated policy, this function should be ONLY used
|
||||
* for roles routes.
|
||||
*/
|
||||
export default function koaRoleRlsErrorHandler<StateT, ContextT, ResponseBodyT>(): MiddlewareType<
|
||||
StateT,
|
||||
ContextT,
|
||||
ResponseBodyT
|
||||
> {
|
||||
return async (_, next) => {
|
||||
try {
|
||||
await next();
|
||||
} catch (error: unknown) {
|
||||
// https://www.postgresql.org/docs/14/errcodes-appendix.html
|
||||
if (error instanceof DatabaseError && error.code === '42501') {
|
||||
throw new RequestError(
|
||||
{ code: 'role.internal_role_violation', status: 403 },
|
||||
{ original: error }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,18 +1,21 @@
|
|||
import type { ApplicationsRole, CreateApplicationsRole } from '@logto/schemas';
|
||||
import { ApplicationsRoles, RolesScopes } from '@logto/schemas';
|
||||
import type { ApplicationsRole, CreateApplicationsRole, Role } from '@logto/schemas';
|
||||
import { Roles, ApplicationsRoles, RolesScopes } from '@logto/schemas';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import type { CommonQueryMethods } from 'slonik';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||
|
||||
const { table, fields } = convertToIdentifiers(ApplicationsRoles);
|
||||
const { table, fields } = convertToIdentifiers(ApplicationsRoles, true);
|
||||
|
||||
export const createApplicationsRolesQueries = (pool: CommonQueryMethods) => {
|
||||
const findApplicationsRolesByApplicationId = async (applicationId: string) =>
|
||||
pool.any<ApplicationsRole>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`,`)}
|
||||
pool.any<ApplicationsRole & { role: Role }>(sql`
|
||||
select
|
||||
${sql.join(Object.values(fields), sql`,`)},
|
||||
to_jsonb(${sql.identifier([Roles.table])}) as role
|
||||
from ${table}
|
||||
join roles on ${sql.identifier([Roles.table, Roles.fields.id])} = ${fields.roleId}
|
||||
where ${fields.applicationId}=${applicationId}
|
||||
`);
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { CreateRole, Role } from '@logto/schemas';
|
||||
import { defaultManagementApi, SearchJointMode, Roles } from '@logto/schemas';
|
||||
import { internalRolePrefix, SearchJointMode, Roles } from '@logto/schemas';
|
||||
import type { OmitAutoSetFields } from '@logto/shared';
|
||||
import { conditionalArraySql, conditionalSql, convertToIdentifiers } from '@logto/shared';
|
||||
import type { CommonQueryMethods } from 'slonik';
|
||||
|
@ -34,7 +34,7 @@ export const createRolesQueries = (pool: CommonQueryMethods) => {
|
|||
pool.one<{ count: number }>(sql`
|
||||
select count(*)
|
||||
from ${table}
|
||||
where ${fields.id}<>${defaultManagementApi.role.id}
|
||||
where (not starts_with(${fields.name}, ${internalRolePrefix}))
|
||||
${conditionalArraySql(
|
||||
excludeRoleIds,
|
||||
(value) => sql`and ${fields.id} not in (${sql.join(value, sql`, `)})`
|
||||
|
@ -57,7 +57,7 @@ export const createRolesQueries = (pool: CommonQueryMethods) => {
|
|||
sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.id}<>${defaultManagementApi.role.id}
|
||||
where (not starts_with(${fields.name}, ${internalRolePrefix}))
|
||||
${conditionalArraySql(
|
||||
excludeRoleIds,
|
||||
(value) => sql`and ${fields.id} not in (${sql.join(value, sql`, `)})`
|
||||
|
|
|
@ -5,6 +5,7 @@ import { object, string } from 'zod';
|
|||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import koaPagination from '#src/middleware/koa-pagination.js';
|
||||
import koaRoleRlsErrorHandler from '#src/middleware/koa-role-rls-error-handler.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { parseSearchParamsForSearch } from '#src/utils/search.js';
|
||||
|
||||
|
@ -19,6 +20,8 @@ export default function adminUserRoleRoutes<T extends AuthedRouter>(
|
|||
usersRoles: { deleteUsersRolesByUserIdAndRoleId, findUsersRolesByUserId, insertUsersRoles },
|
||||
} = queries;
|
||||
|
||||
router.use('/users/:userId/roles(/.*)?', koaRoleRlsErrorHandler());
|
||||
|
||||
router.get(
|
||||
'/users/:userId/roles',
|
||||
koaPagination(),
|
||||
|
|
|
@ -1,19 +1,24 @@
|
|||
import { generateStandardId, buildIdGenerator } from '@logto/core-kit';
|
||||
import type { Role } from '@logto/schemas';
|
||||
import {
|
||||
defaultManagementApi,
|
||||
Applications,
|
||||
demoAppApplicationId,
|
||||
buildDemoAppDataForTenant,
|
||||
Applications,
|
||||
InternalRole,
|
||||
} from '@logto/schemas';
|
||||
import { boolean, object, string, z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import koaPagination from '#src/middleware/koa-pagination.js';
|
||||
import { buildOidcClientMetadata } from '#src/oidc/utils.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type { AuthedRouter, RouterInitArgs } from './types.js';
|
||||
|
||||
const applicationId = buildIdGenerator(21);
|
||||
const includesInternalAdminRole = (roles: Readonly<Array<{ role: Role }>>) =>
|
||||
roles.some(({ role: { name } }) => name === InternalRole.Admin);
|
||||
|
||||
export default function applicationRoutes<T extends AuthedRouter>(
|
||||
...[router, { queries, id: tenantId }]: RouterInitArgs<T>
|
||||
|
@ -28,6 +33,7 @@ export default function applicationRoutes<T extends AuthedRouter>(
|
|||
} = queries.applications;
|
||||
const { findApplicationsRolesByApplicationId, insertApplicationsRoles, deleteApplicationRole } =
|
||||
queries.applicationsRoles;
|
||||
const { findRoleByRoleName } = queries.roles;
|
||||
|
||||
router.get('/applications', koaPagination(), async (ctx, next) => {
|
||||
const { limit, offset } = ctx.pagination;
|
||||
|
@ -89,7 +95,7 @@ export default function applicationRoutes<T extends AuthedRouter>(
|
|||
|
||||
ctx.body = {
|
||||
...application,
|
||||
isAdmin: applicationsRoles.some(({ roleId }) => roleId === defaultManagementApi.role.id),
|
||||
isAdmin: includesInternalAdminRole(applicationsRoles),
|
||||
};
|
||||
|
||||
return next();
|
||||
|
@ -117,19 +123,27 @@ export default function applicationRoutes<T extends AuthedRouter>(
|
|||
|
||||
const { isAdmin, ...rest } = body;
|
||||
|
||||
// FIXME @sijie temp solution to set admin access to machine to machine app
|
||||
// User can enable the admin access of Machine-to-Machine apps by switching on a toggle on Admin Console.
|
||||
// Since those apps sit in the user tenant, we provide an internal role to apply the necessary scopes.
|
||||
// This role is NOT intended for user assignment.
|
||||
if (isAdmin !== undefined) {
|
||||
const applicationsRoles = await findApplicationsRolesByApplicationId(id);
|
||||
const originalIsAdmin = applicationsRoles.some(
|
||||
({ roleId }) => roleId === defaultManagementApi.role.id
|
||||
const [applicationsRoles, internalAdminRole] = await Promise.all([
|
||||
findApplicationsRolesByApplicationId(id),
|
||||
findRoleByRoleName(InternalRole.Admin),
|
||||
]);
|
||||
const usedToBeAdmin = includesInternalAdminRole(applicationsRoles);
|
||||
|
||||
assertThat(
|
||||
internalAdminRole,
|
||||
new RequestError('entity.not_exists', { name: InternalRole.Admin })
|
||||
);
|
||||
|
||||
if (isAdmin && !originalIsAdmin) {
|
||||
if (isAdmin && !usedToBeAdmin) {
|
||||
await insertApplicationsRoles([
|
||||
{ id: generateStandardId(), applicationId: id, roleId: defaultManagementApi.role.id },
|
||||
{ id: generateStandardId(), applicationId: id, roleId: internalAdminRole.id },
|
||||
]);
|
||||
} else if (!isAdmin && originalIsAdmin) {
|
||||
await deleteApplicationRole(id, defaultManagementApi.role.id);
|
||||
} else if (!isAdmin && usedToBeAdmin) {
|
||||
await deleteApplicationRole(id, internalAdminRole.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { User, Profile } from '@logto/schemas';
|
||||
import {
|
||||
AdminTenantRole,
|
||||
SignInMode,
|
||||
UserRole,
|
||||
getManagementApiAdminName,
|
||||
defaultTenantId,
|
||||
adminTenantId,
|
||||
|
@ -180,7 +180,7 @@ export default async function submitInteraction(
|
|||
...upsertProfile,
|
||||
},
|
||||
conditionalArray<string>(
|
||||
isInAdminTenant && UserRole.User,
|
||||
isInAdminTenant && AdminTenantRole.User,
|
||||
isCreatingFirstAdminUser && getManagementApiAdminName(defaultTenantId),
|
||||
isCreatingFirstAdminUser && isCloud && getManagementApiAdminName(adminTenantId)
|
||||
)
|
||||
|
|
|
@ -8,6 +8,7 @@ import { object, string, z } from 'zod';
|
|||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import koaPagination from '#src/middleware/koa-pagination.js';
|
||||
import koaRoleRlsErrorHandler from '#src/middleware/koa-role-rls-error-handler.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { parseSearchParamsForSearch } from '#src/utils/search.js';
|
||||
|
||||
|
@ -39,6 +40,8 @@ export default function roleRoutes<T extends AuthedRouter>(
|
|||
},
|
||||
} = queries;
|
||||
|
||||
router.use('/roles(/.*)?', koaRoleRlsErrorHandler());
|
||||
|
||||
router.get('/roles', koaPagination(), async (ctx, next) => {
|
||||
const { limit, offset } = ctx.pagination;
|
||||
const { searchParams } = ctx.request.URL;
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import { defaultManagementApi } from '@logto/schemas';
|
||||
import { HTTPError } from 'got';
|
||||
|
||||
import { createResource } from '#src/api/index.js';
|
||||
import {
|
||||
assignScopesToRole,
|
||||
|
@ -42,4 +45,14 @@ describe('roles scopes', () => {
|
|||
const newScopes = await getRoleScopes(role.id);
|
||||
expect(newScopes.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should fail when try to assign a scope to an internal role', async () => {
|
||||
const resource = await createResource();
|
||||
const scope = await createScope(resource.id);
|
||||
const response = await assignScopesToRole([scope.id], defaultManagementApi.role.id).catch(
|
||||
(error: unknown) => error
|
||||
);
|
||||
|
||||
expect(response instanceof HTTPError && response.response.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -48,7 +48,13 @@ describe('roles', () => {
|
|||
const createdRole = await createRole();
|
||||
|
||||
const response = await createRole(createdRole.name).catch((error: unknown) => error);
|
||||
expect(response instanceof HTTPError && response.response.statusCode === 422).toBe(true);
|
||||
expect(response instanceof HTTPError && response.response.statusCode).toBe(422);
|
||||
});
|
||||
|
||||
it('should fail when try to create an internal role', async () => {
|
||||
const response = await createRole('#internal:foo').catch((error: unknown) => error);
|
||||
|
||||
expect(response instanceof HTTPError && response.response.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should get role detail successfully', async () => {
|
||||
|
@ -82,7 +88,7 @@ describe('roles', () => {
|
|||
const response = await updateRole(role2.id, {
|
||||
name: role1.name,
|
||||
}).catch((error: unknown) => error);
|
||||
expect(response instanceof HTTPError && response.response.statusCode === 422).toBe(true);
|
||||
expect(response instanceof HTTPError && response.response.statusCode).toBe(422);
|
||||
});
|
||||
|
||||
it('should delete role successfully', async () => {
|
||||
|
@ -91,6 +97,6 @@ describe('roles', () => {
|
|||
await deleteRole(role.id);
|
||||
|
||||
const response = await getRole(role.id).catch((error: unknown) => error);
|
||||
expect(response instanceof HTTPError && response.response.statusCode === 404).toBe(true);
|
||||
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -184,6 +184,8 @@ const errors = {
|
|||
user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED
|
||||
default_role_missing:
|
||||
'Some of the default roleNames does not exist in database, please ensure to create roles first', // UNTRANSLATED
|
||||
internal_role_violation:
|
||||
'You may be trying to update or delete an internal role which is forbidden by Logto. If you are creating a new role, try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
},
|
||||
scope: {
|
||||
name_exists: 'The scope name {{name}} is already in use', // UNTRANSLATED
|
||||
|
|
|
@ -17,7 +17,9 @@ const errors = {
|
|||
'Password requires a minimum of {{min}} characters and contains a mix of letters, numbers, and symbols.', // UNTRANSLATED
|
||||
insecure_contexts: 'Unsichere Kontexte (nicht-HTTPS) werden nicht unterstützt.',
|
||||
unexpected_error: 'Ein unerwarteter Fehler ist aufgetreten',
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
not_found: '404 not found', // UNTRANSLATED,
|
||||
create_internal_role_violation:
|
||||
'You are creating a new internal role which is forbidden by Logto. Try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -183,6 +183,8 @@ const errors = {
|
|||
user_exists: 'The user id {{userId}} is already been added to this role',
|
||||
default_role_missing:
|
||||
'Some of the default roleNames does not exist in database, please ensure to create roles first',
|
||||
internal_role_violation:
|
||||
'You may be trying to update or delete an internal role which is forbidden by Logto. If you are creating a new role, try another name that does not start with "#internal:".',
|
||||
},
|
||||
scope: {
|
||||
name_exists: 'The scope name {{name}} is already in use',
|
||||
|
|
|
@ -18,6 +18,8 @@ const errors = {
|
|||
insecure_contexts: 'Insecure contexts (non-HTTPS) are not supported.',
|
||||
unexpected_error: 'An unexpected error occurred',
|
||||
not_found: '404 not found',
|
||||
create_internal_role_violation:
|
||||
'You are creating a new internal role which is forbidden by Logto. Try another name that does not start with "#internal:".',
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -190,6 +190,8 @@ const errors = {
|
|||
user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED
|
||||
default_role_missing:
|
||||
'Some of the default roleNames does not exist in database, please ensure to create roles first', // UNTRANSLATED
|
||||
internal_role_violation:
|
||||
'You may be trying to update or delete an internal role which is forbidden by Logto. If you are creating a new role, try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
},
|
||||
scope: {
|
||||
name_exists: 'The scope name {{name}} is already in use', // UNTRANSLATED
|
||||
|
|
|
@ -18,6 +18,8 @@ const errors = {
|
|||
insecure_contexts: 'Les contextes non sécurisés (non HTTPS) ne sont pas pris en charge.',
|
||||
unexpected_error: "Une erreur inattendue s'est produite",
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
create_internal_role_violation:
|
||||
'You are creating a new internal role which is forbidden by Logto. Try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -177,6 +177,8 @@ const errors = {
|
|||
user_exists: '사용자 ID {{userId}}이/가 이미 이 역할에 추가되어 있어요.',
|
||||
default_role_missing:
|
||||
'기본 역할 이름의 일부가 데이터베이스에 존재하지 않아요. 먼저 역할을 생성해 주세요.',
|
||||
internal_role_violation:
|
||||
'You may be trying to update or delete an internal role which is forbidden by Logto. If you are creating a new role, try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
},
|
||||
scope: {
|
||||
name_exists: '범위 이름 {{name}}이/가 이미 사용 중이에요.',
|
||||
|
|
|
@ -18,6 +18,8 @@ const errors = {
|
|||
insecure_contexts: '비보안 연결(non-HTTPS)는 지원하지 않아요.',
|
||||
unexpected_error: '알 수 없는 오류가 발생했어요.',
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
create_internal_role_violation:
|
||||
'You are creating a new internal role which is forbidden by Logto. Try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -191,6 +191,8 @@ const errors = {
|
|||
user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED
|
||||
default_role_missing:
|
||||
'Some of the default roleNames does not exist in database, please ensure to create roles first', // UNTRANSLATED
|
||||
internal_role_violation:
|
||||
'You may be trying to update or delete an internal role which is forbidden by Logto. If you are creating a new role, try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
},
|
||||
scope: {
|
||||
name_exists: 'The scope name {{name}} is already in use', // UNTRANSLATED
|
||||
|
|
|
@ -18,6 +18,8 @@ const errors = {
|
|||
insecure_contexts: 'Contextos inseguros (não-HTTPS) não são suportados.',
|
||||
unexpected_error: 'Um erro inesperado ocorreu',
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
create_internal_role_violation:
|
||||
'You are creating a new internal role which is forbidden by Logto. Try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -185,6 +185,8 @@ const errors = {
|
|||
user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED
|
||||
default_role_missing:
|
||||
'Some of the default roleNames does not exist in database, please ensure to create roles first', // UNTRANSLATED
|
||||
internal_role_violation:
|
||||
'You may be trying to update or delete an internal role which is forbidden by Logto. If you are creating a new role, try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
},
|
||||
scope: {
|
||||
name_exists: 'The scope name {{name}} is already in use', // UNTRANSLATED
|
||||
|
|
|
@ -18,6 +18,8 @@ const errors = {
|
|||
insecure_contexts: 'Contextos inseguros (não HTTPS) não são compatíveis.',
|
||||
unexpected_error: 'Um erro inesperado ocorreu',
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
create_internal_role_violation:
|
||||
'You are creating a new internal role which is forbidden by Logto. Try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -185,6 +185,8 @@ const errors = {
|
|||
user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED
|
||||
default_role_missing:
|
||||
'Some of the default roleNames does not exist in database, please ensure to create roles first', // UNTRANSLATED
|
||||
internal_role_violation:
|
||||
'You may be trying to update or delete an internal role which is forbidden by Logto. If you are creating a new role, try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
},
|
||||
scope: {
|
||||
name_exists: 'The scope name {{name}} is already in use', // UNTRANSLATED
|
||||
|
|
|
@ -18,6 +18,8 @@ const errors = {
|
|||
insecure_contexts: 'Güvenli olmayan bağlamlar (HTTPS olmayan) desteklenmez.',
|
||||
unexpected_error: 'Beklenmedik bir hata oluştu',
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
create_internal_role_violation:
|
||||
'You are creating a new internal role which is forbidden by Logto. Try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -166,6 +166,8 @@ const errors = {
|
|||
user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED
|
||||
default_role_missing:
|
||||
'Some of the default roleNames does not exist in database, please ensure to create roles first', // UNTRANSLATED
|
||||
internal_role_violation:
|
||||
'You may be trying to update or delete an internal role which is forbidden by Logto. If you are creating a new role, try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
},
|
||||
scope: {
|
||||
name_exists: 'The scope name {{name}} is already in use', // UNTRANSLATED
|
||||
|
|
|
@ -16,6 +16,8 @@ const errors = {
|
|||
insecure_contexts: '不支持不安全的上下文(非 HTTPS)。',
|
||||
unexpected_error: '发生未知错误',
|
||||
not_found: '404 not found', // UNTRANSLATED
|
||||
create_internal_role_violation:
|
||||
'You are creating a new internal role which is forbidden by Logto. Try another name that does not start with "#internal:".', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
import { sql } from 'slonik';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await pool.query(sql`
|
||||
update roles
|
||||
set name = '#internal:admin', description = 'Internal admin role for Logto tenant ' || tenant_id || '.'
|
||||
where name = 'admin'
|
||||
and tenant_id != 'admin';
|
||||
|
||||
update roles
|
||||
set description = 'Admin tenant admin role for Logto tenant ' || substring(name from 0 for strpos(name, ':admin')) || '.'
|
||||
where name like '%:admin'
|
||||
and tenant_id = 'admin';
|
||||
|
||||
-- Restrict direct role modification
|
||||
create policy roles_select on roles
|
||||
for select using (true);
|
||||
|
||||
drop policy roles_modification on roles;
|
||||
create policy roles_modification on roles
|
||||
using (not starts_with(name, '#internal:'));
|
||||
|
||||
-- Restrict role - scope modification
|
||||
create policy roles_scopes_select on roles_scopes
|
||||
for select using (true);
|
||||
|
||||
drop policy roles_scopes_modification on roles_scopes;
|
||||
create policy roles_scopes_modification on roles_scopes
|
||||
using (not starts_with((select roles.name from roles where roles.id = role_id), '#internal:'));
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`
|
||||
update roles
|
||||
set name = 'admin', description = 'Admin role for Logto.'
|
||||
where name = '#internal:admin'
|
||||
and tenant_id != 'admin';
|
||||
|
||||
update roles
|
||||
set description = 'Admin role for Logto.'
|
||||
where name like '%:admin'
|
||||
and tenant_id = 'admin';
|
||||
|
||||
drop policy roles_select on roles;
|
||||
drop policy roles_modification on roles;
|
||||
|
||||
create policy roles_modification on roles
|
||||
using (true);
|
||||
|
||||
drop policy roles_scopes_select on roles_scopes;
|
||||
drop policy roles_scopes_modification on roles_scopes;
|
||||
|
||||
create policy roles_scopes_modification on roles_scopes
|
||||
using (true);
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -4,3 +4,4 @@ export * from './types/index.js';
|
|||
export * from './api/index.js';
|
||||
export * from './seeds/index.js';
|
||||
export * from './consts/index.js';
|
||||
export * from './utils/index.js';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { generateStandardId } from '@logto/core-kit';
|
||||
|
||||
import type { CreateScope } from '../index.js';
|
||||
import { UserRole } from '../types/index.js';
|
||||
import { AdminTenantRole } from '../types/index.js';
|
||||
import type { UpdateAdminData } from './management-api.js';
|
||||
import { adminTenantId } from './tenant.js';
|
||||
|
||||
|
@ -34,7 +34,7 @@ export const createCloudApi = (): Readonly<[UpdateAdminData, ...CreateScope[]]>
|
|||
scope: buildScope(CloudScope.CreateTenant, 'Allow creating new tenants.'),
|
||||
role: {
|
||||
tenantId: adminTenantId,
|
||||
name: UserRole.User,
|
||||
name: AdminTenantRole.User,
|
||||
},
|
||||
},
|
||||
buildScope(
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { generateStandardId } from '@logto/core-kit';
|
||||
|
||||
import type { CreateResource, CreateRole, CreateScope } from '../db-entries/index.js';
|
||||
import { PredefinedScope, UserRole } from '../types/index.js';
|
||||
import { PredefinedScope, InternalRole, AdminTenantRole } from '../types/index.js';
|
||||
import { adminTenantId, defaultTenantId } from './tenant.js';
|
||||
|
||||
export type AdminData = {
|
||||
|
@ -47,8 +47,8 @@ export const defaultManagementApi = Object.freeze({
|
|||
tenantId: defaultTenantId,
|
||||
/** @deprecated You should not rely on this constant. Change to something else. */
|
||||
id: 'admin-role',
|
||||
name: UserRole.Admin,
|
||||
description: 'Admin role for Logto.',
|
||||
name: InternalRole.Admin,
|
||||
description: `Internal admin role for Logto tenant ${defaultTenantId}.`,
|
||||
},
|
||||
}) satisfies AdminData;
|
||||
|
||||
|
@ -65,7 +65,7 @@ export function getManagementApiResourceIndicator(tenantId: string, path = 'api'
|
|||
}
|
||||
|
||||
export const getManagementApiAdminName = <TenantId extends string>(tenantId: TenantId) =>
|
||||
`${tenantId}:${UserRole.Admin}` as const;
|
||||
`${tenantId}:${AdminTenantRole.Admin}` as const;
|
||||
|
||||
/** Create a set of admin data for Management API of the given tenant ID. */
|
||||
export const createAdminData = (tenantId: string): AdminData => {
|
||||
|
@ -88,8 +88,8 @@ export const createAdminData = (tenantId: string): AdminData => {
|
|||
role: {
|
||||
tenantId,
|
||||
id: generateStandardId(),
|
||||
name: UserRole.Admin,
|
||||
description: 'Admin role for Logto.',
|
||||
name: InternalRole.Admin,
|
||||
description: `Internal admin role for Logto tenant ${defaultTenantId}.`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -116,7 +116,7 @@ export const createAdminDataInAdminTenant = (tenantId: string): AdminData => {
|
|||
tenantId: adminTenantId,
|
||||
id: generateStandardId(),
|
||||
name: getManagementApiAdminName(tenantId),
|
||||
description: 'Admin role for Logto.',
|
||||
description: `Admin tenant admin role for Logto tenant ${tenantId}.`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -141,7 +141,7 @@ export const createMeApiInAdminTenant = (): AdminData => {
|
|||
role: {
|
||||
tenantId: adminTenantId,
|
||||
id: generateStandardId(),
|
||||
name: UserRole.User,
|
||||
name: AdminTenantRole.User,
|
||||
description: 'Default role for admin tenant.',
|
||||
},
|
||||
});
|
||||
|
|
|
@ -22,7 +22,17 @@ export type UserInfo<Keys extends keyof CreateUser = (typeof userInfoSelectField
|
|||
|
||||
export type UserProfileResponse = UserInfo & { hasPassword?: boolean };
|
||||
|
||||
export enum UserRole {
|
||||
/** Internal read-only roles for user tenants. */
|
||||
export enum InternalRole {
|
||||
/**
|
||||
* Internal admin role for Machine-to-Machine apps in Logto user tenants.
|
||||
*
|
||||
* It should NOT be assigned to any user.
|
||||
*/
|
||||
Admin = '#internal:admin',
|
||||
}
|
||||
|
||||
export enum AdminTenantRole {
|
||||
Admin = 'admin',
|
||||
/** Common user role in admin tenant. */
|
||||
User = 'user',
|
||||
|
|
1
packages/schemas/src/utils/index.ts
Normal file
1
packages/schemas/src/utils/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './role.js';
|
3
packages/schemas/src/utils/role.ts
Normal file
3
packages/schemas/src/utils/role.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const internalRolePrefix = '#internal:';
|
||||
|
||||
export const isInternalRole = (roleName: string) => roleName.startsWith(internalRolePrefix);
|
|
@ -1,30 +1,56 @@
|
|||
/* This SQL will run after all other queries. */
|
||||
|
||||
---- Grant CRUD access to the group ----
|
||||
grant select, insert, update, delete
|
||||
on all tables
|
||||
in schema public
|
||||
to logto_tenant_${database};
|
||||
|
||||
-- Security policies for tenants table --
|
||||
---- Security policies for tenants table ----
|
||||
|
||||
revoke all privileges
|
||||
on table tenants
|
||||
from logto_tenant_${database};
|
||||
|
||||
/* Allow limited select to perform the RLS policy query in `after_each` (using select ... from tenants ...) */
|
||||
-- Allow limited select to perform the RLS policy query in `after_each` (using select ... from tenants ...)
|
||||
grant select (id, db_user)
|
||||
on table tenants
|
||||
to logto_tenant_${database};
|
||||
|
||||
alter table tenants enable row level security;
|
||||
|
||||
/* Create RLS policy to minimize the privilege */
|
||||
-- Create RLS policy to minimize the privilege
|
||||
create policy tenants_tenant_id on tenants
|
||||
using (db_user = current_user);
|
||||
|
||||
-- End --
|
||||
|
||||
/* Revoke all privileges on systems table for tenant roles */
|
||||
---- Revoke all privileges on systems table for tenant roles ----
|
||||
revoke all privileges
|
||||
on table systems
|
||||
from logto_tenant_${database};
|
||||
|
||||
---- Create policies to make internal roles read-only ----
|
||||
|
||||
/**
|
||||
* Note:
|
||||
*
|
||||
* Internal roles have scope preset and they are read-only, but we do not
|
||||
* limit user or application assignment since it's business logic.
|
||||
*/
|
||||
|
||||
-- Restrict direct role modification
|
||||
create policy roles_select on roles
|
||||
for select using (true);
|
||||
|
||||
drop policy roles_modification on roles;
|
||||
create policy roles_modification on roles
|
||||
using (not starts_with(name, '#internal:'));
|
||||
|
||||
-- Restrict role - scope modification
|
||||
create policy roles_scopes_select on roles_scopes
|
||||
for select using (true);
|
||||
|
||||
drop policy roles_scopes_modification on roles_scopes;
|
||||
create policy roles_scopes_modification on roles_scopes
|
||||
using (not starts_with((select roles.name from roles where roles.id = role_id), '#internal:'));
|
||||
|
||||
---- TODO: Make internal API Resources read-only ----
|
||||
|
|
50
pnpm-lock.yaml
generated
50
pnpm-lock.yaml
generated
|
@ -53,6 +53,7 @@ importers:
|
|||
ora: ^6.1.2
|
||||
p-limit: ^4.0.0
|
||||
p-retry: ^5.1.2
|
||||
pg-protocol: ^1.6.0
|
||||
prettier: ^2.8.2
|
||||
roarr: ^7.11.0
|
||||
semver: ^7.3.8
|
||||
|
@ -79,6 +80,7 @@ importers:
|
|||
ora: 6.1.2
|
||||
p-limit: 4.0.0
|
||||
p-retry: 5.1.2
|
||||
pg-protocol: 1.6.0
|
||||
roarr: 7.11.0
|
||||
semver: 7.3.8
|
||||
slonik: 30.1.2
|
||||
|
@ -386,6 +388,7 @@ importers:
|
|||
oidc-provider: ^8.0.0
|
||||
openapi-types: ^12.0.0
|
||||
p-retry: ^5.1.2
|
||||
pg-protocol: ^1.6.0
|
||||
prettier: ^2.8.2
|
||||
roarr: ^7.11.0
|
||||
semver: ^7.3.8
|
||||
|
@ -439,6 +442,7 @@ importers:
|
|||
nanoid: 4.0.0
|
||||
oidc-provider: 8.0.0
|
||||
p-retry: 5.1.2
|
||||
pg-protocol: 1.6.0
|
||||
roarr: 7.11.0
|
||||
semver: 7.3.8
|
||||
slonik: 30.1.2
|
||||
|
@ -4271,7 +4275,7 @@ packages:
|
|||
resolution: {integrity: sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw==}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
pg-protocol: 1.5.0
|
||||
pg-protocol: 1.6.0
|
||||
pg-types: 2.2.0
|
||||
|
||||
/@types/pluralize/0.0.29:
|
||||
|
@ -11353,12 +11357,12 @@ packages:
|
|||
dependencies:
|
||||
obuf: 1.1.2
|
||||
|
||||
/pg-cursor/2.7.3_pg@8.7.3:
|
||||
/pg-cursor/2.7.3_pg@8.8.0:
|
||||
resolution: {integrity: sha512-vmjXRMD4jZK/oHaaYk6clTypgHNlzCCAqyLCO5d/UeI42egJVE5H4ZfZWACub3jzkHUXXyvibH207zAJg9iBOw==}
|
||||
peerDependencies:
|
||||
pg: ^8
|
||||
dependencies:
|
||||
pg: 8.7.3
|
||||
pg: 8.8.0
|
||||
|
||||
/pg-formatter/1.3.0:
|
||||
resolution: {integrity: sha512-y1kNdgD+QWzhmYCm91z/k7VGyx6BekQg6ww/krFEEhw1IIB4zEk2xaB0pmueTcc59YFetpiHIKECgHEuw6gyvg==}
|
||||
|
@ -11376,13 +11380,6 @@ packages:
|
|||
resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
/pg-pool/3.5.1_pg@8.7.3:
|
||||
resolution: {integrity: sha512-6iCR0wVrro6OOHFsyavV+i6KYL4lVNyYAB9RD18w66xSzN+d8b66HiwuP30Gp1SH5O9T82fckkzsRjlrhD0ioQ==}
|
||||
peerDependencies:
|
||||
pg: '>=8.0'
|
||||
dependencies:
|
||||
pg: 8.7.3
|
||||
|
||||
/pg-pool/3.5.2_pg@8.8.0:
|
||||
resolution: {integrity: sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==}
|
||||
peerDependencies:
|
||||
|
@ -11390,8 +11387,8 @@ packages:
|
|||
dependencies:
|
||||
pg: 8.8.0
|
||||
|
||||
/pg-protocol/1.5.0:
|
||||
resolution: {integrity: sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==}
|
||||
/pg-protocol/1.6.0:
|
||||
resolution: {integrity: sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==}
|
||||
|
||||
/pg-types/2.2.0:
|
||||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||
|
@ -11427,23 +11424,6 @@ packages:
|
|||
postgres-interval: 3.0.0
|
||||
postgres-range: 1.1.2
|
||||
|
||||
/pg/8.7.3:
|
||||
resolution: {integrity: sha512-HPmH4GH4H3AOprDJOazoIcpI49XFsHCe8xlrjHkWiapdbHK+HLtbm/GQzXYAZwmPju/kzKhjaSfMACG+8cgJcw==}
|
||||
engines: {node: '>= 8.0.0'}
|
||||
peerDependencies:
|
||||
pg-native: '>=2.0.0'
|
||||
peerDependenciesMeta:
|
||||
pg-native:
|
||||
optional: true
|
||||
dependencies:
|
||||
buffer-writer: 2.0.0
|
||||
packet-reader: 1.0.0
|
||||
pg-connection-string: 2.5.0
|
||||
pg-pool: 3.5.1_pg@8.7.3
|
||||
pg-protocol: 1.5.0
|
||||
pg-types: 2.2.0
|
||||
pgpass: 1.0.4
|
||||
|
||||
/pg/8.8.0:
|
||||
resolution: {integrity: sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==}
|
||||
engines: {node: '>= 8.0.0'}
|
||||
|
@ -11457,7 +11437,7 @@ packages:
|
|||
packet-reader: 1.0.0
|
||||
pg-connection-string: 2.5.0
|
||||
pg-pool: 3.5.2_pg@8.8.0
|
||||
pg-protocol: 1.5.0
|
||||
pg-protocol: 1.6.0
|
||||
pg-types: 2.2.0
|
||||
pgpass: 1.0.4
|
||||
|
||||
|
@ -13045,11 +13025,11 @@ packages:
|
|||
inline-loops.macro: 1.2.2
|
||||
is-plain-object: 5.0.0
|
||||
iso8601-duration: 1.3.0
|
||||
pg: 8.7.3
|
||||
pg: 8.8.0
|
||||
pg-connection-string: 2.5.0
|
||||
pg-copy-streams: 5.1.1
|
||||
pg-copy-streams-binary: 2.2.0
|
||||
pg-cursor: 2.7.3_pg@8.7.3
|
||||
pg-cursor: 2.7.3_pg@8.8.0
|
||||
pg-types: 3.0.1
|
||||
postgres-array: 2.0.0
|
||||
postgres-interval: 2.1.0
|
||||
|
@ -13073,11 +13053,11 @@ packages:
|
|||
is-plain-object: 5.0.0
|
||||
iso8601-duration: 1.3.0
|
||||
p-defer: 3.0.0
|
||||
pg: 8.7.3
|
||||
pg: 8.8.0
|
||||
pg-copy-streams: 6.0.2
|
||||
pg-copy-streams-binary: 2.2.0
|
||||
pg-cursor: 2.7.3_pg@8.7.3
|
||||
pg-protocol: 1.5.0
|
||||
pg-cursor: 2.7.3_pg@8.8.0
|
||||
pg-protocol: 1.6.0
|
||||
pg-types: 4.0.0
|
||||
postgres-array: 3.0.1
|
||||
postgres-interval: 4.0.0
|
||||
|
|
Loading…
Add table
Reference in a new issue