diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 7529c60..ea1e223 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -8,9 +8,15 @@ export interface GetTeamMembersResponse { totalCount: number; } +export type PermissionsByRole = { + role: string; + permissions: string[]; + userCount: number; +}; + // TODO: replece api path once is created export const getTeamMembers = async (object: string): Promise => { - const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users?scope=${object}`)); + const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`)); return camelCaseObject(data.results); }; @@ -24,3 +30,10 @@ export const getLibrary = async (libraryId: string): Promise => slug: data.slug, }; }; + +export const getPermissionsByRole = async (scope: string): Promise => { + const url = new URL(getApiUrl('/api/authz/v1/roles/')); + url.searchParams.append('scope', scope); + const { data } = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(data.results); +}; diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index 28aced0..79fafc4 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -2,7 +2,7 @@ import { ReactNode } from 'react'; import { act, renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { useLibrary, useTeamMembers } from './hooks'; +import { useLibrary, usePermissionsByRole, useTeamMembers } from './hooks'; jest.mock('@edx/frontend-platform/auth', () => ({ getAuthenticatedHttpClient: jest.fn(), @@ -10,13 +10,13 @@ jest.mock('@edx/frontend-platform/auth', () => ({ const mockMembers = [ { - displayName: 'Alice', + fullName: 'Alice', username: 'user1', email: 'alice@example.com', roles: ['admin', 'author'], }, { - displayName: 'Bob', + fullName: 'Bob', username: 'user2', email: 'bob@example.com', roles: ['collaborator'], @@ -123,3 +123,36 @@ describe('useLibrary', () => { expect(getAuthenticatedHttpClient).toHaveBeenCalled(); }); }); + +describe('usePermissionsByRole', () => { + it('fetches roles for a given scope', async () => { + const mockRoles = [ + { role: 'admin', permissions: ['perm1'], userCount: 1 }, + { role: 'user', permissions: ['perm2'], userCount: 2 }, + ]; + + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: { results: mockRoles } }), + }); + + const wrapper = createWrapper(); + const { result } = renderHook(() => usePermissionsByRole('lib'), { wrapper }); + await waitFor(() => result.current.data !== undefined); + expect(result.current.data).toEqual(mockRoles); + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + }); + + it('returns error if getRoles fails', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockRejectedValue(new Error('Not found')), + }); + const wrapper = createWrapper(); + try { + act(() => { + renderHook(() => usePermissionsByRole('lib'), { wrapper }); + }); + } catch (e) { + expect(e).toEqual(new Error('Not found')); + } + }); +}); diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index e374c09..658f548 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -1,11 +1,14 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { appId } from '@src/constants'; import { LibraryMetadata, TeamMember } from '@src/types'; -import { getLibrary, getTeamMembers } from './api'; +import { + getLibrary, getPermissionsByRole, getTeamMembers, PermissionsByRole, +} from './api'; const authzQueryKeys = { all: [appId, 'authz'] as const, teamMembers: (object: string) => [...authzQueryKeys.all, 'teamMembers', object] as const, + permissionsByRole: (scope: string) => [...authzQueryKeys.all, 'permissionsByRole', scope] as const, library: (libraryId: string) => [...authzQueryKeys.all, 'library', libraryId] as const, }; @@ -26,6 +29,23 @@ export const useTeamMembers = (object: string) => useQuery( staleTime: 1000 * 60 * 30, // refetch after 30 minutes }); +/** + * React Query hook to fetch all the roles for the specific object/scope. + * It retrieves the full list of roles with the corresponding permissions. + * + * @param scope - The unique identifier of the object/scope + * + * @example + * ```tsx + * const { data: roles } = usePermissionsByRole('lib:123'); + * ``` + */ +export const usePermissionsByRole = (scope: string) => useSuspenseQuery({ + queryKey: authzQueryKeys.permissionsByRole(scope), + queryFn: () => getPermissionsByRole(scope), + retry: false, +}); + /** * React Query hook to retrieve the information of the current library. * diff --git a/src/authz-module/libraries-manager/constants.ts b/src/authz-module/libraries-manager/constants.ts new file mode 100644 index 0000000..7b3686e --- /dev/null +++ b/src/authz-module/libraries-manager/constants.ts @@ -0,0 +1,34 @@ +import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types'; + +// Note: this information will eventually come from the backend API +// but for the MVP we decided to manage it in the frontend +export const libraryRolesMetadata: RoleMetadata[] = [ + { role: 'library_admin', name: 'Library Admin', description: 'The Library Admin has full control over the library, including managing users, modifying content, and handling publishing workflows. They ensure content is properly maintained and accessible as needed.' }, + { role: 'library_author', name: 'Library Author', description: 'The Library Author is responsible for creating, editing, and publishing content within a library. They can manage tags and collections but cannot delete libraries or manage users.' }, + { role: 'library_collaborator', name: 'Library Collaborator', description: 'The Library Collaborator can create and edit content within a library but cannot publish it. They support the authoring process while leaving final publishing to Authors or Admins.' }, + { role: 'library_user', name: 'Library User', description: 'The Library User can view and reuse content but cannot edit or delete any resource.' }, +]; + +export const libraryResourceTypes: ResourceMetadata[] = [ + { key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' }, + { key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' }, + { key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.' }, + { key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.' }, +]; + +export const libraryPermissions: PermissionMetadata[] = [ + { key: 'view_library', resource: 'library', description: 'View content, search, filter, and sort within the library.' }, + { key: 'delete_library', resource: 'library', description: 'Allows the user to delete the library and all its contents.' }, + { key: 'manage_library_tags', resource: 'library', description: 'Add or remove tags from content.' }, + + { key: 'edit_library_content', resource: 'library_content', description: 'Edit content in draft mode' }, + { key: 'publish_library_content', resource: 'library_content', description: 'Publish content, making it available for reuse' }, + { key: 'reuse_library_content', resource: 'library_content', description: 'Reuse published content within a course.' }, + + { key: 'create_library_collection', resource: 'library_collection', description: 'Create new collections within a library.' }, + { key: 'edit_library_collection', resource: 'library_collection', description: 'Add or remove content from existing collections.' }, + { key: 'delete_library_collection', resource: 'library_collection', description: 'Delete entire collections from the library.' }, + + { key: 'manage_library_team', resource: 'library_team', description: 'View the list of users who have access to the library.' }, + { key: 'view_library_team', resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' }, +]; diff --git a/src/authz-module/libraries-manager/context.test.tsx b/src/authz-module/libraries-manager/context.test.tsx index f133f41..9755c70 100644 --- a/src/authz-module/libraries-manager/context.test.tsx +++ b/src/authz-module/libraries-manager/context.test.tsx @@ -12,6 +12,20 @@ jest.mock('react-router-dom', () => ({ jest.mock('@src/data/hooks', () => ({ useValidateUserPermissions: jest.fn(), })); +jest.mock('@src/authz-module/data/hooks', () => ({ + usePermissionsByRole: jest.fn().mockReturnValue({ + data: [ + { + role: 'library_author', + permissions: [ + 'view_library_team', + 'edit_library', + ], + user_count: 12, + }, + ], + }), +})); const TestComponent = () => { const context = useLibraryAuthZ(); @@ -20,6 +34,9 @@ const TestComponent = () => {
{context.username}
{context.libraryId}
{context.canManageTeam ? 'true' : 'false'}
+
{Array.isArray(context.roles) ? context.roles.length : 'undefined'}
+
{Array.isArray(context.permissions) ? context.permissions.length : 'undefined'}
+
{Array.isArray(context.resources) ? context.resources.length : 'undefined'}
); }; @@ -47,6 +64,9 @@ describe('LibraryAuthZProvider', () => { expect(screen.getByTestId('username')).toHaveTextContent('testuser'); expect(screen.getByTestId('libraryId')).toHaveTextContent('lib123'); expect(screen.getByTestId('canManageTeam')).toHaveTextContent('true'); + expect(Number(screen.getByTestId('roles').textContent)).not.toBeNaN(); + expect(Number(screen.getByTestId('permissions').textContent)).not.toBeNaN(); + expect(Number(screen.getByTestId('resources').textContent)).not.toBeNaN(); }); it('throws error when user lacks both view and manage permissions', () => { diff --git a/src/authz-module/libraries-manager/context.tsx b/src/authz-module/libraries-manager/context.tsx index b013e6b..419744d 100644 --- a/src/authz-module/libraries-manager/context.tsx +++ b/src/authz-module/libraries-manager/context.tsx @@ -4,8 +4,11 @@ import { import { useParams } from 'react-router-dom'; import { AppContext } from '@edx/frontend-platform/react'; import { useValidateUserPermissions } from '@src/data/hooks'; +import { usePermissionsByRole } from '@src/authz-module/data/hooks'; +import { PermissionMetadata, ResourceMetadata, Role } from 'types'; +import { libraryPermissions, libraryResourceTypes, libraryRolesMetadata } from './constants'; -const LIBRARY_TEAM_PERMISSIONS = ['act:view_library_team', 'act:manage_library_team']; +const LIBRARY_TEAM_PERMISSIONS = ['view_library_team', 'manage_library_team']; export type AppContextType = { authenticatedUser: { @@ -18,8 +21,9 @@ type LibraryAuthZContextType = { canManageTeam: boolean; username: string; libraryId: string; - roles: string[]; - permissions: string[]; + resources: ResourceMetadata[]; + roles: Role[]; + permissions: PermissionMetadata[]; }; const LibraryAuthZContext = createContext(undefined); @@ -36,7 +40,7 @@ export const LibraryAuthZProvider: React.FC = ({ children }: if (!libraryId) { throw new Error('MissingLibrary'); } - const permissions = LIBRARY_TEAM_PERMISSIONS.map(action => ({ action, object: libraryId })); + const permissions = LIBRARY_TEAM_PERMISSIONS.map(action => ({ action, scope: libraryId })); const { data: userPermissions } = useValidateUserPermissions(permissions); const [{ allowed: canViewTeam }, { allowed: canManageTeam }] = userPermissions; @@ -45,13 +49,20 @@ export const LibraryAuthZProvider: React.FC = ({ children }: throw new Error('NoAccess'); } + const { data: libraryRoles } = usePermissionsByRole(libraryId); + const roles = libraryRoles.map(role => ({ + ...role, + ...libraryRolesMetadata.find(r => r.role === role.role), + } as Role)); + const value = useMemo((): LibraryAuthZContextType => ({ username: authenticatedUser.username, libraryId, - roles: [], - permissions: [], + roles, + permissions: libraryPermissions, + resources: libraryResourceTypes, canManageTeam, - }), [libraryId, authenticatedUser.username, canManageTeam]); + }), [libraryId, authenticatedUser.username, canManageTeam, roles]); return ( diff --git a/src/types.ts b/src/types.ts index 6ce5b7f..3a7ebdd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,5 @@ export interface PermissionValidationRequest { action: string; - object?: string; scope?: string; } @@ -11,6 +10,7 @@ export interface PermissionValidationResponse extends PermissionValidationReques // Libraries AuthZ types export interface TeamMember { username: string; + fullName: string; email: string; roles: string[]; } @@ -22,6 +22,29 @@ export interface LibraryMetadata { slug: string; } +export interface RoleMetadata { + role: string; + name: string; + description: string; +} +export interface Role extends RoleMetadata { + userCount: number; + permissions: string[]; +} + +export type ResourceMetadata = { + key: string; + label: string; + description: string; +}; + +export type PermissionMetadata = { + key: string; + resource: string; + label?: string; + description?: string; +}; + // Paragon table type export interface TableCellValue { row: {