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:
parent
03f03fe354
commit
ece866db7c
15 changed files with 246 additions and 62 deletions
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
|
||||
|
|
|
@ -21,7 +21,6 @@ export const mockApplication: Application = {
|
|||
idTokenTtl: 5000,
|
||||
refreshTokenTtl: 6_000_000,
|
||||
},
|
||||
roleNames: [],
|
||||
createdAt: 1_645_334_775_356,
|
||||
};
|
||||
|
||||
|
|
33
packages/core/src/libraries/application.ts
Normal file
33
packages/core/src/libraries/application.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -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);
|
||||
|
|
44
packages/core/src/queries/applications-roles.ts
Normal file
44
packages/core/src/queries/applications-roles.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -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 () => {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
@ -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;
|
3
packages/schemas/src/types/application.ts
Normal file
3
packages/schemas/src/types/application.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import type { Application } from '../db-entries/index.js';
|
||||
|
||||
export type ApplicationResponse = Application & { isAdmin: boolean };
|
|
@ -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';
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
5
packages/schemas/tables/rolesapplications.sql
Normal file
5
packages/schemas/tables/rolesapplications.sql
Normal 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)
|
||||
);
|
Loading…
Add table
Reference in a new issue