Skip to content

Commit

Permalink
chore: launch m2m app for organizations (#6129)
Browse files Browse the repository at this point in the history
* chore: launch m2m app for organizations

* chore: add changeset
  • Loading branch information
gao-sun authored Jul 1, 2024
1 parent 2f31d1a commit 87615d5
Show file tree
Hide file tree
Showing 15 changed files with 125 additions and 137 deletions.
29 changes: 29 additions & 0 deletions .changeset/gentle-camels-film.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,15 +16,15 @@ export const organizations: RouteObject = {
{
path: ':id/*',
element: <OrganizationDetails />,
children: condArray(
children: [
{ index: true, element: <Navigate replace to={OrganizationDetailsTabs.Settings} /> },
{ path: OrganizationDetailsTabs.Settings, element: <Settings /> },
{ path: OrganizationDetailsTabs.Members, element: <Members /> },
isDevFeaturesEnabled && {
{
path: OrganizationDetailsTabs.MachineToMachine,
element: <MachineToMachine />,
}
),
},
],
}
),
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -178,11 +177,9 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Logs}`}>
{t('application_details.machine_logs')}
</TabNavItem>
{isDevFeaturesEnabled && (
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Organizations}`}>
{t('organizations.title')}
</TabNavItem>
)}
<TabNavItem href={`/applications/${data.id}/${ApplicationDetailsTabs.Organizations}`}>
{t('organizations.title')}
</TabNavItem>
</>
)}
{data.isThirdParty && (
Expand Down
11 changes: 4 additions & 7 deletions packages/console/src/pages/OrganizationDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -134,12 +133,10 @@ function OrganizationDetails() {
<TabNavItem href={`${pathname}/${id}/${OrganizationDetailsTabs.Members}`}>
{t('organizations.members')}
</TabNavItem>
{/* TODO: Remove */}
{isDevFeaturesEnabled && (
<TabNavItem href={`${pathname}/${id}/${OrganizationDetailsTabs.MachineToMachine}`}>
{t('organizations.machine_to_machine')}
</TabNavItem>
)}

<TabNavItem href={`${pathname}/${id}/${OrganizationDetailsTabs.MachineToMachine}`}>
{t('organizations.machine_to_machine')}
</TabNavItem>
</TabNav>
<Outlet
context={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';

import { isDevFeaturesEnabled } from '@/consts/env';
import Button from '@/ds-components/Button';
import DynamicT from '@/ds-components/DynamicT';
import FormField from '@/ds-components/FormField';
Expand Down Expand Up @@ -105,29 +104,26 @@ function CreateOrganizationRoleModal({ isOpen, onClose }: Props) {
{...register('description')}
/>
</FormField>
{/* TODO: Remove */}
{isDevFeaturesEnabled && (
<FormField title="organization_template.roles.create_modal.type">
<Controller
name="type"
control={control}
render={({ field: { onChange, value, name } }) => (
<RadioGroup
name={name}
className={styles.roleTypes}
value={value}
onChange={(value) => {
onChange(value);
}}
>
{radioOptions.map(({ key, value }) => (
<Radio key={value} title={<DynamicT forKey={key} />} value={value} />
))}
</RadioGroup>
)}
/>
</FormField>
)}
<FormField title="organization_template.roles.create_modal.type">
<Controller
name="type"
control={control}
render={({ field: { onChange, value, name } }) => (
<RadioGroup
name={name}
className={styles.roleTypes}
value={value}
onChange={(value) => {
onChange(value);
}}
>
{radioOptions.map(({ key, value }) => (
<Radio key={value} title={<DynamicT forKey={key} />} value={value} />
))}
</RadioGroup>
)}
/>
</FormField>
</ModalLayout>
</ReactModal>
);
Expand Down
6 changes: 1 addition & 5 deletions packages/core/src/oidc/grants/client-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -10,11 +9,6 @@ import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
export default function applicationOrganizationRoutes<T extends ManagementApiRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
// TODO: Remove
if (!EnvSet.values.isDevFeaturesEnabled) {
return;
}

router.get(
'/applications/:id/organizations',
koaPagination({ isOptional: true }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
111 changes: 54 additions & 57 deletions packages/core/src/routes/organization/application/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,69 +20,67 @@ export default function applicationRoutes(
router: SchemaRouter<OrganizationKeys, CreateOrganization, Organization>,
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
);
const [totalCount, entities] =
await organizations.relations.apps.getApplicationsByOrganizationId(
ctx.guard.params.id,
ctx.pagination,
search
);

ctx.pagination.totalCount = totalCount;
ctx.body = entities;
ctx.pagination.totalCount = totalCount;
ctx.body = entities;

return next();
}
);
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],
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(),
}),
async (ctx, next) => {
const { id } = ctx.guard.params;
const { applicationIds, organizationRoleIds } = ctx.guard.body;
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,
}))
)
);
await organizations.relations.appsRoles.insert(
...organizationRoleIds.flatMap((organizationRoleId) =>
applicationIds.map((applicationId) => ({
organizationId: id,
applicationId,
organizationRoleId,
}))
)
);

ctx.status = 201;
return next();
}
);
ctx.status = 201;
return next();
}
);

// MARK: Organization - application role relation routes
applicationRoleRelationRoutes(router, organizations);
}
// MARK: Organization - application role relation routes
applicationRoleRelationRoutes(router, organizations);
}
2 changes: 1 addition & 1 deletion packages/core/src/routes/swagger/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ const identifiableEntityNames = Object.freeze([
/** Additional tags that cannot be inferred from the path. */
const additionalTags = Object.freeze(
condArray<string>(
EnvSet.values.isDevFeaturesEnabled && 'Organization applications',
'Organization applications',
EnvSet.values.isDevFeaturesEnabled && 'Security',
'Organization users'
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createApplicationApi>) => {
Expand Down
Loading

0 comments on commit 87615d5

Please sign in to comment.