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:
parent
2f31d1a746
commit
87615d58ce
15 changed files with 129 additions and 141 deletions
29
.changeset/gentle-camels-film.md
Normal file
29
.changeset/gentle-camels-film.md
Normal 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.
|
|
@ -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 />,
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
};
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
|
|
|
@ -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>) => {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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[] = [];
|
||||
|
|
|
@ -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({
|
||||
|
|
Loading…
Add table
Reference in a new issue