Skip to content

Commit

Permalink
feat(core,schemas): add organization resource scope relations
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Apr 1, 2024
1 parent 9bf9f07 commit fed0597
Show file tree
Hide file tree
Showing 9 changed files with 306 additions and 1 deletion.
9 changes: 9 additions & 0 deletions packages/core/src/queries/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
type OrganizationInvitationEntity,
OrganizationInvitationRoleRelations,
OrganizationInvitationStatus,
OrganizationRoleResourceScopeRelations,
Scopes,
} from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';

Expand Down Expand Up @@ -214,6 +216,13 @@ export default class OrganizationQueries extends SchemaQueries<
OrganizationRoles,
OrganizationScopes
),
/** Queries for organization role - organization resource scope relations. */
rolesResourceScopes: new TwoRelationsQueries(
this.pool,
OrganizationRoleResourceScopeRelations.table,
OrganizationRoles,
Scopes
),
/** Queries for organization - user relations. */
users: new UserRelationQueries(this.pool),
/** Queries for organization - organization role - user relations. */
Expand Down
72 changes: 72 additions & 0 deletions packages/core/src/routes/organization/roles.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,78 @@
}
}
}
},
"/api/organization-roles/{id}/resource-scopes": {
"get": {
"summary": "Get organization role resource scopes",
"description": "Get all resource scopes that are assigned to the specified organization role.",
"responses": {
"200": {
"description": "A list of resource scopes."
}
}
},
"post": {
"summary": "Assign resource scopes to organization role",
"description": "Assign resource scopes to the specified organization role",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"scopeIds": {
"description": "An array of resource scope IDs to be assigned. Existed scope IDs assignments will be ignored."
}
}
}
}
}
},
"responses": {
"201": {
"description": "Resource scopes were assigned successfully."
},
"422": {
"description": "At least one of the IDs provided is invalid. For example, the resource scope ID does not exist;"
}
}
},
"put": {
"summary": "Replace resource scopes for organization role",
"description": "Replace all resource scopes that are assigned to the specified organization role with the given resource scopes. This effectively removes all existing organization scope assignments and replaces them with the new ones.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"scopeIds": {
"description": "An array of resource scope IDs to replace existing scopes."
}
}
}
}
}
},
"responses": {
"204": {
"description": "Resource scopes were replaced successfully."
},
"422": {
"description": "At least one of the IDs provided is invalid. For example, the resource scope ID does not exist."
}
}
}
},
"/api/organization-roles/{id}/resource-scopes/{scopeId}": {
"delete": {
"summary": "Remove resource scope",
"description": "Remove a resource scope assignment from the specified organization role.",
"responses": {
"204": {
"description": "Resource scope assignment was removed successfully."
}
}
}
}
}
}
6 changes: 5 additions & 1 deletion packages/core/src/routes/organization/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { generateStandardId } from '@logto/shared';
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 koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
Expand All @@ -22,7 +23,7 @@ export default function organizationRoleRoutes<T extends AuthedRouter>(
queries: {
organizations: {
roles,
relations: { rolesScopes },
relations: { rolesScopes, rolesResourceScopes },
},
},
libraries: { quota },
Expand Down Expand Up @@ -89,6 +90,9 @@ export default function organizationRoleRoutes<T extends AuthedRouter>(
);

router.addRelationRoutes(rolesScopes, 'scopes');
if (EnvSet.values.isDevFeaturesEnabled) {
router.addRelationRoutes(rolesResourceScopes, 'resource-scopes');
}

originalRouter.use(router.routes());
}
1 change: 1 addition & 0 deletions packages/integration-tests/src/api/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ 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
15 changes: 15 additions & 0 deletions packages/integration-tests/src/api/organization-role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type OrganizationScope,
type OrganizationRole,
type OrganizationRoleWithScopes,
type Scope,
} from '@logto/schemas';

import { authedAdminApi } from './api.js';
Expand Down Expand Up @@ -44,4 +45,18 @@ export class OrganizationRoleApi extends ApiFactory<
async deleteScope(id: string, scopeId: string): Promise<void> {
await authedAdminApi.delete(`${this.path}/${id}/scopes/${scopeId}`);
}

async addResourceScopes(id: string, scopeIds: string[]): Promise<void> {
await authedAdminApi.post(`${this.path}/${id}/resource-scopes`, { json: { scopeIds } });
}

async getResourceScopes(id: string, searchParams?: URLSearchParams): Promise<Scope[]> {
return authedAdminApi
.get(`${this.path}/${id}/resource-scopes`, { searchParams })
.json<Scope[]>();
}

async deleteResourceScope(id: string, scopeId: string): Promise<void> {
await authedAdminApi.delete(`${this.path}/${id}/resource-scopes/${scopeId}`);
}
}
50 changes: 50 additions & 0 deletions packages/integration-tests/src/helpers/resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type Scope } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';

import { createResource, deleteResource } from '#src/api/resource.js';
import { createScope, deleteScope } from '#src/api/scope.js';

export class ScopeApiTest {
#scopes: Scope[] = [];
#resourceId?: string;

/**
* Initialize the resource, scopes will be created under this resource.
*/
async initResource(): Promise<void> {
const resource = await createResource();
this.#resourceId = resource.id;
}

get scopes(): Scope[] {
return this.#scopes;
}

async create(data: { name: string }): Promise<Scope> {
if (!this.#resourceId) {
throw new Error('Resource is not initialized');
}

const created = await createScope(this.#resourceId, data.name);
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
this.scopes.push(created);
return created;
}

/**
* Delete all created scopes and the resource. This method will ignore errors when deleting scopes to avoid error
* when they are deleted by other tests.
*/
async cleanUp(): Promise<void> {
// Use `trySafe` to avoid error when scope is deleted by other tests.
await Promise.all(
this.scopes.map(
async (scope) => this.#resourceId && trySafe(deleteScope(this.#resourceId, scope.id))
)
);
this.#scopes = [];

await trySafe(async () => this.#resourceId && deleteResource(this.#resourceId));
this.#resourceId = undefined;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isKeyInObject, pick } from '@silverhand/essentials';
import { HTTPError } from 'ky';

import { OrganizationRoleApiTest, OrganizationScopeApiTest } from '#src/helpers/organization.js';
import { ScopeApiTest } from '#src/helpers/resource.js';

const randomId = () => generateStandardId(4);

Expand Down Expand Up @@ -250,4 +251,115 @@ describe('organization role APIs', () => {
expect(response.response.status).toBe(404);
});
});

describe('organization role - resource scope relations', () => {
const roleApi = new OrganizationRoleApiTest();
const scopeApi = new ScopeApiTest();

beforeEach(async () => {
await scopeApi.initResource();
});

afterEach(async () => {
await Promise.all([roleApi.cleanUp(), scopeApi.cleanUp()]);
});

it('should be able to add and get scopes of a role', async () => {
const [role, scope1, scope2] = await Promise.all([
roleApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
]);
await roleApi.addResourceScopes(role.id, [scope1.id, scope2.id]);
const scopes = await roleApi.getResourceScopes(role.id);

expect(scopes).toContainEqual(
expect.objectContaining({
name: scope1.name,
})
);
expect(scopes).toContainEqual(
expect.objectContaining({
name: scope2.name,
})
);
});

it('should safely add scopes to a role when some of them already exist', async () => {
const [role, scope1, scope2] = await Promise.all([
roleApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
]);

await roleApi.addResourceScopes(role.id, [scope1.id, scope2.id]);

await expect(roleApi.addResourceScopes(role.id, [scope2.id])).resolves.not.toThrow();

const scopes = await roleApi.getResourceScopes(role.id);

expect(scopes).toContainEqual(
expect.objectContaining({
name: scope1.name,
})
);
expect(scopes).toContainEqual(
expect.objectContaining({
name: scope2.name,
})
);
});

it('should fail when try to add non-existent scopes to a role', async () => {
const [role, scope1, scope2] = await Promise.all([
roleApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
]);
const response = await roleApi
.addResourceScopes(role.id, [scope1.id, scope2.id, '0'])
.catch((error: unknown) => error);

assert(response instanceof HTTPError);
expect(response.response.status).toBe(422);
expect(await response.response.json()).toMatchObject(
expect.objectContaining({
code: 'entity.relation_foreign_key_not_found',
})
);
});

it('should be able to remove scopes from a role', async () => {
const [role, scope1, scope2] = await Promise.all([
roleApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
]);
await roleApi.addResourceScopes(role.id, [scope1.id, scope2.id]);
await roleApi.deleteResourceScope(role.id, scope1.id);
const scopes = await roleApi.getResourceScopes(role.id);

expect(scopes).not.toContainEqual(
expect.objectContaining({
name: scope1.name,
})
);
expect(scopes).toContainEqual(
expect.objectContaining({
name: scope2.name,
})
);
});

it('should fail when try to remove non-existent scopes from a role', async () => {
const [role] = await Promise.all([roleApi.create({ name: 'test' + randomId() })]);

const response = await roleApi
.deleteResourceScope(role.id, '0')
.catch((error: unknown) => error);

assert(response instanceof HTTPError);
expect(response.response.status).toBe(404);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { sql } from '@silverhand/slonik';

import type { AlterationScript } from '../lib/types/alteration.js';

import { applyTableRls, dropTableRls } from './utils/1704934999-tables.js';

const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
create table organization_role_resource_scope_relations (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
organization_role_id varchar(21) not null
references organization_roles (id) on update cascade on delete cascade,
scope_id varchar(21) not null
references scopes (id) on update cascade on delete cascade,
primary key (tenant_id, organization_role_id, scope_id)
);
`);
await applyTableRls(pool, 'organization_role_resource_scope_relations');
},
down: async (pool) => {
await dropTableRls(pool, 'organization_role_resource_scope_relations');
await pool.query(sql`
drop table organization_role_resource_scope_relations
`);
},
};

export default alteration;
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* init_order = 3 */

/** The relations between organization roles and resource scopes (normal scopes). It indicates which resource scopes are available to which organization roles. */
create table organization_role_resource_scope_relations (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
organization_role_id varchar(21) not null
references organization_roles (id) on update cascade on delete cascade,
scope_id varchar(21) not null
references scopes (id) on update cascade on delete cascade,
primary key (tenant_id, organization_role_id, scope_id)
);

0 comments on commit fed0597

Please sign in to comment.