Skip to content

Commit

Permalink
feat(core,schemas): edit and query resource scopes for org role
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Apr 7, 2024
1 parent 94ccbaf commit 89b40a2
Show file tree
Hide file tree
Showing 7 changed files with 131 additions and 12 deletions.
38 changes: 33 additions & 5 deletions packages/core/src/queries/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
OrganizationInvitationStatus,
OrganizationRoleResourceScopeRelations,
Scopes,
Resources,
} from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';

Expand Down Expand Up @@ -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}`;
})}
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/routes/organization/roles.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
38 changes: 33 additions & 5 deletions packages/core/src/routes/organization/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type CreateOrganizationRole,
OrganizationRoles,
organizationRoleWithScopesGuard,
organizationRoleWithScopesGuardDeprecated,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { z } from 'zod';
Expand Down Expand Up @@ -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) => {
Expand All @@ -63,30 +67,54 @@ 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'> & { scopeIds?: 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({
id: true,
})
.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;
Expand Down
1 change: 0 additions & 1 deletion packages/integration-tests/src/api/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>());
}

Expand Down
1 change: 1 addition & 0 deletions packages/integration-tests/src/api/organization-role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type CreateOrganizationRolePostData = {
name: string;
description?: string;
organizationScopeIds?: string[];
resourceScopeIds?: string[];
};

export class OrganizationRoleApi extends ApiFactory<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()]);
Expand Down Expand Up @@ -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);

Expand All @@ -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 () => {
Expand Down
35 changes: 35 additions & 0 deletions packages/schemas/src/types/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
});

/**
Expand Down

0 comments on commit 89b40a2

Please sign in to comment.