mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core,schemas): edit and query resource scopes for org role (#5613)
This commit is contained in:
parent
1030b66966
commit
3160b40f3b
7 changed files with 131 additions and 12 deletions
|
@ -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<OrganizationRoleWithScopes>`
|
||||
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}`;
|
||||
})}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<T extends AuthedRouter>(
|
|||
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<T extends AuthedRouter>(
|
|||
/** Allows to carry an initial set of scopes for creating a new organization role. */
|
||||
type CreateOrganizationRolePayload = Omit<CreateOrganizationRole, 'id'> & {
|
||||
organizationScopeIds: string[];
|
||||
resourceScopeIds: string[];
|
||||
};
|
||||
|
||||
// TODO @wangsijie - Remove this once the feature is ready
|
||||
const originalCreateCard: z.ZodType<
|
||||
Omit<CreateOrganizationRolePayload, 'resourceScopeIds'> & { resourceScopeIds?: string[] },
|
||||
z.ZodTypeDef,
|
||||
unknown
|
||||
> = OrganizationRoles.createGuard
|
||||
.omit({
|
||||
id: true,
|
||||
})
|
||||
.extend({
|
||||
organizationScopeIds: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
const createGuard: z.ZodType<CreateOrganizationRolePayload, z.ZodTypeDef, unknown> =
|
||||
OrganizationRoles.createGuard
|
||||
.omit({
|
||||
|
@ -72,21 +90,31 @@ export default function organizationRoleRoutes<T extends AuthedRouter>(
|
|||
})
|
||||
.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;
|
||||
|
|
|
@ -26,7 +26,6 @@ export class ApiFactory<
|
|||
constructor(public readonly path: string) {}
|
||||
|
||||
async create(data: PostData): Promise<Schema> {
|
||||
console.log(this.path);
|
||||
return transform(await authedAdminApi.post(this.path, { json: data }).json<Schema>());
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ export type CreateOrganizationRolePostData = {
|
|||
name: string;
|
||||
description?: string;
|
||||
organizationScopeIds?: string[];
|
||||
resourceScopeIds?: string[];
|
||||
};
|
||||
|
||||
export class OrganizationRoleApi extends ApiFactory<
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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<OrganizationRoleWithScopes, 'resourceScopes'>
|
||||
> = OrganizationRoles.guard.extend({
|
||||
scopes: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
export const organizationRoleWithScopesGuard: ToZodObject<OrganizationRoleWithScopes> =
|
||||
OrganizationRoles.guard.extend({
|
||||
scopes: z
|
||||
|
@ -32,6 +57,16 @@ export const organizationRoleWithScopesGuard: ToZodObject<OrganizationRoleWithSc
|
|||
name: z.string(),
|
||||
})
|
||||
.array(),
|
||||
resourceScopes: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
resource: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in a new issue