mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): add api to fetch organization scopes for a user (#5701)
* feat(core): add api to fetch user organization scopes * chore: add openapi.json * fix: integration test * chore: turn off max-lines lint rules for openapi json files * chore: add changeset * refactor: return all scope information instead of just the name
This commit is contained in:
parent
1b4c106603
commit
aacbebcbc8
8 changed files with 98 additions and 7 deletions
7
.changeset/real-camels-cheat.md
Normal file
7
.changeset/real-camels-cheat.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@logto/core": patch
|
||||
---
|
||||
|
||||
Provide management API to fetch user organization scopes based on user organization roles
|
||||
|
||||
- GET `organizations/:id/users/:userId/scopes`
|
|
@ -148,6 +148,14 @@
|
|||
"import/no-unused-modules": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.openapi.json"
|
||||
],
|
||||
"rules": {
|
||||
"max-lines": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/include.d/oidc-provider/**/*"
|
||||
|
|
|
@ -307,9 +307,9 @@ describe('organization token grant', () => {
|
|||
Sinon.stub(tenant.queries.organizations.relations.users, 'exists').resolves(true);
|
||||
Sinon.stub(tenant.queries.applications, 'findApplicationById').resolves(mockApplication);
|
||||
Sinon.stub(tenant.queries.organizations.relations.rolesUsers, 'getUserScopes').resolves([
|
||||
{ id: 'foo', name: 'foo' },
|
||||
{ id: 'bar', name: 'bar' },
|
||||
{ id: 'baz', name: 'baz' },
|
||||
{ tenantId: 'default', id: 'foo', name: 'foo', description: 'foo' },
|
||||
{ tenantId: 'default', id: 'bar', name: 'bar', description: 'bar' },
|
||||
{ tenantId: 'default', id: 'baz', name: 'baz', description: 'baz' },
|
||||
]);
|
||||
|
||||
const entityStub = Sinon.stub(ctx.oidc, 'entity');
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
type OrganizationWithRoles,
|
||||
type UserWithOrganizationRoles,
|
||||
type FeaturedUser,
|
||||
type OrganizationScopeEntity,
|
||||
type OrganizationScope,
|
||||
} from '@logto/schemas';
|
||||
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
|
||||
|
||||
|
@ -178,14 +178,14 @@ export class RoleUserRelationQueries extends RelationQueries<
|
|||
async getUserScopes(
|
||||
organizationId: string,
|
||||
userId: string
|
||||
): Promise<readonly OrganizationScopeEntity[]> {
|
||||
): Promise<readonly OrganizationScope[]> {
|
||||
const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true);
|
||||
const roleScopeRelations = convertToIdentifiers(OrganizationRoleScopeRelations, true);
|
||||
const scopes = convertToIdentifiers(OrganizationScopes, true);
|
||||
|
||||
return this.pool.any<OrganizationScopeEntity>(sql`
|
||||
return this.pool.any<OrganizationScope>(sql`
|
||||
select distinct on (${scopes.fields.id})
|
||||
${scopes.fields.id}, ${scopes.fields.name}
|
||||
${sql.join(Object.values(scopes.fields), sql`, `)}
|
||||
from ${this.table}
|
||||
join ${roleScopeRelations.table}
|
||||
on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId}
|
||||
|
|
|
@ -288,6 +288,20 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/organizations/{id}/users/{userId}/scopes": {
|
||||
"get": {
|
||||
"summary": "Get scopes for a user in an organization tailored by the organization roles",
|
||||
"description": "Get scopes assigned to a user in the specified organization tailored by the organization roles. The scopes are derived from the organization roles assigned to the user.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A list of scopes assigned to the user."
|
||||
},
|
||||
"422": {
|
||||
"description": "The user is not a member of the organization."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
Organizations,
|
||||
featuredUserGuard,
|
||||
userWithOrganizationRolesGuard,
|
||||
OrganizationScopes,
|
||||
} from '@logto/schemas';
|
||||
import { yes } from '@silverhand/essentials';
|
||||
import { z } from 'zod';
|
||||
|
@ -235,6 +236,23 @@ export default function organizationRoutes<T extends AuthedRouter>(...args: Rout
|
|||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/users/:userId/scopes',
|
||||
koaGuard({
|
||||
params: z.object(params),
|
||||
response: z.array(OrganizationScopes.guard),
|
||||
status: [200, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id, userId } = ctx.guard.params;
|
||||
|
||||
const scopes = await organizations.relations.rolesUsers.getUserScopes(id, userId);
|
||||
|
||||
ctx.body = scopes;
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
// MARK: Mount sub-routes
|
||||
organizationRoleRoutes(...args);
|
||||
organizationScopeRoutes(...args);
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
type OrganizationWithRoles,
|
||||
type UserWithOrganizationRoles,
|
||||
type OrganizationWithFeatured,
|
||||
type OrganizationScope,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { authedAdminApi } from './api.js';
|
||||
|
@ -67,4 +68,10 @@ export class OrganizationApi extends ApiFactory<
|
|||
async getUserOrganizations(userId: string): Promise<OrganizationWithRoles[]> {
|
||||
return authedAdminApi.get(`users/${userId}/organizations`).json<OrganizationWithRoles[]>();
|
||||
}
|
||||
|
||||
async getUserOrganizationScopes(id: string, userId: string): Promise<OrganizationScope[]> {
|
||||
return authedAdminApi
|
||||
.get(`${this.path}/${id}/users/${userId}/scopes`)
|
||||
.json<OrganizationScope[]>();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -247,4 +247,41 @@ describe('organization user APIs', () => {
|
|||
expect(response instanceof HTTPError && response.response.status).toBe(422); // Require membership
|
||||
});
|
||||
});
|
||||
|
||||
describe('organization - user - organization role - organization scopes relation', () => {
|
||||
it('should be able to get organization scopes for a user with a specific role', async () => {
|
||||
const organizationApi = new OrganizationApiTest();
|
||||
const { roleApi, scopeApi } = organizationApi;
|
||||
const userApi = new UserApiTest();
|
||||
|
||||
const organization = await organizationApi.create({ name: 'test' });
|
||||
const user = await userApi.create({ username: generateTestName() });
|
||||
await organizationApi.addUsers(organization.id, [user.id]);
|
||||
|
||||
const [role1, role2] = await Promise.all([
|
||||
roleApi.create({ name: generateTestName() }),
|
||||
roleApi.create({ name: generateTestName() }),
|
||||
]);
|
||||
const [scope1, scope2] = await Promise.all([
|
||||
scopeApi.create({ name: generateTestName() }),
|
||||
scopeApi.create({ name: generateTestName() }),
|
||||
]);
|
||||
|
||||
// Assign scope1 and scope2 to role1
|
||||
await roleApi.addScopes(role1.id, [scope1.id, scope2.id]);
|
||||
// Assign scope1 to role2
|
||||
await roleApi.addScopes(role2.id, [scope1.id]);
|
||||
|
||||
// Assign role1 to user
|
||||
await organizationApi.addUserRoles(organization.id, user.id, [role1.id]);
|
||||
const scopes = await organizationApi.getUserOrganizationScopes(organization.id, user.id);
|
||||
expect(scopes.map(({ name }) => name)).toMatchObject([scope1.name, scope2.name]);
|
||||
|
||||
// Remove role1 and assign role2 to user
|
||||
await organizationApi.deleteUserRole(organization.id, user.id, role1.id);
|
||||
await organizationApi.addUserRoles(organization.id, user.id, [role2.id]);
|
||||
const newScopes = await organizationApi.getUserOrganizationScopes(organization.id, user.id);
|
||||
expect(newScopes.map(({ name }) => name)).toEqual([scope1.name]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue