diff --git a/.changeset/gentle-camels-film.md b/.changeset/gentle-camels-film.md new file mode 100644 index 000000000..35903a01a --- /dev/null +++ b/.changeset/gentle-camels-film.md @@ -0,0 +1,29 @@ +--- +"@logto/console": minor +"@logto/core": minor +"@logto/phrases": minor +"@logto/schemas": minor +"@logto/integration-tests": patch +--- + +support machine-to-machine apps for organizations + +This feature allows machine-to-machine apps to be associated with organizations, and be assigned with organization roles. + +### Console + +- Add a new "machine-to-machine" type to organization roles. All existing roles are now "user" type. +- You can manage machine-to-machine apps in the organization details page -> Machine-to-machine apps section. +- You can view the associated organizations in the machine-to-machine app details page. + +### OpenID Connect grant + +The `client_credentials` grant type is now supported for organizations. You can use this grant type to obtain an access token for an organization. + +### Management API + +A set of new endpoints are added to the Management API: + +- `/api/organizations/{id}/applications` to manage machine-to-machine apps. +- `/api/organizations/{id}/applications/{applicationId}` to manage a specific machine-to-machine app in an organization. +- `/api/applications/{id}/organizations` to view the associated organizations of a machine-to-machine app. diff --git a/packages/console/src/hooks/use-console-routes/routes/organizations.tsx b/packages/console/src/hooks/use-console-routes/routes/organizations.tsx index cb6e5d69d..1c70a6738 100644 --- a/packages/console/src/hooks/use-console-routes/routes/organizations.tsx +++ b/packages/console/src/hooks/use-console-routes/routes/organizations.tsx @@ -1,7 +1,6 @@ import { condArray } from '@silverhand/essentials'; import { Navigate, type RouteObject } from 'react-router-dom'; -import { isDevFeaturesEnabled } from '@/consts/env'; import OrganizationDetails from '@/pages/OrganizationDetails'; import MachineToMachine from '@/pages/OrganizationDetails/MachineToMachine'; import Members from '@/pages/OrganizationDetails/Members'; @@ -17,15 +16,15 @@ export const organizations: RouteObject = { { path: ':id/*', element: , - children: condArray( + children: [ { index: true, element: }, { path: OrganizationDetailsTabs.Settings, element: }, { path: OrganizationDetailsTabs.Members, element: }, - isDevFeaturesEnabled && { + { path: OrganizationDetailsTabs.MachineToMachine, element: , - } - ), + }, + ], } ), }; diff --git a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx index ccf9522ff..27924e718 100644 --- a/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx +++ b/packages/console/src/pages/ApplicationDetails/ApplicationDetailsContent/index.tsx @@ -19,7 +19,6 @@ import EmptyDataPlaceholder from '@/components/EmptyDataPlaceholder'; import OrganizationList from '@/components/OrganizationList'; import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; import { ApplicationDetailsTabs, logtoThirdPartyGuideLink, protectedAppLink } from '@/consts'; -import { isDevFeaturesEnabled } from '@/consts/env'; import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal'; import TabNav, { TabNavItem } from '@/ds-components/TabNav'; import TabWrapper from '@/ds-components/TabWrapper'; @@ -178,11 +177,9 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P {t('application_details.machine_logs')} - {isDevFeaturesEnabled && ( - - {t('organizations.title')} - - )} + + {t('organizations.title')} + )} {data.isThirdParty && ( diff --git a/packages/console/src/pages/OrganizationDetails/index.tsx b/packages/console/src/pages/OrganizationDetails/index.tsx index 265dbd050..ddf858181 100644 --- a/packages/console/src/pages/OrganizationDetails/index.tsx +++ b/packages/console/src/pages/OrganizationDetails/index.tsx @@ -19,7 +19,6 @@ import Skeleton from '@/components/DetailsPage/Skeleton'; import Drawer from '@/components/Drawer'; import PageMeta from '@/components/PageMeta'; import ThemedIcon from '@/components/ThemedIcon'; -import { isDevFeaturesEnabled } from '@/consts/env'; import DeleteConfirmModal from '@/ds-components/DeleteConfirmModal'; import TabNav, { TabNavItem } from '@/ds-components/TabNav'; import useApi, { type RequestError } from '@/hooks/use-api'; @@ -134,12 +133,10 @@ function OrganizationDetails() { {t('organizations.members')} - {/* TODO: Remove */} - {isDevFeaturesEnabled && ( - - {t('organizations.machine_to_machine')} - - )} + + + {t('organizations.machine_to_machine')} + - {/* TODO: Remove */} - {isDevFeaturesEnabled && ( - - ( - { - onChange(value); - }} - > - {radioOptions.map(({ key, value }) => ( - } value={value} /> - ))} - - )} - /> - - )} + + ( + { + onChange(value); + }} + > + {radioOptions.map(({ key, value }) => ( + } value={value} /> + ))} + + )} + /> + ); diff --git a/packages/core/src/oidc/grants/client-credentials.ts b/packages/core/src/oidc/grants/client-credentials.ts index 12a12225e..5bf06f889 100644 --- a/packages/core/src/oidc/grants/client-credentials.ts +++ b/packages/core/src/oidc/grants/client-credentials.ts @@ -28,7 +28,7 @@ import dpopValidate from 'oidc-provider/lib/helpers/validate_dpop.js'; import instance from 'oidc-provider/lib/helpers/weak_cache.js'; import checkResource from 'oidc-provider/lib/shared/check_resource.js'; -import { EnvSet } from '#src/env-set/index.js'; +import { type EnvSet } from '#src/env-set/index.js'; import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; @@ -68,10 +68,6 @@ export const buildHandler: ( // The value type is `unknown`, which will swallow other type inferences. So we have to cast it // to `Boolean` first. const organizationId = cond(Boolean(params?.organization_id) && String(params?.organization_id)); - // TODO: Remove - if (!EnvSet.values.isDevFeaturesEnabled && organizationId) { - throw new InvalidTarget('organization tokens are not supported yet'); - } if ( organizationId && diff --git a/packages/core/src/routes/applications/application-organization.openapi.json b/packages/core/src/routes/applications/application-organization.openapi.json index 9edad9907..3ceb28b52 100644 --- a/packages/core/src/routes/applications/application-organization.openapi.json +++ b/packages/core/src/routes/applications/application-organization.openapi.json @@ -2,7 +2,6 @@ "paths": { "/api/applications/{id}/organizations": { "get": { - "tags": ["Dev feature"], "summary": "Get application organizations", "description": "Get the list of organizations that an application is associated with.", "responses": { diff --git a/packages/core/src/routes/applications/application-organization.ts b/packages/core/src/routes/applications/application-organization.ts index 79a30d9a8..79e5c3bba 100644 --- a/packages/core/src/routes/applications/application-organization.ts +++ b/packages/core/src/routes/applications/application-organization.ts @@ -1,7 +1,6 @@ import { organizationWithOrganizationRolesGuard } from '@logto/schemas'; import { z } from 'zod'; -import { EnvSet } from '#src/env-set/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; @@ -10,11 +9,6 @@ import { type ManagementApiRouter, type RouterInitArgs } from '../types.js'; export default function applicationOrganizationRoutes( ...[router, { queries }]: RouterInitArgs ) { - // TODO: Remove - if (!EnvSet.values.isDevFeaturesEnabled) { - return; - } - router.get( '/applications/:id/organizations', koaPagination({ isOptional: true }), diff --git a/packages/core/src/routes/organization/application/index.openapi.json b/packages/core/src/routes/organization/application/index.openapi.json index 00dc4b8d4..ea9390633 100644 --- a/packages/core/src/routes/organization/application/index.openapi.json +++ b/packages/core/src/routes/organization/application/index.openapi.json @@ -3,8 +3,7 @@ { "name": "Organization applications", "description": "Manage organization - application relationships. An application can be associated with one or more organizations in order to get access to the organization resources.\n\nCurrently, only machine-to-machine applications can be associated with organizations." - }, - { "name": "Dev feature" } + } ], "paths": { "/api/organizations/{id}/applications": { @@ -81,7 +80,6 @@ }, "/api/organizations/{id}/applications/roles": { "post": { - "tags": ["Dev feature"], "summary": "Assign roles to applications in an organization", "description": "Assign roles to applications in the specified organization.", "requestBody": { diff --git a/packages/core/src/routes/organization/application/index.ts b/packages/core/src/routes/organization/application/index.ts index 94f107256..cc44d46f4 100644 --- a/packages/core/src/routes/organization/application/index.ts +++ b/packages/core/src/routes/organization/application/index.ts @@ -6,7 +6,6 @@ import { } from '@logto/schemas'; import { z } from 'zod'; -import { EnvSet } from '#src/env-set/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; import { applicationSearchKeys } from '#src/queries/application.js'; @@ -21,69 +20,67 @@ export default function applicationRoutes( router: SchemaRouter, organizations: OrganizationQueries ) { - if (EnvSet.values.isDevFeaturesEnabled) { - // MARK: Organization - application relation routes - router.addRelationRoutes(organizations.relations.apps, undefined, { - disabled: { get: true }, - hookEvent: 'Organization.Membership.Updated', - }); + // MARK: Organization - application relation routes + router.addRelationRoutes(organizations.relations.apps, undefined, { + disabled: { get: true }, + hookEvent: 'Organization.Membership.Updated', + }); - router.get( - '/:id/applications', - koaPagination(), - koaGuard({ - query: z.object({ q: z.string().optional() }), - params: z.object({ id: z.string().min(1) }), - response: applicationWithOrganizationRolesGuard.array(), - status: [200, 404], - }), - async (ctx, next) => { - const search = parseSearchOptions(applicationSearchKeys, ctx.guard.query); + router.get( + '/:id/applications', + koaPagination(), + koaGuard({ + query: z.object({ q: z.string().optional() }), + params: z.object({ id: z.string().min(1) }), + response: applicationWithOrganizationRolesGuard.array(), + status: [200, 404], + }), + async (ctx, next) => { + const search = parseSearchOptions(applicationSearchKeys, ctx.guard.query); - const [totalCount, entities] = - await organizations.relations.apps.getApplicationsByOrganizationId( - ctx.guard.params.id, - ctx.pagination, - search - ); - - ctx.pagination.totalCount = totalCount; - ctx.body = entities; - - return next(); - } - ); - - router.post( - '/:id/applications/roles', - koaGuard({ - params: z.object({ id: z.string().min(1) }), - body: z.object({ - applicationIds: z.string().min(1).array().nonempty(), - organizationRoleIds: z.string().min(1).array().nonempty(), - }), - status: [201, 422], - }), - async (ctx, next) => { - const { id } = ctx.guard.params; - const { applicationIds, organizationRoleIds } = ctx.guard.body; - - await organizations.relations.appsRoles.insert( - ...organizationRoleIds.flatMap((organizationRoleId) => - applicationIds.map((applicationId) => ({ - organizationId: id, - applicationId, - organizationRoleId, - })) - ) + const [totalCount, entities] = + await organizations.relations.apps.getApplicationsByOrganizationId( + ctx.guard.params.id, + ctx.pagination, + search ); - ctx.status = 201; - return next(); - } - ); + ctx.pagination.totalCount = totalCount; + ctx.body = entities; - // MARK: Organization - application role relation routes - applicationRoleRelationRoutes(router, organizations); - } + return next(); + } + ); + + router.post( + '/:id/applications/roles', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + body: z.object({ + applicationIds: z.string().min(1).array().nonempty(), + organizationRoleIds: z.string().min(1).array().nonempty(), + }), + status: [201, 422], + }), + async (ctx, next) => { + const { id } = ctx.guard.params; + const { applicationIds, organizationRoleIds } = ctx.guard.body; + + await organizations.relations.appsRoles.insert( + ...organizationRoleIds.flatMap((organizationRoleId) => + applicationIds.map((applicationId) => ({ + organizationId: id, + applicationId, + organizationRoleId, + })) + ) + ); + + ctx.status = 201; + return next(); + } + ); + + // MARK: Organization - application role relation routes + applicationRoleRelationRoutes(router, organizations); } diff --git a/packages/core/src/routes/swagger/index.ts b/packages/core/src/routes/swagger/index.ts index d203744a7..b27831f8c 100644 --- a/packages/core/src/routes/swagger/index.ts +++ b/packages/core/src/routes/swagger/index.ts @@ -150,7 +150,7 @@ const identifiableEntityNames = Object.freeze([ /** Additional tags that cannot be inferred from the path. */ const additionalTags = Object.freeze( condArray( - EnvSet.values.isDevFeaturesEnabled && 'Organization applications', + 'Organization applications', EnvSet.values.isDevFeaturesEnabled && 'Security', 'Organization users' ) diff --git a/packages/integration-tests/src/tests/api/application/application.organization.test.ts b/packages/integration-tests/src/tests/api/application/application.organization.test.ts index 96c9ea878..047fd0f7f 100644 --- a/packages/integration-tests/src/tests/api/application/application.organization.test.ts +++ b/packages/integration-tests/src/tests/api/application/application.organization.test.ts @@ -7,9 +7,9 @@ import { getOrganizations, } from '#src/api/application.js'; import { OrganizationApiTest } from '#src/helpers/organization.js'; -import { devFeatureTest, generateTestName } from '#src/utils.js'; +import { generateTestName } from '#src/utils.js'; -devFeatureTest.describe('application organizations', () => { +describe('application organizations', () => { const organizationApi = new OrganizationApiTest(); const applications: Application[] = []; const createApplication = async (...args: Parameters) => { diff --git a/packages/integration-tests/src/tests/api/oidc/client-credentials-grant.test.ts b/packages/integration-tests/src/tests/api/oidc/client-credentials-grant.test.ts index 63c5dbacc..530862136 100644 --- a/packages/integration-tests/src/tests/api/oidc/client-credentials-grant.test.ts +++ b/packages/integration-tests/src/tests/api/oidc/client-credentials-grant.test.ts @@ -26,9 +26,9 @@ import { } from '#src/api/resource.js'; import { assignScopesToRole, createRole as createRoleApi, deleteRole } from '#src/api/role.js'; import { createScope as createScopeApi } from '#src/api/scope.js'; -import { isDevFeaturesEnabled, logtoUrl } from '#src/constants.js'; +import { logtoUrl } from '#src/constants.js'; import { OrganizationApiTest } from '#src/helpers/organization.js'; -import { devFeatureTest, randomString } from '#src/utils.js'; +import { randomString } from '#src/utils.js'; type TokenResponse = { access_token: string; @@ -175,19 +175,6 @@ describe('client credentials grant', () => { }); describe('organization token', () => { - it('should fail if dev feature is not enabled', async () => { - if (isDevFeaturesEnabled) { - return; - } - - await expectError({ organization_id: 'not-found' }, 400, { - error: 'invalid_target', - error_description: 'organization tokens are not supported yet', - }); - }); - }); - - devFeatureTest.describe('organization token', () => { it('should fail if the application is not associated with the organization', async () => { await expectError({ organization_id: 'not-found' }, 403, { error: 'access_denied', diff --git a/packages/integration-tests/src/tests/api/organization/organization-application.test.ts b/packages/integration-tests/src/tests/api/organization/organization-application.test.ts index b4ae58698..44c7b2f6a 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-application.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-application.test.ts @@ -13,10 +13,9 @@ import { deleteApplication, } from '#src/api/application.js'; import { OrganizationApiTest } from '#src/helpers/organization.js'; -import { devFeatureTest, generateTestName } from '#src/utils.js'; +import { generateTestName } from '#src/utils.js'; -// TODO: Remove this prefix -devFeatureTest.describe('organization application APIs', () => { +describe('organization application APIs', () => { describe('organization get applications', () => { const organizationApi = new OrganizationApiTest(); const applications: Application[] = []; diff --git a/packages/integration-tests/src/tests/api/organization/organization-user.test.ts b/packages/integration-tests/src/tests/api/organization/organization-user.test.ts index 6247ff01e..26604555f 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-user.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-user.test.ts @@ -5,7 +5,7 @@ import { HTTPError } from 'ky'; import { OrganizationApiTest } from '#src/helpers/organization.js'; import { UserApiTest } from '#src/helpers/user.js'; -import { devFeatureTest, generateTestName } from '#src/utils.js'; +import { generateTestName } from '#src/utils.js'; describe('organization user APIs', () => { describe('organization get users', () => { @@ -289,7 +289,7 @@ describe('organization user APIs', () => { ); }); - devFeatureTest.it('should fail when try to add role that is not user type', async () => { + it('should fail when try to add role that is not user type', async () => { const organization = await organizationApi.create({ name: 'test' }); const user = await userApi.create({ username: generateTestName() }); const role = await roleApi.create({