0
Fork 0
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:
Gao Sun 2023-03-09 12:41:34 +08:00 committed by GitHub
commit 310c543f93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 288 additions and 88 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}}이/가 이미 사용 중이에요.',

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
export * from './role.js';

View file

@ -0,0 +1,3 @@
export const internalRolePrefix = '#internal:';
export const isInternalRole = (roleName: string) => roleName.startsWith(internalRolePrefix);

View file

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

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