mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
Merge pull request #4657 from logto-io/gao-org-apis-4
feat(core): role - scope relation apis
This commit is contained in:
commit
d69a73617e
6 changed files with 375 additions and 115 deletions
|
@ -28,8 +28,8 @@ export default class OrganizationQueries extends SchemaQueries<
|
|||
rolesScopes: new RelationQueries(
|
||||
this.pool,
|
||||
OrganizationRoleScopeRelations.table,
|
||||
OrganizationRoles.table,
|
||||
OrganizationScopes.table
|
||||
OrganizationRoles,
|
||||
OrganizationScopes
|
||||
),
|
||||
};
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import {
|
|||
type OrganizationRole,
|
||||
type OrganizationRoleKeys,
|
||||
OrganizationRoles,
|
||||
OrganizationScopes,
|
||||
} from '@logto/schemas';
|
||||
import { UniqueIntegrityConstraintViolationError } from 'slonik';
|
||||
import { z } from 'zod';
|
||||
|
@ -82,5 +83,63 @@ export default function organizationRoleRoutes<T extends AuthedRouter>(
|
|||
}
|
||||
);
|
||||
|
||||
// MARK: Role - scope relations routes
|
||||
router.get(
|
||||
'/:id/scopes',
|
||||
koaGuard({
|
||||
params: z.object({ id: z.string().min(1) }),
|
||||
response: OrganizationScopes.guard.array(),
|
||||
status: [200, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id } = ctx.guard.params;
|
||||
|
||||
// Ensure that role exists
|
||||
await actions.getById(id);
|
||||
|
||||
ctx.body = await rolesScopes.getEntries(OrganizationScopes, { organizationRoleId: id });
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/scopes',
|
||||
koaGuard({
|
||||
params: z.object({ id: z.string().min(1) }),
|
||||
body: z.object({ scopeIds: z.string().min(1).array().nonempty() }),
|
||||
response: OrganizationScopes.guard.array(),
|
||||
status: [200, 404, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { id },
|
||||
body: { scopeIds },
|
||||
} = ctx.guard;
|
||||
|
||||
await rolesScopes.insert(...scopeIds.map<[string, string]>((scopeId) => [id, scopeId]));
|
||||
|
||||
ctx.body = await rolesScopes.getEntries(OrganizationScopes, { organizationRoleId: id });
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id/scopes/:scopeId',
|
||||
koaGuard({
|
||||
params: z.object({ id: z.string().min(1), scopeId: z.string().min(1) }),
|
||||
status: [204, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { id, scopeId },
|
||||
} = ctx.guard;
|
||||
|
||||
await rolesScopes.delete({ organizationRoleId: id, organizationScopeId: scopeId });
|
||||
|
||||
ctx.status = 204;
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
originalRouter.use(router.routes());
|
||||
}
|
||||
|
|
|
@ -1,21 +1,35 @@
|
|||
import pluralize from 'pluralize';
|
||||
import { type KeysToCamelCase } from '@silverhand/essentials';
|
||||
import { sql, type CommonQueryMethods } from 'slonik';
|
||||
import snakecaseKeys from 'snakecase-keys';
|
||||
import { type z } from 'zod';
|
||||
|
||||
type AtLeast2<T extends unknown[]> = `${T['length']}` extends '0' | '1' ? never : T;
|
||||
|
||||
type RemoveLiteral<T extends string, L extends string> = T extends L ? Exclude<T, L> : T;
|
||||
type TableInfo<Table, TableSingular, Schema> = {
|
||||
table: Table;
|
||||
tableSingular: TableSingular;
|
||||
guard: z.ZodType<Schema, z.ZodTypeDef, unknown>;
|
||||
};
|
||||
|
||||
type InferSchema<T> = T extends TableInfo<infer _, infer _, infer Schema> ? Schema : never;
|
||||
|
||||
type CamelCaseIdObject<T extends string> = KeysToCamelCase<{
|
||||
[Key in `${T}_id`]: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Query class for relation tables that connect several tables by their entry ids.
|
||||
*
|
||||
* @example
|
||||
* Let's say we have two tables `users` and `groups` and a relation table
|
||||
* `user_group_relations`. Then we can create a `RelationQueries` instance like this:
|
||||
*
|
||||
* ```ts
|
||||
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', 'users', 'groups');
|
||||
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', Users, Groups);
|
||||
* ```
|
||||
*
|
||||
* `Users` and `Groups` are the schemas of the tables that satisfy the {@link TableInfo}
|
||||
* interface. The generated schemas in `@logto/schemas` satisfy this interface.
|
||||
*
|
||||
* To insert a new relation, we can use the {@link RelationQueries.insert} method:
|
||||
*
|
||||
* ```ts
|
||||
|
@ -30,33 +44,34 @@ type RemoveLiteral<T extends string, L extends string> = T extends L ? Exclude<T
|
|||
* To get all entries for a specific table, we can use the {@link RelationQueries.getEntries} method:
|
||||
*
|
||||
* ```ts
|
||||
* await userGroupRelations.getEntries('users', { group_id: 'group-id-1' });
|
||||
* await userGroupRelations.getEntries(Users, { groupId: 'group-id-1' });
|
||||
* ```
|
||||
*
|
||||
* This will return all entries for the `users` table that are connected to the
|
||||
* group with the id `group-id-1`.
|
||||
*/
|
||||
export default class RelationQueries<
|
||||
SnakeCaseRelations extends Array<Lowercase<string>>,
|
||||
Length = AtLeast2<SnakeCaseRelations>['length'],
|
||||
Schemas extends Array<TableInfo<string, string, unknown>>,
|
||||
Length = AtLeast2<Schemas>['length'],
|
||||
> {
|
||||
protected get table() {
|
||||
return sql.identifier([this.relationTable]);
|
||||
}
|
||||
|
||||
public readonly relations: SnakeCaseRelations;
|
||||
public readonly schemas: Schemas;
|
||||
|
||||
/**
|
||||
* @param pool The database pool.
|
||||
* @param relationTable The name of the relation table.
|
||||
* @param relations The names of the tables that are connected by the relation table.
|
||||
* @param relations The schemas of the tables that are connected by the relation table.
|
||||
* @see {@link TableInfo} for more information about the schemas.
|
||||
*/
|
||||
constructor(
|
||||
public readonly pool: CommonQueryMethods,
|
||||
public readonly relationTable: string,
|
||||
...relations: Readonly<SnakeCaseRelations>
|
||||
...schemas: Readonly<Schemas>
|
||||
) {
|
||||
this.relations = relations;
|
||||
this.schemas = schemas;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -67,7 +82,7 @@ export default class RelationQueries<
|
|||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', 'users', 'groups');
|
||||
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', Users, Groups);
|
||||
*
|
||||
* userGroupRelations.insert(['user-id-1', 'group-id-1']);
|
||||
* // Insert multiple relations at once
|
||||
|
@ -83,7 +98,7 @@ export default class RelationQueries<
|
|||
async insert(...data: ReadonlyArray<string[] & { length: Length }>) {
|
||||
return this.pool.query(sql`
|
||||
insert into ${this.table} (${sql.join(
|
||||
this.relations.map((relation) => sql.identifier([pluralize(relation, 1) + '_id'])),
|
||||
this.schemas.map(({ tableSingular }) => sql.identifier([tableSingular + '_id'])),
|
||||
sql`, `
|
||||
)})
|
||||
values ${sql.join(
|
||||
|
@ -99,10 +114,69 @@ export default class RelationQueries<
|
|||
`);
|
||||
}
|
||||
|
||||
async getEntries<L extends SnakeCaseRelations[number]>(
|
||||
forRelation: L,
|
||||
where: Record<RemoveLiteral<SnakeCaseRelations[number], L>, unknown>
|
||||
) {
|
||||
throw new Error('Not implemented');
|
||||
/**
|
||||
* Delete a relation from the relation table.
|
||||
*
|
||||
* @param data The ids of the entries to delete. The keys must be in camel case
|
||||
* and end with `Id`.
|
||||
* @returns A Promise that resolves to the query result.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', Users, Groups);
|
||||
* userGroupRelations.delete({ userId: 'user-id-1', groupId: 'group-id-1' });
|
||||
* ```
|
||||
*/
|
||||
async delete(data: CamelCaseIdObject<Schemas[number]['tableSingular']>) {
|
||||
const snakeCaseData = snakecaseKeys(data);
|
||||
return this.pool.query(sql`
|
||||
delete from ${this.table}
|
||||
where ${sql.join(
|
||||
Object.entries(snakeCaseData).map(
|
||||
([column, value]) => sql`${sql.identifier([column])} = ${value}`
|
||||
),
|
||||
sql` and `
|
||||
)};
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entries for a specific schema that are connected to the given ids.
|
||||
*
|
||||
* @param forSchema The schema to get the entries for.
|
||||
* @param where Other ids to filter the entries by. The keys must be in camel case
|
||||
* and end with `Id`.
|
||||
* @returns A Promise that resolves an array of entries of the given schema.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', Users, Groups);
|
||||
*
|
||||
* userGroupRelations.getEntries(Users, { groupId: 'group-id-1' });
|
||||
* ```
|
||||
*/
|
||||
async getEntries<S extends Schemas[number]>(
|
||||
forSchema: S,
|
||||
where: CamelCaseIdObject<Exclude<Schemas[number]['tableSingular'], S['tableSingular']>>
|
||||
): Promise<ReadonlyArray<InferSchema<S>>> {
|
||||
const snakeCaseWhere = snakecaseKeys(where);
|
||||
const forTable = sql.identifier([forSchema.table]);
|
||||
|
||||
const { rows } = await this.pool.query<InferSchema<S>>(sql`
|
||||
select ${forTable}.*
|
||||
from ${this.table}
|
||||
join ${forTable} on ${sql.identifier([
|
||||
this.relationTable,
|
||||
forSchema.tableSingular + '_id',
|
||||
])} = ${forTable}.id
|
||||
where ${sql.join(
|
||||
Object.entries(snakeCaseWhere).map(
|
||||
([column, value]) => sql`${sql.identifier([column])} = ${value}`
|
||||
),
|
||||
sql` and `
|
||||
)};
|
||||
`);
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ export class SchemaActions<
|
|||
*
|
||||
* @param id The ID of the entity to be fetched.
|
||||
* @returns The entity to be returned.
|
||||
* @throws An `RequestError` with 404 status code if the entity is not found.
|
||||
*/
|
||||
public async getById(id: string): Promise<Readonly<Schema>> {
|
||||
return this.queries.findById(id);
|
||||
|
@ -63,6 +64,7 @@ export class SchemaActions<
|
|||
* @param id The ID of the entity to be updated.
|
||||
* @param data The data of the entity to be updated.
|
||||
* @returns The updated entity.
|
||||
* @throws An `RequestError` with 404 status code if the entity is not found.
|
||||
*/
|
||||
public async patchById(id: string, data: Partial<Schema>): Promise<Readonly<Schema>> {
|
||||
return this.queries.updateById(id, data);
|
||||
|
@ -72,6 +74,7 @@ export class SchemaActions<
|
|||
* The function for `DELETE /:id` route to delete an entity by ID.
|
||||
*
|
||||
* @param id The ID of the entity to be deleted.
|
||||
* @throws An `RequestError` with 404 status code if the entity is not found.
|
||||
*/
|
||||
public async deleteById(id: string): Promise<void> {
|
||||
return this.queries.deleteById(id);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { type OrganizationRole } from '@logto/schemas';
|
||||
import { type OrganizationScope, type OrganizationRole } from '@logto/schemas';
|
||||
|
||||
import { authedAdminApi } from './api.js';
|
||||
import { ApiFactory } from './factory.js';
|
||||
|
||||
class OrganizationRoleApi extends ApiFactory<
|
||||
|
@ -9,6 +10,18 @@ class OrganizationRoleApi extends ApiFactory<
|
|||
constructor() {
|
||||
super('organization-roles');
|
||||
}
|
||||
|
||||
async addScopes(id: string, scopeIds: string[]): Promise<void> {
|
||||
await authedAdminApi.post(`${this.path}/${id}/scopes`, { json: { scopeIds } });
|
||||
}
|
||||
|
||||
async getScopes(id: string): Promise<OrganizationScope[]> {
|
||||
return authedAdminApi.get(`${this.path}/${id}/scopes`).json<OrganizationScope[]>();
|
||||
}
|
||||
|
||||
async deleteScope(id: string, scopeId: string): Promise<void> {
|
||||
await authedAdminApi.delete(`${this.path}/${id}/scopes/${scopeId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const roleApi = new OrganizationRoleApi();
|
||||
|
|
|
@ -9,10 +9,12 @@ import { scopeApi } from '#src/api/organization-scope.js';
|
|||
|
||||
const randomId = () => generateStandardId(4);
|
||||
|
||||
// Add additional layer of describe to run tests in band
|
||||
describe('organization role APIs', () => {
|
||||
describe('organization roles', () => {
|
||||
it('should fail if the name of the new organization role already exists', async () => {
|
||||
const name = 'test' + randomId();
|
||||
await roleApi.create({ name });
|
||||
const createdRole = await roleApi.create({ name });
|
||||
const response = await roleApi.create({ name }).catch((error: unknown) => error);
|
||||
|
||||
assert(response instanceof HTTPError);
|
||||
|
@ -20,7 +22,11 @@ describe('organization roles', () => {
|
|||
const { statusCode, body: raw } = response.response;
|
||||
const body: unknown = JSON.parse(String(raw));
|
||||
expect(statusCode).toBe(400);
|
||||
expect(isKeyInObject(body, 'code') && body.code).toBe('entity.duplicate_value_of_unique_field');
|
||||
expect(isKeyInObject(body, 'code') && body.code).toBe(
|
||||
'entity.duplicate_value_of_unique_field'
|
||||
);
|
||||
|
||||
await roleApi.delete(createdRole.id);
|
||||
});
|
||||
|
||||
it('should be able to create a role with some scopes', async () => {
|
||||
|
@ -38,25 +44,41 @@ describe('organization roles', () => {
|
|||
})
|
||||
);
|
||||
|
||||
// TODO: Check scopes under a role after API is implemented
|
||||
// Check scopes under a role after API is implemented
|
||||
const scopes = await roleApi.getScopes(role.id);
|
||||
expect(scopes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: scope1.name,
|
||||
})
|
||||
);
|
||||
expect(scopes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: scope2.name,
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all([scopeApi.delete(scope1.id), scopeApi.delete(scope2.id)]);
|
||||
});
|
||||
|
||||
it('should get organization roles successfully', async () => {
|
||||
const [name1, name2] = ['test' + randomId(), 'test' + randomId()];
|
||||
await roleApi.create({ name: name1, description: 'A test organization role.' });
|
||||
await roleApi.create({ name: name2 });
|
||||
const createdRoles = await Promise.all([
|
||||
roleApi.create({ name: name1, description: 'A test organization role.' }),
|
||||
roleApi.create({ name: name2 }),
|
||||
]);
|
||||
const roles = await roleApi.getList();
|
||||
|
||||
expect(roles).toContainEqual(
|
||||
expect.objectContaining({ name: name1, description: 'A test organization role.' })
|
||||
);
|
||||
expect(roles).toContainEqual(expect.objectContaining({ name: name2, description: null }));
|
||||
|
||||
await Promise.all(createdRoles.map(async (role) => roleApi.delete(role.id)));
|
||||
});
|
||||
|
||||
it('should get organization roles with pagination', async () => {
|
||||
// Add 20 roles to exceed the default page size
|
||||
await Promise.all(
|
||||
const allRoles = await Promise.all(
|
||||
Array.from({ length: 30 }).map(async () => roleApi.create({ name: 'test' + randomId() }))
|
||||
);
|
||||
|
||||
|
@ -72,6 +94,8 @@ describe('organization roles', () => {
|
|||
expect(roles2.length).toBeGreaterThanOrEqual(10);
|
||||
expect(roles2[0]?.id).not.toBeFalsy();
|
||||
expect(roles2[0]?.id).toBe(roles[10]?.id);
|
||||
|
||||
await Promise.all(allRoles.map(async (role) => roleApi.delete(role.id)));
|
||||
});
|
||||
|
||||
it('should be able to create and get organization roles by id', async () => {
|
||||
|
@ -79,6 +103,7 @@ describe('organization roles', () => {
|
|||
const role = await roleApi.get(createdRole.id);
|
||||
|
||||
expect(role).toStrictEqual(createdRole);
|
||||
await roleApi.delete(createdRole.id);
|
||||
});
|
||||
|
||||
it('should fail when try to get an organization role that does not exist', async () => {
|
||||
|
@ -99,6 +124,7 @@ describe('organization roles', () => {
|
|||
name: newName,
|
||||
description: 'test description.',
|
||||
});
|
||||
await roleApi.delete(createdRole.id);
|
||||
});
|
||||
|
||||
it('should be able to delete organization role', async () => {
|
||||
|
@ -113,3 +139,88 @@ describe('organization roles', () => {
|
|||
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('organization role - scope relations', () => {
|
||||
it('should be able to 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.addScopes(role.id, [scope1.id, scope2.id]);
|
||||
const scopes = await roleApi.getScopes(role.id);
|
||||
|
||||
expect(scopes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: scope1.name,
|
||||
})
|
||||
);
|
||||
expect(scopes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: scope2.name,
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
roleApi.delete(role.id),
|
||||
scopeApi.delete(scope1.id),
|
||||
scopeApi.delete(scope2.id),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should be able to add 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() }),
|
||||
]);
|
||||
await roleApi.addScopes(role.id, [scope1.id, scope2.id]);
|
||||
const scopes = await roleApi.getScopes(role.id);
|
||||
|
||||
expect(scopes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: scope1.name,
|
||||
})
|
||||
);
|
||||
expect(scopes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: scope2.name,
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
roleApi.delete(role.id),
|
||||
scopeApi.delete(scope1.id),
|
||||
scopeApi.delete(scope2.id),
|
||||
]);
|
||||
});
|
||||
|
||||
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.addScopes(role.id, [scope1.id, scope2.id]);
|
||||
await roleApi.deleteScope(role.id, scope1.id);
|
||||
const scopes = await roleApi.getScopes(role.id);
|
||||
|
||||
expect(scopes).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: scope1.name,
|
||||
})
|
||||
);
|
||||
expect(scopes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: scope2.name,
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
roleApi.delete(role.id),
|
||||
scopeApi.delete(scope1.id),
|
||||
scopeApi.delete(scope2.id),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue