mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -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"
|
"import/no-unused-modules": "off"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"*.openapi.json"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"max-lines": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"files": [
|
"files": [
|
||||||
"src/include.d/oidc-provider/**/*"
|
"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.organizations.relations.users, 'exists').resolves(true);
|
||||||
Sinon.stub(tenant.queries.applications, 'findApplicationById').resolves(mockApplication);
|
Sinon.stub(tenant.queries.applications, 'findApplicationById').resolves(mockApplication);
|
||||||
Sinon.stub(tenant.queries.organizations.relations.rolesUsers, 'getUserScopes').resolves([
|
Sinon.stub(tenant.queries.organizations.relations.rolesUsers, 'getUserScopes').resolves([
|
||||||
{ id: 'foo', name: 'foo' },
|
{ tenantId: 'default', id: 'foo', name: 'foo', description: 'foo' },
|
||||||
{ id: 'bar', name: 'bar' },
|
{ tenantId: 'default', id: 'bar', name: 'bar', description: 'bar' },
|
||||||
{ id: 'baz', name: 'baz' },
|
{ tenantId: 'default', id: 'baz', name: 'baz', description: 'baz' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const entityStub = Sinon.stub(ctx.oidc, 'entity');
|
const entityStub = Sinon.stub(ctx.oidc, 'entity');
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
type OrganizationWithRoles,
|
type OrganizationWithRoles,
|
||||||
type UserWithOrganizationRoles,
|
type UserWithOrganizationRoles,
|
||||||
type FeaturedUser,
|
type FeaturedUser,
|
||||||
type OrganizationScopeEntity,
|
type OrganizationScope,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
|
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
|
||||||
|
|
||||||
|
@ -178,14 +178,14 @@ export class RoleUserRelationQueries extends RelationQueries<
|
||||||
async getUserScopes(
|
async getUserScopes(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<readonly OrganizationScopeEntity[]> {
|
): Promise<readonly OrganizationScope[]> {
|
||||||
const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true);
|
const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true);
|
||||||
const roleScopeRelations = convertToIdentifiers(OrganizationRoleScopeRelations, true);
|
const roleScopeRelations = convertToIdentifiers(OrganizationRoleScopeRelations, true);
|
||||||
const scopes = convertToIdentifiers(OrganizationScopes, true);
|
const scopes = convertToIdentifiers(OrganizationScopes, true);
|
||||||
|
|
||||||
return this.pool.any<OrganizationScopeEntity>(sql`
|
return this.pool.any<OrganizationScope>(sql`
|
||||||
select distinct on (${scopes.fields.id})
|
select distinct on (${scopes.fields.id})
|
||||||
${scopes.fields.id}, ${scopes.fields.name}
|
${sql.join(Object.values(scopes.fields), sql`, `)}
|
||||||
from ${this.table}
|
from ${this.table}
|
||||||
join ${roleScopeRelations.table}
|
join ${roleScopeRelations.table}
|
||||||
on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId}
|
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,
|
Organizations,
|
||||||
featuredUserGuard,
|
featuredUserGuard,
|
||||||
userWithOrganizationRolesGuard,
|
userWithOrganizationRolesGuard,
|
||||||
|
OrganizationScopes,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { yes } from '@silverhand/essentials';
|
import { yes } from '@silverhand/essentials';
|
||||||
import { z } from 'zod';
|
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
|
// MARK: Mount sub-routes
|
||||||
organizationRoleRoutes(...args);
|
organizationRoleRoutes(...args);
|
||||||
organizationScopeRoutes(...args);
|
organizationScopeRoutes(...args);
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
type OrganizationWithRoles,
|
type OrganizationWithRoles,
|
||||||
type UserWithOrganizationRoles,
|
type UserWithOrganizationRoles,
|
||||||
type OrganizationWithFeatured,
|
type OrganizationWithFeatured,
|
||||||
|
type OrganizationScope,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
|
||||||
import { authedAdminApi } from './api.js';
|
import { authedAdminApi } from './api.js';
|
||||||
|
@ -67,4 +68,10 @@ export class OrganizationApi extends ApiFactory<
|
||||||
async getUserOrganizations(userId: string): Promise<OrganizationWithRoles[]> {
|
async getUserOrganizations(userId: string): Promise<OrganizationWithRoles[]> {
|
||||||
return authedAdminApi.get(`users/${userId}/organizations`).json<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
|
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