diff --git a/packages/core/src/queries/organization/index.ts b/packages/core/src/queries/organization/index.ts index 1ba5fdefaafb..266728d4b79f 100644 --- a/packages/core/src/queries/organization/index.ts +++ b/packages/core/src/queries/organization/index.ts @@ -19,6 +19,7 @@ import { OrganizationInvitationStatus, OrganizationRoleResourceScopeRelations, Scopes, + Resources, } from '@logto/schemas'; import { sql, type CommonQueryMethods } from '@silverhand/slonik'; @@ -62,25 +63,52 @@ class OrganizationRolesQueries extends SchemaQueries< ) { const { table, fields } = convertToIdentifiers(OrganizationRoles, true); const relations = convertToIdentifiers(OrganizationRoleScopeRelations, true); + const resourceScopeRelations = convertToIdentifiers( + OrganizationRoleResourceScopeRelations, + true + ); const scopes = convertToIdentifiers(OrganizationScopes, true); + const resourceScopes = convertToIdentifiers(Scopes, true); + const resource = convertToIdentifiers(Resources, true); return sql` select ${table}.*, coalesce( - json_agg( - json_build_object( + json_agg(distinct + jsonb_build_object( 'id', ${scopes.fields.id}, - 'name', ${scopes.fields.name} - ) order by ${scopes.fields.name} + 'name', ${scopes.fields.name}, + 'order', ${scopes.fields.id} + ) ) filter (where ${scopes.fields.id} is not null), '[]' - ) as scopes -- left join could produce nulls as scopes + ) as scopes, -- left join could produce nulls as scopes + coalesce( + json_agg(distinct + jsonb_build_object( + 'id', ${resourceScopes.fields.id}, + 'name', ${resourceScopes.fields.name}, + 'resource', json_build_object( + 'id', ${resource.fields.id}, + 'name', ${resource.fields.name} + ), + 'order', ${resourceScopes.fields.id} + ) + ) filter (where ${resourceScopes.fields.id} is not null), + '[]' + ) as "resourceScopes" -- left join could produce nulls as resourceScopes from ${table} left join ${relations.table} on ${relations.fields.organizationRoleId} = ${fields.id} left join ${scopes.table} on ${relations.fields.organizationScopeId} = ${scopes.fields.id} + left join ${resourceScopeRelations.table} + on ${resourceScopeRelations.fields.organizationRoleId} = ${fields.id} + left join ${resourceScopes.table} + on ${resourceScopeRelations.fields.scopeId} = ${resourceScopes.fields.id} + left join ${resource.table} + on ${resourceScopes.fields.resourceId} = ${resource.fields.id} ${conditionalSql(roleId, (id) => { return sql`where ${fields.id} = ${id}`; })} diff --git a/packages/core/src/routes/organization/roles.openapi.json b/packages/core/src/routes/organization/roles.openapi.json index 830430f1ff41..fbdcbcf4dff9 100644 --- a/packages/core/src/routes/organization/roles.openapi.json +++ b/packages/core/src/routes/organization/roles.openapi.json @@ -29,6 +29,12 @@ }, "description": { "description": "The description of the organization role." + }, + "organizationScopeIds": { + "description": "An array of organization scope IDs to be assigned to the organization role." + }, + "resourceScopeIds": { + "description": "An array of resource scope IDs to be assigned to the organization role." } } } diff --git a/packages/core/src/routes/organization/roles.ts b/packages/core/src/routes/organization/roles.ts index 46df1f4c4e2a..3f2b1ad7be2e 100644 --- a/packages/core/src/routes/organization/roles.ts +++ b/packages/core/src/routes/organization/roles.ts @@ -2,6 +2,7 @@ import { type CreateOrganizationRole, OrganizationRoles, organizationRoleWithScopesGuard, + organizationRoleWithScopesGuardDeprecated, } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { z } from 'zod'; @@ -44,7 +45,10 @@ export default function organizationRoleRoutes( koaPagination(), koaGuard({ query: z.object({ q: z.string().optional() }), - response: organizationRoleWithScopesGuard.array(), + // TODO @wangsijie - Remove this once the feature is ready + response: EnvSet.values.isDevFeaturesEnabled + ? organizationRoleWithScopesGuard.array() + : organizationRoleWithScopesGuardDeprecated.array(), status: [200], }), async (ctx, next) => { @@ -63,8 +67,22 @@ export default function organizationRoleRoutes( /** Allows to carry an initial set of scopes for creating a new organization role. */ type CreateOrganizationRolePayload = Omit & { organizationScopeIds: string[]; + resourceScopeIds: string[]; }; + // TODO @wangsijie - Remove this once the feature is ready + const originalCreateCard: z.ZodType< + Omit & { scopeIds?: string[] }, + z.ZodTypeDef, + unknown + > = OrganizationRoles.createGuard + .omit({ + id: true, + }) + .extend({ + organizationScopeIds: z.array(z.string()).default([]), + }); + const createGuard: z.ZodType = OrganizationRoles.createGuard .omit({ @@ -72,21 +90,31 @@ export default function organizationRoleRoutes( }) .extend({ organizationScopeIds: z.array(z.string()).default([]), + resourceScopeIds: z.array(z.string()).default([]), }); router.post( '/', koaGuard({ - body: createGuard, + body: EnvSet.values.isDevFeaturesEnabled ? createGuard : originalCreateCard, response: OrganizationRoles.guard, status: [201, 422], }), async (ctx, next) => { - const { organizationScopeIds: scopeIds, ...data } = ctx.guard.body; + const { organizationScopeIds, resourceScopeIds, ...data } = ctx.guard.body; const role = await roles.insert({ id: generateStandardId(), ...data }); - if (scopeIds.length > 0) { - await rolesScopes.insert(...scopeIds.map<[string, string]>((id) => [role.id, id])); + if (organizationScopeIds.length > 0) { + await rolesScopes.insert( + ...organizationScopeIds.map<[string, string]>((id) => [role.id, id]) + ); + } + + // TODO @wangsijie - Remove this once the feature is ready + if (EnvSet.values.isDevFeaturesEnabled && resourceScopeIds && resourceScopeIds.length > 0) { + await rolesResourceScopes.insert( + ...resourceScopeIds.map<[string, string]>((id) => [role.id, id]) + ); } ctx.body = role; diff --git a/packages/integration-tests/src/api/factory.ts b/packages/integration-tests/src/api/factory.ts index d8cd684bdf52..f7ccbaac498f 100644 --- a/packages/integration-tests/src/api/factory.ts +++ b/packages/integration-tests/src/api/factory.ts @@ -26,7 +26,6 @@ export class ApiFactory< constructor(public readonly path: string) {} async create(data: PostData): Promise { - console.log(this.path); return transform(await authedAdminApi.post(this.path, { json: data }).json()); } diff --git a/packages/integration-tests/src/api/organization-role.ts b/packages/integration-tests/src/api/organization-role.ts index 9e8c535d4bb0..eabe04ae440a 100644 --- a/packages/integration-tests/src/api/organization-role.ts +++ b/packages/integration-tests/src/api/organization-role.ts @@ -12,6 +12,7 @@ export type CreateOrganizationRolePostData = { name: string; description?: string; organizationScopeIds?: string[]; + resourceScopeIds?: string[]; }; export class OrganizationRoleApi extends ApiFactory< diff --git a/packages/integration-tests/src/tests/api/organization/organization-role.test.ts b/packages/integration-tests/src/tests/api/organization/organization-role.test.ts index 83d1191c360a..2ff9abf5343e 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-role.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-role.test.ts @@ -14,6 +14,15 @@ describe('organization role APIs', () => { describe('organization roles', () => { const roleApi = new OrganizationRoleApiTest(); const scopeApi = new OrganizationScopeApiTest(); + const resourceScopeApi = new ScopeApiTest(); + + beforeAll(async () => { + await resourceScopeApi.initResource(); + }); + + afterAll(async () => { + await resourceScopeApi.cleanUp(); + }); afterEach(async () => { await Promise.all([roleApi.cleanUp(), scopeApi.cleanUp()]); @@ -84,17 +93,23 @@ describe('organization role APIs', () => { expect(role).toStrictEqual(createdRole); }); - it('should be able to create a new organization with initial scopes', async () => { + it('should be able to create a new organization with initial organization scopes and resource scopes', async () => { const [scope1, scope2] = await Promise.all([ scopeApi.create({ name: 'test' + randomId() }), scopeApi.create({ name: 'test' + randomId() }), ]); + const [resourceScope1, resourceScope2] = await Promise.all([ + resourceScopeApi.create({ name: 'test' + randomId() }), + resourceScopeApi.create({ name: 'test' + randomId() }), + ]); const createdRole = await roleApi.create({ name: 'test' + randomId(), description: 'test description.', organizationScopeIds: [scope1.id, scope2.id], + resourceScopeIds: [resourceScope1.id, resourceScope2.id], }); const scopes = await roleApi.getScopes(createdRole.id); + const resourceScopes = await roleApi.getResourceScopes(createdRole.id); const roles = await roleApi.getList(); const roleWithScopes = roles.find((role) => role.id === createdRole.id); @@ -104,6 +119,13 @@ describe('organization role APIs', () => { ); expect(scopes).toContainEqual(expect.objectContaining(pick(scope, 'id', 'name'))); } + + for (const scope of [resourceScope1, resourceScope2]) { + expect(roleWithScopes?.resourceScopes).toContainEqual( + expect.objectContaining(pick(scope, 'id', 'name')) + ); + expect(resourceScopes).toContainEqual(expect.objectContaining(pick(scope, 'id', 'name'))); + } }); it('should fail when try to get an organization role that does not exist', async () => { diff --git a/packages/schemas/src/types/organization.ts b/packages/schemas/src/types/organization.ts index 64ab17f8e91e..27a8201932fb 100644 --- a/packages/schemas/src/types/organization.ts +++ b/packages/schemas/src/types/organization.ts @@ -20,10 +20,35 @@ export type OrganizationScopeEntity = { name: string; }; +/** + * The simplified resource scope entity that is returned for some endpoints. + */ +export type ResourceScopeEntity = { + id: string; + name: string; + resource: { + id: string; + name: string; + }; +}; + export type OrganizationRoleWithScopes = OrganizationRole & { scopes: OrganizationScopeEntity[]; + resourceScopes: ResourceScopeEntity[]; }; +// TODO @wangsijie - Remove this once the feature is ready +export const organizationRoleWithScopesGuardDeprecated: ToZodObject< + Omit +> = OrganizationRoles.guard.extend({ + scopes: z + .object({ + id: z.string(), + name: z.string(), + }) + .array(), +}); + export const organizationRoleWithScopesGuard: ToZodObject = OrganizationRoles.guard.extend({ scopes: z @@ -32,6 +57,16 @@ export const organizationRoleWithScopesGuard: ToZodObject