0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

chore: launch m2m app for organizations (#6129)

* chore: launch m2m app for organizations

* chore: add changeset
This commit is contained in:
Gao Sun 2024-07-01 14:55:47 +08:00 committed by GitHub
parent 2f31d1a746
commit 87615d58ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 129 additions and 141 deletions

View file

@ -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.

View file

@ -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: <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 />,
}
),
},
],
}
),
};

View file

@ -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
<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 && (

View file

@ -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() {
<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={

View file

@ -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';
@ -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>
);

View file

@ -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 &&

View file

@ -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": {

View file

@ -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<T extends ManagementApiRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
// TODO: Remove
if (!EnvSet.values.isDevFeaturesEnabled) {
return;
}
router.get(
'/applications/:id/organizations',
koaPagination({ isOptional: true }),

View file

@ -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": {

View file

@ -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<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
);
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);
}

View file

@ -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'
)

View file

@ -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>) => {

View file

@ -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',

View file

@ -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[] = [];

View file

@ -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({