feat(platform): add Roles service for role and access management - #666
feat(platform): add Roles service for role and access management#666Sarath1018 wants to merge 1 commit into
Conversation
|
| private toEffectiveRole(raw: RawPlatformEffectiveRole): PlatformEffectiveRole { | ||
| const { roleAssignments, ...role } = raw; | ||
| return { | ||
| ...role, |
There was a problem hiding this comment.
toEffectiveRole() skips PlatformRoleTypeMap normalization, but toPrincipalAssignments() (just above, line ~377) applies it to every assignment's roleType. The PR description explicitly calls out that role-type strings are inconsistent across endpoints (BUILTIN/CUSTOM in lists vs BuiltIn/Custom in single reads). If the effective-access endpoint also returns mixed casing, callers comparing role.roleType / assignment.roleType from getEffectiveAccess() with values from getAll() or getAssignments() will see silently inconsistent strings.
Two options:
- Apply the same normalization and type both
PlatformEffectiveRole.roleTypeandPlatformEffectiveRoleAssignment.roleTypeasPlatformRoleType | null:
private toEffectiveRole(raw: RawPlatformEffectiveRole): PlatformEffectiveRole {
const { roleAssignments, roleType, ...rest } = raw;
return {
...rest,
roleType: roleType != null ? (PlatformRoleTypeMap[roleType] ?? roleType) : null,
assignments: (roleAssignments ?? []).map(assignment =>
applyDataTransforms(
transformData({ ...assignment }, PlatformRoleMap) as Record<string, unknown>,
{ field: 'roleType', valueMap: PlatformRoleTypeMap }
) as unknown as PlatformEffectiveRoleAssignment
),
};
}- If live-testing confirmed the effective-access endpoint consistently returns
BUILTIN/CUSTOM(already matching the enum values), leaveroleTypeasstring | nullbut add an inline comment explaining why normalization is intentionally skipped here.
Review summaryOne finding posted:
|
b3f0e24 to
178c896
Compare
| it('should retrieve a role by ID with the transform pipeline applied', async () => { | ||
| mockApiClient.get.mockResolvedValue(createBasicRawPlatformRole()); | ||
|
|
||
| const role = await rolesService.getById(roleId); | ||
|
|
||
| expect(mockApiClient.get).toHaveBeenCalledWith(AUTHORIZATION_ENDPOINTS.ROLE.GET_BY_ID(roleId), {}); | ||
| expect(role.id).toBe(roleId); | ||
| expect(role.createdTime).toBe(PLATFORM_ROLE_TEST_CONSTANTS.CREATED_ON); | ||
| expect((role as any).createdOn).toBeUndefined(); | ||
| expect(typeof role.delete).toBe('function'); | ||
| }); |
There was a problem hiding this comment.
The getById endpoint returns mixed-case role types (BuiltIn/Custom) while list endpoints return all-caps (BUILTIN/CUSTOM) — the PR description explicitly calls this out as a live-API quirk. The mock here uses the default type: 'BUILTIN', which passes through PlatformRoleTypeMap unchanged, so this test never exercises the normalization that getById actually needs. Per convention, mocks for transform completeness tests must use raw wire-format values (not post-transform values), and getAll/getById need separate completeness tests.
Use the single-read wire format to make the assertion meaningful:
| it('should retrieve a role by ID with the transform pipeline applied', async () => { | |
| mockApiClient.get.mockResolvedValue(createBasicRawPlatformRole()); | |
| const role = await rolesService.getById(roleId); | |
| expect(mockApiClient.get).toHaveBeenCalledWith(AUTHORIZATION_ENDPOINTS.ROLE.GET_BY_ID(roleId), {}); | |
| expect(role.id).toBe(roleId); | |
| expect(role.createdTime).toBe(PLATFORM_ROLE_TEST_CONSTANTS.CREATED_ON); | |
| expect((role as any).createdOn).toBeUndefined(); | |
| expect(typeof role.delete).toBe('function'); | |
| }); | |
| it('should retrieve a role by ID with the transform pipeline applied', async () => { | |
| // getById returns mixed-case type strings (live-API quirk) — use the single-read wire format | |
| mockApiClient.get.mockResolvedValue(createBasicRawPlatformRole({ type: 'BuiltIn' })); | |
| const role = await rolesService.getById(roleId); | |
| expect(mockApiClient.get).toHaveBeenCalledWith(AUTHORIZATION_ENDPOINTS.ROLE.GET_BY_ID(roleId), {}); | |
| expect(role.id).toBe(roleId); | |
| expect(role.createdTime).toBe(PLATFORM_ROLE_TEST_CONSTANTS.CREATED_ON); | |
| expect((role as any).createdOn).toBeUndefined(); | |
| // PlatformRoleTypeMap normalizes 'BuiltIn' (single-read wire) → PlatformRoleType.BuiltIn ('BUILTIN') | |
| expect(role.type).toBe(PlatformRoleType.BuiltIn); | |
| expect(typeof role.delete).toBe('function'); | |
| }); |
Review summaryOne new finding posted (lines 130-140 of the test file, getById transform test does not exercise mixed-case normalization). The PR documents that single-read endpoints return BuiltIn/Custom while list endpoints return BUILTIN/CUSTOM. The getById transform completeness test uses the default mock (type: 'BUILTIN'), which passes through PlatformRoleTypeMap unchanged so the key normalization code path for getById is never exercised. The fix is to supply type: 'BuiltIn' in the mock and assert role.type === PlatformRoleType.BuiltIn. The pre-existing unresolved thread on src/services/platform/roles.ts line 394 (toEffectiveRole skipping PlatformRoleTypeMap normalization) is still open and was not re-raised. |
Adds PlatformRoleService (exported as Roles) to the /platform subpath,
backed by the Authorization service (org-level routing, new
AUTHORIZATION_BASE):
- getAll(options?) / getById(roleId) — roles with their permissions
(actionDetails); paged or fetch-all
- upsert(request) — create-or-update custom roles; the API returns only
{createdRoleId}, so the service follows up with a read (shared
private fetchRole keeps getById/upsert singly tracked); bound
role.delete()
- deleteById(roleId)
- getAssignments(scope, options?) — assignments grouped by principal;
scope is API-required ('/' = whole organization)
- updateAssignments(changes) — atomic add/remove batch
- exportAssignments() — CSV via blob response
- getEffectiveAccess(request) — a principal's effective roles in a
tenant, envelope reshaped (roles/assignments/grantedServices/
grantedRoles)
- getActions(options?) — permission definitions catalog
Live-verified API quirks encoded: actionsGrantedByRole takes action
names (not GUIDs); roleDescription is server-required; role type
strings are inconsistent across endpoints (BUILTIN vs BuiltIn —
normalized to the enum); userroleassignments caps top at 10 while
roles allows 1000 (separate max-page-size constants); createdOn
renamed to createdTime throughout.
Verified against the live API: 2444 unit tests passing, integration
8/8 (role lifecycle, assignment grant/revoke, CSV export, effective
access), build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
178c896 to
18464c2
Compare
| expect(spec.params.securityPrincipalId).toBe(PLATFORM_USER_TEST_CONSTANTS.USER_ID); | ||
| expect(spec.params.top).toBe(10); | ||
| expect(spec.params).not.toHaveProperty('$scope'); | ||
| }); |
There was a problem hiding this comment.
The paginated path for getAssignments is missing a companion test for roleIds. The fetch-all path handles roleIds with an explicit conditional spread (opts.roleIds !== undefined && { roleIds: opts.roleIds }) to preserve array serialization — a distinct code path from the paginated route, which relies on PaginationHelpers.getAll() receiving roleIds inside opts. Per the convention: "When sibling methods share a configuration pattern, each method needs its own test for that pattern" — but there's currently no paginated-path test that passes roleIds and asserts it reaches the API call without a $ prefix.
| }); | |
| }); | |
| it('should pass roleIds on the paginated path too', async () => { | |
| mockApiClient.get.mockResolvedValue( | |
| createRawPlatformRoleAssignmentListResponse([createBasicRawPlatformPrincipalRoleAssignments()], 1) | |
| ); | |
| await rolesService.getAssignments('/', { roleIds: [roleId], pageSize: 5 }); | |
| const spec = mockApiClient.get.mock.calls[0][1] as { params: Record<string, unknown> }; | |
| expect(spec.params.roleIds).toEqual([roleId]); | |
| expect(spec.params).not.toHaveProperty('$roleIds'); | |
| }); |
Review summaryOne new finding posted:
The fetch-all path handles The two pre-existing unresolved threads (line 394: |
Summary
PR 4/4 in the platform RBAC stack (Users → Groups → Directory → Roles). Stacked on #665 — merge order: #663, #664, #665, then this; the diff here is Roles-only. Cloudflare whitelist: UiPath/apps-dev-tools#146 (new
pap_base + patterns) must deploy before browser apps can call these.Adds
PlatformRoleService(exported asRoles) to the/platformsubpath, backed by the Authorization service (pap_, organization-level routing via the newAUTHORIZATION_BASE). This completes the RBAC story: put users in Groups, grant Roles to the groups, answer "what can this user do" viagetEffectiveAccess().getAll(options?)GET /pap_/api/rolesactionDetailspermissions on each; filters + paged/fetch-allgetById(roleId)GET /pap_/api/roles/{id}role.delete()upsert(request)PUT /pap_/api/roles{createdRoleId}→ service follows up with a read (shared untrackedfetchRoleavoids double telemetry)deleteById(roleId)DELETE /pap_/api/roles/{id}getAssignments(scope, options?)GET /pap_/api/userroleassignmentsscopeAPI-required (/= org)updateAssignments(changes)PATCH /pap_/api/userroleassignmentstoAdd/toDeletebatchexportAssignments()GET .../exportgetEffectiveAccess(request)POST /pap_/api/geteffectiveaccessroles[](each with grantingassignments),grantedServices,grantedRolesgetActions(options?)GET /pap_/api/actionsactionsGrantedByRolenamesLive-verified API quirks (all encoded with comments + tests)
actionsGrantedByRoletakes fully qualified action names, not GUIDs (400 "actions that do not exist" with GUIDs)roleDescriptionis server-required on upsert → required field per the structurally-required conventionBUILTIN/CUSTOMin lists,BuiltIn/Customin single reads) → normalized toPlatformRoleTypevia a four-casing mapuserroleassignmentsrejectstop> 10 whilerolesallows 1000 → separateAUTHORIZATION_{ROLES,ASSIGNMENTS}_MAX_PAGE_SIZEconstantsscopeTypedeliberately left asstring— the value vocabulary is unpublished (observed:ORGANIZATION,TENANT,ANY)Testing
roleIdsarray, upsert read-follow-up, wire body assertions, CSV blob, effective-access reshape, all validation branches)delete()delegationas unknown asintoAction, dedicated roles max-page-size constant)Docs
docs/oauth-scopes.md(honest note: the Authorization service publishes no OAuth scopes yet — access governed by the caller's platform roles, PAT-verified),docs/pagination.md(2 rows),mkdocs.ymlnav.Stack
mainfeat/platform-usersfeat/platform-groupsfeat/platform-directory🤖 Generated with Claude Code