0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

feat!: migrate application role names to RBAC (#2972)

This commit is contained in:
wangsijie 2023-01-18 15:38:36 +08:00 committed by GitHub
parent 03f03fe354
commit ece866db7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 246 additions and 62 deletions

View file

@ -1,6 +1,5 @@
import type { Application, SnakeCaseOidcConfig } from '@logto/schemas';
import { ApplicationType, UserRole } from '@logto/schemas';
import { deduplicate } from '@silverhand/essentials';
import { ApplicationType } from '@logto/schemas';
import { Controller, useFormContext } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
@ -18,7 +17,7 @@ type Props = {
};
const AdvancedSettings = ({ applicationType, oidcConfig }: Props) => {
const { control } = useFormContext<Application>();
const { control } = useFormContext<Application & { isAdmin: boolean }>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
@ -68,19 +67,14 @@ const AdvancedSettings = ({ applicationType, oidcConfig }: Props) => {
{applicationType === ApplicationType.MachineToMachine && (
<FormField title="application_details.enable_admin_access">
<Controller
name="roleNames"
name="isAdmin"
control={control}
defaultValue={[]}
render={({ field: { onChange, value } }) => (
<Switch
label={t('application_details.enable_admin_access_label')}
checked={value.includes(UserRole.Admin)}
checked={value}
onChange={({ currentTarget: { checked } }) => {
if (checked) {
onChange(deduplicate(value.concat(UserRole.Admin)));
} else {
onChange(value.filter((value) => value !== UserRole.Admin));
}
onChange(checked);
}}
/>
)}

View file

@ -1,4 +1,4 @@
import type { Application, SnakeCaseOidcConfig } from '@logto/schemas';
import type { Application, ApplicationResponse, SnakeCaseOidcConfig } from '@logto/schemas';
import { ApplicationType } from '@logto/schemas';
import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
@ -42,7 +42,7 @@ const mapToUriOriginFormatArrays = (value?: string[]) =>
const ApplicationDetails = () => {
const { id } = useParams();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { data, error, mutate } = useSWR<Application, RequestError>(
const { data, error, mutate } = useSWR<ApplicationResponse, RequestError>(
id && `/api/applications/${id}`
);
const { data: oidcConfig, error: fetchOidcConfigError } = useSWR<
@ -56,7 +56,7 @@ const ApplicationDetails = () => {
const [isDeleted, setIsDeleted] = useState(false);
const api = useApi();
const navigate = useNavigate();
const formMethods = useForm<Application>();
const formMethods = useForm<Application & { isAdmin: boolean }>();
const documentationUrl = useDocumentationUrl();
const {
@ -78,7 +78,7 @@ const ApplicationDetails = () => {
return;
}
const updatedApplication = await api
await api
.patch(`/api/applications/${data.id}`, {
json: {
...formData,
@ -98,7 +98,7 @@ const ApplicationDetails = () => {
},
})
.json<Application>();
void mutate(updatedApplication);
void mutate();
toast.success(t('general.saved'));
});

View file

@ -21,7 +21,6 @@ export const mockApplication: Application = {
idTokenTtl: 5000,
refreshTokenTtl: 6_000_000,
},
roleNames: [],
createdAt: 1_645_334_775_356,
};

View file

@ -0,0 +1,33 @@
import type { Scope } from '@logto/schemas';
import type Queries from '#src/tenants/Queries.js';
export type ApplicationLibrary = ReturnType<typeof createApplicationLibrary>;
export const createApplicationLibrary = (queries: Queries) => {
const {
applicationsRoles: { findApplicationsRolesByApplicationId },
rolesScopes: { findRolesScopesByRoleIds },
scopes: { findScopesByIdsAndResourceId },
} = queries;
const findApplicationScopesForResourceId = async (
applicationId: string,
resourceId: string
): Promise<readonly Scope[]> => {
const applicationsRoles = await findApplicationsRolesByApplicationId(applicationId);
const rolesScopes = await findRolesScopesByRoleIds(
applicationsRoles.map(({ roleId }) => roleId)
);
const scopes = await findScopesByIdsAndResourceId(
rolesScopes.map(({ scopeId }) => scopeId),
resourceId
);
return scopes;
};
return {
findApplicationScopesForResourceId,
};
};

View file

@ -4,8 +4,7 @@ import { readFileSync } from 'fs';
import { userClaims } from '@logto/core-kit';
import { CustomClientMetadataKey } from '@logto/schemas';
import { tryThat } from '@logto/shared';
import Provider, { errors } from 'oidc-provider';
import Provider, { errors, ResourceServer } from 'oidc-provider';
import snakecaseKeys from 'snakecase-keys';
import type { EnvSet } from '#src/env-set/index.js';
@ -16,7 +15,6 @@ import { isOriginAllowed, validateCustomClientMetadata } from '#src/oidc/utils.j
import { routes } from '#src/routes/consts.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
import { claimToUserKey, getUserClaims } from './scope.js';
@ -33,10 +31,10 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li
defaultRefreshTokenTtl,
} = envSet.oidc;
const {
applications: { findApplicationById },
resources: { findResourceByIndicator },
} = queries;
const { findUserByIdWithRoles, findUserScopesForResourceId } = libraries.users;
const { findApplicationScopesForResourceId } = libraries.applications;
const logoutSource = readFileSync('static/html/logout.html', 'utf8');
const cookieConfig = Object.freeze({
@ -91,19 +89,40 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li
throw new errors.InvalidTarget();
}
const userId = ctx.oidc.session?.accountId;
const scopes = userId ? await findUserScopesForResourceId(userId, resourceServer.id) : [];
const { accessTokenTtl: accessTokenTTL } = resourceServer;
return {
const { accessTokenTtl: accessTokenTTL, id } = resourceServer;
const result = {
accessTokenFormat: 'jwt',
scope: scopes.map(({ name }) => name).join(' '),
accessTokenTTL,
jwt: {
sign: { alg: jwkSigningAlg },
},
};
scope: '',
} satisfies ResourceServer;
const userId = ctx.oidc.session?.accountId;
if (userId) {
const scopes = await findUserScopesForResourceId(userId, id);
return {
...result,
scope: scopes.map(({ name }) => name).join(' '),
};
}
const clientId = ctx.oidc.entities.Client?.clientId;
// Machine to machine app
if (clientId) {
const scopes = await findApplicationScopesForResourceId(clientId, id);
return {
...result,
scope: scopes.map(({ name }) => name).join(' '),
};
}
return result;
},
},
},
@ -186,30 +205,6 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li
Session: 1_209_600 /* 14 days in seconds */,
Grant: 1_209_600 /* 14 days in seconds */,
},
extraTokenClaims: async (_ctx, token) => {
if (token.kind === 'AccessToken') {
const { accountId } = token;
const { roleNames } = await tryThat(
findUserByIdWithRoles(accountId),
new errors.InvalidClient(`invalid user ${accountId}`)
);
return snakecaseKeys({
roleNames,
});
}
// `token.kind === 'ClientCredentials'`
const { clientId } = token;
assertThat(clientId, 'oidc.invalid_grant');
const { roleNames } = await tryThat(
findApplicationById(clientId),
new errors.InvalidClient(`invalid client ${clientId}`)
);
return snakecaseKeys({ roleNames });
},
});
addOidcEventListeners(oidc);

View file

@ -0,0 +1,44 @@
import type { ApplicationsRole } from '@logto/schemas';
import { 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);
export const createApplicationsRolesQueries = (pool: CommonQueryMethods) => {
const findApplicationsRolesByApplicationId = async (applicationId: string) =>
pool.any<ApplicationsRole>(sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
where ${fields.applicationId}=${applicationId}
`);
const insertApplicationsRoles = async (applicationsRoles: ApplicationsRole[]) =>
pool.query(sql`
insert into ${table} (${fields.applicationId}, ${fields.roleId}) values
${sql.join(
applicationsRoles.map(({ applicationId, roleId }) => sql`(${applicationId}, ${roleId})`),
sql`, `
)}
`);
const deleteApplicationRole = async (applicationId: string, roleId: string) => {
const { rowCount } = await pool.query(sql`
delete from ${table}
where ${fields.applicationId} = ${applicationId} and ${fields.roleId} = ${roleId}
`);
if (rowCount < 1) {
throw new DeletionError(RolesScopes.table);
}
};
return {
findApplicationsRolesByApplicationId,
insertApplicationsRoles,
deleteApplicationRole,
};
};

View file

@ -127,7 +127,10 @@ describe('application route', () => {
const response = await applicationRequest.get('/applications/foo');
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockApplication);
expect(response.body).toEqual({
...mockApplication,
isAdmin: false,
});
});
it('PATCH /applications/:applicationId', async () => {

View file

@ -1,6 +1,6 @@
import { generateStandardId, buildIdGenerator } from '@logto/core-kit';
import { Applications } from '@logto/schemas';
import { object, string } from 'zod';
import { adminConsoleAdminRoleId, Applications } from '@logto/schemas';
import { boolean, object, string } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
@ -21,6 +21,8 @@ export default function applicationRoutes<T extends AuthedRouter>(
updateApplicationById,
findTotalNumberOfApplications,
} = queries.applications;
const { findApplicationsRolesByApplicationId, insertApplicationsRoles, deleteApplicationRole } =
queries.applicationsRoles;
router.get('/applications', koaPagination(), async (ctx, next) => {
const { limit, offset } = ctx.pagination;
@ -69,7 +71,13 @@ export default function applicationRoutes<T extends AuthedRouter>(
params: { id },
} = ctx.guard;
ctx.body = await findApplicationById(id);
const application = await findApplicationById(id);
const applicationsRoles = await findApplicationsRolesByApplicationId(id);
ctx.body = {
...application,
isAdmin: applicationsRoles.some(({ roleId }) => roleId === adminConsoleAdminRoleId),
};
return next();
}
@ -79,7 +87,14 @@ export default function applicationRoutes<T extends AuthedRouter>(
'/applications/:id',
koaGuard({
params: object({ id: string().min(1) }),
body: Applications.createGuard.omit({ id: true, createdAt: true }).deepPartial(),
body: Applications.createGuard
.omit({ id: true, createdAt: true })
.deepPartial()
.merge(
object({
isAdmin: boolean().optional(),
})
),
}),
async (ctx, next) => {
const {
@ -87,9 +102,23 @@ export default function applicationRoutes<T extends AuthedRouter>(
body,
} = ctx.guard;
ctx.body = await updateApplicationById(id, {
...body,
});
const { isAdmin, ...rest } = body;
// FIXME @sijie temp solution to set admin access to machine to machine app
if (isAdmin !== undefined) {
const applicationsRoles = await findApplicationsRolesByApplicationId(id);
const originalIsAdmin = applicationsRoles.some(
({ roleId }) => roleId === adminConsoleAdminRoleId
);
if (isAdmin && !originalIsAdmin) {
await insertApplicationsRoles([{ applicationId: id, roleId: adminConsoleAdminRoleId }]);
} else if (!isAdmin && originalIsAdmin) {
await deleteApplicationRole(id, adminConsoleAdminRoleId);
}
}
ctx.body = await updateApplicationById(id, rest);
return next();
}

View file

@ -1,3 +1,4 @@
import { createApplicationLibrary } from '#src/libraries/application.js';
import { createConnectorLibrary } from '#src/libraries/connector.js';
import { createHookLibrary } from '#src/libraries/hook.js';
import { createPasscodeLibrary } from '#src/libraries/passcode.js';
@ -19,6 +20,7 @@ export default class Libraries {
hooks = createHookLibrary(this.queries, this.modelRouters);
socials = createSocialLibrary(this.queries, this.connectors);
passcodes = createPasscodeLibrary(this.queries, this.connectors);
applications = createApplicationLibrary(this.queries);
constructor(private readonly queries: Queries, private readonly modelRouters: ModelRouters) {}
}

View file

@ -1,6 +1,7 @@
import type { CommonQueryMethods } from 'slonik';
import { createApplicationQueries } from '#src/queries/application.js';
import { createApplicationsRolesQueries } from '#src/queries/applications-roles.js';
import { createConnectorQueries } from '#src/queries/connector.js';
import { createCustomPhraseQueries } from '#src/queries/custom-phrase.js';
import { createLogQueries } from '#src/queries/log.js';
@ -30,6 +31,7 @@ export default class Queries {
signInExperiences = createSignInExperienceQueries(this.pool);
users = createUserQueries(this.pool);
usersRoles = createUsersRolesQueries(this.pool);
applicationsRoles = createApplicationsRolesQueries(this.pool);
constructor(public readonly pool: CommonQueryMethods) {}
}

View file

@ -0,0 +1,75 @@
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
create table applications_roles (
application_id varchar(21) not null references applications (id) on update cascade on delete cascade,
role_id varchar(21) not null references roles (id) on update cascade on delete cascade,
primary key (application_id, role_id)
);
`);
const applications = await pool.any<{ id: string; roleNames: string[] }>(sql`
select * from applications where jsonb_array_length(role_names) > 0
`);
const roles = await pool.any<{ id: string; name: string }>(sql`
select * from roles
`);
for (const application of applications) {
for (const roleName of application.roleNames) {
if (!roleName) {
continue;
}
const role = roles.find(({ name }) => name === roleName);
if (!role) {
throw new Error(`Unable to find role: ${roleName}`);
}
// eslint-disable-next-line no-await-in-loop
await pool.query(sql`
insert into applications_roles (application_id, role_id) values (${application.id}, ${role.id})
`);
}
}
await pool.query(sql`
alter table applications drop column role_names
`);
},
down: async (pool) => {
await pool.query(sql`
alter table applications add column role_names jsonb not null default '[]'::jsonb
`);
const relations = await pool.any<{ applicationId: string; roleId: string }>(sql`
select * from applications_roles
`);
const roles = await pool.any<{ id: string; name: string }>(sql`
select * from roles
`);
for (const relation of relations) {
const role = roles.find(({ id }) => id === relation.roleId);
if (!role) {
continue;
}
// eslint-disable-next-line no-await-in-loop
await pool.query(sql`
update applications set role_names = role_names || '[${role.name}]'::jsonb where id = ${relation.applicationId}
`);
}
await pool.query(sql`
drop table applications_roles;
`);
},
};
export default alteration;

View file

@ -0,0 +1,3 @@
import type { Application } from '../db-entries/index.js';
export type ApplicationResponse = Application & { isAdmin: boolean };

View file

@ -9,3 +9,4 @@ export * from './resource.js';
export * from './scope.js';
export * from './role.js';
export * from './verification-code.js';
export * from './application.js';

View file

@ -8,7 +8,6 @@ create table applications (
type application_type not null,
oidc_client_metadata jsonb /* @use OidcClientMetadata */ not null,
custom_client_metadata jsonb /* @use CustomClientMetadata */ not null default '{}'::jsonb,
role_names jsonb /* @use RoleNames */ not null default '[]'::jsonb,
created_at timestamptz not null default(now()),
primary key (id)
);

View file

@ -0,0 +1,5 @@
create table applications_roles (
application_id varchar(21) not null references applications (id) on update cascade on delete cascade,
role_id varchar(21) not null references roles (id) on update cascade on delete cascade,
primary key (application_id, role_id)
);