Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): add api to fetch organization scopes for a user #5701

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/real-camels-cheat.md
Original file line number Diff line number Diff line change
@@ -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`
8 changes: 8 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@
"import/no-unused-modules": "off"
}
},
{
"files": [
"*.openapi.json"
],
"rules": {
"max-lines": "off"
}
},
{
"files": [
"src/include.d/oidc-provider/**/*"
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/oidc/grants/refresh-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/queries/organization/relations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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}
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/routes/organization/index.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
}
}
}
18 changes: 18 additions & 0 deletions packages/core/src/routes/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Organizations,
featuredUserGuard,
userWithOrganizationRolesGuard,
OrganizationScopes,
} from '@logto/schemas';
import { yes } from '@silverhand/essentials';
import { z } from 'zod';
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions packages/integration-tests/src/api/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type OrganizationWithRoles,
type UserWithOrganizationRoles,
type OrganizationWithFeatured,
type OrganizationScope,
} from '@logto/schemas';

import { authedAdminApi } from './api.js';
Expand Down Expand Up @@ -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[]>();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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