diff --git a/packages/console/src/pages/ApplicationDetails/components/AdvancedSettings.tsx b/packages/console/src/pages/ApplicationDetails/components/AdvancedSettings.tsx index b2dc7d4e6..79fa48913 100644 --- a/packages/console/src/pages/ApplicationDetails/components/AdvancedSettings.tsx +++ b/packages/console/src/pages/ApplicationDetails/components/AdvancedSettings.tsx @@ -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(); + const { control } = useFormContext(); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); return ( @@ -68,19 +67,14 @@ const AdvancedSettings = ({ applicationType, oidcConfig }: Props) => { {applicationType === ApplicationType.MachineToMachine && ( ( { - if (checked) { - onChange(deduplicate(value.concat(UserRole.Admin))); - } else { - onChange(value.filter((value) => value !== UserRole.Admin)); - } + onChange(checked); }} /> )} diff --git a/packages/console/src/pages/ApplicationDetails/index.tsx b/packages/console/src/pages/ApplicationDetails/index.tsx index 5c66d65cc..d182add68 100644 --- a/packages/console/src/pages/ApplicationDetails/index.tsx +++ b/packages/console/src/pages/ApplicationDetails/index.tsx @@ -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( + const { data, error, mutate } = useSWR( 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(); + const formMethods = useForm(); 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(); - void mutate(updatedApplication); + void mutate(); toast.success(t('general.saved')); }); diff --git a/packages/core/src/__mocks__/index.ts b/packages/core/src/__mocks__/index.ts index 38913f281..d57050588 100644 --- a/packages/core/src/__mocks__/index.ts +++ b/packages/core/src/__mocks__/index.ts @@ -21,7 +21,6 @@ export const mockApplication: Application = { idTokenTtl: 5000, refreshTokenTtl: 6_000_000, }, - roleNames: [], createdAt: 1_645_334_775_356, }; diff --git a/packages/core/src/libraries/application.ts b/packages/core/src/libraries/application.ts new file mode 100644 index 000000000..eaa26e107 --- /dev/null +++ b/packages/core/src/libraries/application.ts @@ -0,0 +1,33 @@ +import type { Scope } from '@logto/schemas'; + +import type Queries from '#src/tenants/Queries.js'; + +export type ApplicationLibrary = ReturnType; + +export const createApplicationLibrary = (queries: Queries) => { + const { + applicationsRoles: { findApplicationsRolesByApplicationId }, + rolesScopes: { findRolesScopesByRoleIds }, + scopes: { findScopesByIdsAndResourceId }, + } = queries; + + const findApplicationScopesForResourceId = async ( + applicationId: string, + resourceId: string + ): Promise => { + 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, + }; +}; diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 9820181d4..d689c1179 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -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); diff --git a/packages/core/src/queries/applications-roles.ts b/packages/core/src/queries/applications-roles.ts new file mode 100644 index 000000000..c6008a0da --- /dev/null +++ b/packages/core/src/queries/applications-roles.ts @@ -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(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, + }; +}; diff --git a/packages/core/src/routes/application.test.ts b/packages/core/src/routes/application.test.ts index f3deae860..fb7f4fa25 100644 --- a/packages/core/src/routes/application.test.ts +++ b/packages/core/src/routes/application.test.ts @@ -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 () => { diff --git a/packages/core/src/routes/application.ts b/packages/core/src/routes/application.ts index a7f8a14fa..3a6f120ad 100644 --- a/packages/core/src/routes/application.ts +++ b/packages/core/src/routes/application.ts @@ -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( 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( 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( '/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( 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(); } diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts index 545a36769..eb68d75f2 100644 --- a/packages/core/src/tenants/Libraries.ts +++ b/packages/core/src/tenants/Libraries.ts @@ -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) {} } diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index 07d2f0b60..bc13db007 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -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) {} } diff --git a/packages/schemas/alterations/next-1673941897-application-roles.ts b/packages/schemas/alterations/next-1673941897-application-roles.ts new file mode 100644 index 000000000..da3f10f29 --- /dev/null +++ b/packages/schemas/alterations/next-1673941897-application-roles.ts @@ -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; diff --git a/packages/schemas/src/types/application.ts b/packages/schemas/src/types/application.ts new file mode 100644 index 000000000..d81926093 --- /dev/null +++ b/packages/schemas/src/types/application.ts @@ -0,0 +1,3 @@ +import type { Application } from '../db-entries/index.js'; + +export type ApplicationResponse = Application & { isAdmin: boolean }; diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index e2fe584cd..6faa0a306 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -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'; diff --git a/packages/schemas/tables/applications.sql b/packages/schemas/tables/applications.sql index 7e020fdaa..507bc8013 100644 --- a/packages/schemas/tables/applications.sql +++ b/packages/schemas/tables/applications.sql @@ -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) ); diff --git a/packages/schemas/tables/rolesapplications.sql b/packages/schemas/tables/rolesapplications.sql new file mode 100644 index 000000000..88728f9e0 --- /dev/null +++ b/packages/schemas/tables/rolesapplications.sql @@ -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) +);