Skip to content
Open
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
17 changes: 11 additions & 6 deletions src/RealtimeServer/common/services/project-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,23 @@ export abstract class ProjectService<T extends Project = Project> extends JsonDo
};

protected allowRead(_docId: string, doc: T, session: ConnectSession): boolean {
if (
session.isServer ||
session.roles.includes(SystemRole.ServalAdmin) ||
session.roles.includes(SystemRole.SystemAdmin) ||
Object.keys(doc).length === 0
) {
if (this.hasUnrestrictedReadAccess(doc, session)) {
return true;
}

return doc.userRoles != null && session.userId in doc.userRoles;
}

/** Whether the session may read any project doc regardless of project membership. */
protected hasUnrestrictedReadAccess(doc: T, session: ConnectSession): boolean {
return (
session.isServer ||
session.roles.includes(SystemRole.ServalAdmin) ||
session.roles.includes(SystemRole.SystemAdmin) ||
Object.keys(doc).length === 0
);
}

protected allowUpdate(_docId: string, _oldDoc: T, newDoc: T, ops: any, session: ConnectSession): boolean {
if (session.isServer || session.roles.includes(SystemRole.SystemAdmin)) {
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ describe('SFProjectService', () => {
await expect(fetchDoc(conn, env.collection, 'project01')).resolves.not.toThrow();
});

it('allows serval admin user to see project', async () => {
const env = new TestEnvironment();
expect(env.paratextUsers.find(u => u.sfUserId === 'serval_admin')).toBeUndefined();
await env.createData();

const conn = clientConnect(env.server, 'serval_admin', SystemRole.ServalAdmin);
await expect(fetchDoc(conn, env.collection, 'project01')).resolves.not.toThrow();
});

it('does not allow non-member to view profile', async () => {
const env = new TestEnvironment();
await env.createData();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import ShareDB from 'sharedb';
import { ConnectSession } from '../../common/connect-session';
import { MigrationConstructor } from '../../common/migration';
import { Operation } from '../../common/models/project-rights';
import { SystemRole } from '../../common/models/system-role';
import { ValidationSchema } from '../../common/models/validation-schema';
import { RealtimeServer } from '../../common/realtime-server';
import { ProjectDomainConfig } from '../../common/services/project-data-service';
Expand Down Expand Up @@ -596,7 +595,7 @@ export class SFProjectService extends ProjectService<SFProject> {
}

protected allowRead(docId: string, doc: SFProject, session: ConnectSession): boolean {
if (session.isServer || session.roles.includes(SystemRole.SystemAdmin) || Object.keys(doc).length === 0) {
if (this.hasUnrestrictedReadAccess(doc, session)) {
return true;
}
if (this.hasRight(session.userId, doc, Operation.View)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
[disabled]="!isAppOnline"
appRouterLink="/serval-administration"
>
<mat-icon>tune</mat-icon>
<mat-icon>engineering</mat-icon>
{{ t("serval_administration") }}
</button>
}
Expand Down
30 changes: 30 additions & 0 deletions src/SIL.XForge.Scripture/ClientApp/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@
{ provide: ErrorReportingService, useMock: mockedErrorReportingService },
{ provide: BreakpointObserver, useClass: TestBreakpointObserver },
{ provide: DialogService, useMock: mockedDialogService },
provideNoopAnimations()

Check warning on line 126 in src/SIL.XForge.Scripture/ClientApp/src/app/app.component.spec.ts

View workflow job for this annotation

GitHub Actions / Lint and Prettier (22.13.0, 11.11.0, 11.10.0)

`provideNoopAnimations` is deprecated. 20.2 Use `animate.enter` or `animate.leave` instead. Intent to remove in v23
]
}));

Expand Down Expand Up @@ -380,6 +380,30 @@
verify(mockedDialogService.message(anything())).never();
}));

it('does not show the checking disabled message to a serval admin who is not on the project', fakeAsync(() => {
const env = new TestEnvironment();
env.setCurrentUser('user05');
when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]);
env.navigate(['/serval-administration', 'project01']);
when(mockedLocationService.pathname).thenReturn('/serval-administration/project01');
env.init();
env.setCheckingEnabled('project01', false);

expect(env.selectedProjectId).toEqual('project01');
expect(env.component.showCheckingDisabled).toBe(false);
}));

it('shows the checking disabled message to a community checker when checking is disabled', fakeAsync(() => {
const env = new TestEnvironment();
env.setCurrentUser('user02');
env.navigate(['/projects', 'project01']);
env.init();
expect(env.component.showCheckingDisabled).toBe(false);

env.setCheckingEnabled('project01', false);
expect(env.component.showCheckingDisabled).toBe(true);
}));

it('response to Commenter project role changed', fakeAsync(() => {
const env = new TestEnvironment();
env.navigate(['/projects', 'project01']);
Expand Down Expand Up @@ -993,6 +1017,12 @@
this.wait();
}

setCheckingEnabled(projectId: string, enabled: boolean): void {
const projectDoc = this.realtimeService.get<SFProjectProfileDoc>(SFProjectProfileDoc.COLLECTION, projectId);
projectDoc.submitJson0Op(op => op.set<boolean>(p => p.checkingConfig.checkingEnabled, enabled), false);
this.wait();
}

showHideUserMenu(): void {
this.avatarIcon.nativeElement.click();
this.wait();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export class AppComponent extends DataLoadingComponent implements OnInit, OnDest
this._selectedProjectDoc != null &&
this._selectedProjectDoc.data != null &&
!this._selectedProjectDoc.data.checkingConfig.checkingEnabled &&
this.selectedProjectRole != null &&
!roleCanAccessTranslate(this.selectedProjectRole)
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-inf
import { VerseRefData } from 'realtime-server/lib/esm/scriptureforge/models/verse-ref-data';
import { of } from 'rxjs';
import { anything, instance, mock, resetCalls, verify, when } from 'ts-mockito';
import { AuthService } from 'xforge-common/auth.service';
import { DialogService } from 'xforge-common/dialog.service';
import { NoticeService } from 'xforge-common/notice.service';
import { OnlineStatusService } from 'xforge-common/online-status.service';
Expand Down Expand Up @@ -60,6 +61,7 @@ const mockedQuestionsService = mock(CheckingQuestionsService);
const mockedUserService = mock(UserService);
const mockedQuestionDialogService = mock(QuestionDialogService);
const mockedChapterAudioDialogService = mock(ChapterAudioDialogService);
const mockedAuthService = mock(AuthService);

describe('CheckingOverviewComponent', () => {
configureTestingModule(() => ({
Expand All @@ -75,7 +77,8 @@ describe('CheckingOverviewComponent', () => {
{ provide: UserService, useMock: mockedUserService },
{ provide: QuestionDialogService, useMock: mockedQuestionDialogService },
{ provide: OnlineStatusService, useClass: TestOnlineStatusService },
{ provide: ChapterAudioDialogService, useMock: mockedChapterAudioDialogService }
{ provide: ChapterAudioDialogService, useMock: mockedChapterAudioDialogService },
{ provide: AuthService, useMock: mockedAuthService }
]
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
verify,
when
} from 'ts-mockito';
import { AuthService } from 'xforge-common/auth.service';
import { DialogService } from 'xforge-common/dialog.service';
import { FileService } from 'xforge-common/file.service';
import { createStorageFileData, FileOfflineData, FileType } from 'xforge-common/models/file-offline-data';
Expand Down Expand Up @@ -110,6 +111,7 @@
const mockedQuestionDialogService = mock(QuestionDialogService);
const mockedChapterAudioDialogService = mock(ChapterAudioDialogService);
const mockedFileService = mock(FileService);
const mockedAuthService = mock(AuthService);

function createUser(idSuffix: number, role: string, nameConfirmed: boolean = true): UserInfo {
return {
Expand Down Expand Up @@ -182,7 +184,8 @@
{ provide: ChapterAudioDialogService, useMock: mockedChapterAudioDialogService },
{ provide: FileService, useMock: mockedFileService },
{ provide: OnlineStatusService, useClass: TestOnlineStatusService },
{ provide: AuthService, useMock: mockedAuthService },
provideNoopAnimations()

Check warning on line 188 in src/SIL.XForge.Scripture/ClientApp/src/app/checking/checking/checking.component.spec.ts

View workflow job for this annotation

GitHub Actions / Lint and Prettier (22.13.0, 11.11.0, 11.10.0)

`provideNoopAnimations` is deprecated. 20.2 Use `animate.enter` or `animate.leave` instead. Intent to remove in v23
]
}));

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { fakeAsync, TestBed } from '@angular/core/testing';
import { User } from '@bugsnag/js';
import { cloneDeep } from 'lodash-es';
import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role';
import { createTestUser } from 'realtime-server/lib/esm/common/models/user-test-data';
import { RecursivePartial } from 'realtime-server/lib/esm/common/utils/type-utils';
import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project';
import { isParatextRole, SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role';
import { createTestProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data';
import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission';
import { anything, instance, mock, verify, when } from 'ts-mockito';
import { AuthService } from 'xforge-common/auth.service';
import { UserDoc } from 'xforge-common/models/user-doc';
import { provideTestRealtime } from 'xforge-common/test-realtime-providers';
import { TestRealtimeService } from 'xforge-common/test-realtime.service';
Expand All @@ -20,13 +22,15 @@ import { RESOURCE_IDENTIFIER_LENGTH } from './paratext.service';
import { PermissionsService } from './permissions.service';
import { SFProjectService } from './sf-project.service';

const mockedAuthService = mock(AuthService);
const mockedUserService = mock(UserService);
const mockedProjectService = mock(SFProjectService);
const mockedProjectDoc = mock(SFProjectProfileDoc);
describe('PermissionsService', () => {
configureTestingModule(() => ({
providers: [
provideTestRealtime(SF_TYPE_REGISTRY),
{ provide: AuthService, useMock: mockedAuthService },
{ provide: UserService, useMock: mockedUserService },
{ provide: SFProjectService, useMock: mockedProjectService }
]
Expand Down Expand Up @@ -162,6 +166,42 @@ describe('PermissionsService', () => {
verify(mockedProjectService.getProfile('project01')).twice();
}));

describe('canInitiateSync', () => {
it('allows serval admins to sync resources they have read access to', () => {
const env = new TestEnvironment();
env.setProjectType('resource');
env.setCurrentUser(SFProjectRole.ParatextObserver);
when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]);
expect(env.service.canInitiateSync(env.projectDoc)).toBe(true);
});

it('does not allow serval admins to sync non-resource projects they have read access to', () => {
const env = new TestEnvironment();
env.setProjectType('project');
env.setCurrentUser(SFProjectRole.ParatextObserver);
when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]);
expect(env.service.canInitiateSync(env.projectDoc)).toBe(false);
});

it('does not allow non-serval-admins with only read access to sync resources', () => {
const env = new TestEnvironment();
env.setProjectType('resource');
env.setCurrentUser(SFProjectRole.ParatextObserver);
when(mockedAuthService.currentUserRoles).thenReturn([]);
expect(env.service.canInitiateSync(env.projectDoc)).toBe(false);
});
});

describe('canAccessSync and canAccessProjectSettings', () => {
it('do not admit serval admins when the project doc has no data', () => {
const env = new TestEnvironment();
when(mockedAuthService.currentUserRoles).thenReturn([SystemRole.ServalAdmin]);
when(mockedProjectDoc.data).thenReturn(undefined);
expect(env.service.canAccessSync(env.projectDoc)).toBe(false);
expect(env.service.canAccessProjectSettings(env.projectDoc)).toBe(false);
});
});

describe('canSync', () => {
it('returns false when projectDoc.data is undefined', fakeAsync(() => {
const env = new TestEnvironment();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Injectable } from '@angular/core';
import { Operation } from 'realtime-server/lib/esm/common/models/project-rights';
import { SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project';
import { SystemRole } from 'realtime-server/lib/esm/common/models/system-role';
import { isResource, SFProjectProfile } from 'realtime-server/lib/esm/scriptureforge/models/sf-project';
import { SF_PROJECT_RIGHTS, SFProjectDomain } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-rights';
import { isParatextRole, SFProjectRole } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-role';
import { Chapter, TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info';
import { TextInfoPermission } from 'realtime-server/lib/esm/scriptureforge/models/text-info-permission';
import { AuthService } from 'xforge-common/auth.service';
import { UserDoc } from 'xforge-common/models/user-doc';
import { UserService } from 'xforge-common/user.service';
import { environment } from '../../environments/environment';
Expand All @@ -21,10 +23,19 @@ import { SFProjectService } from './sf-project.service';
@Injectable({ providedIn: 'root' })
export class PermissionsService {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
private readonly projectService: SFProjectService
) {}

get isServalAdmin(): boolean {
return this.authService.currentUserRoles.includes(SystemRole.ServalAdmin);
}

get isSystemAdmin(): boolean {
return this.authService.currentUserRoles.includes(SystemRole.SystemAdmin);
}

canAccessCommunityChecking(project: SFProjectProfileDoc, userId?: string): boolean {
if (project.data == null) return false;
const role = project.data.userRoles[userId ?? this.userService.currentUserId];
Expand Down Expand Up @@ -104,6 +115,48 @@ export class PermissionsService {
return false;
}

/** Whether the user can open the project settings and users pages (serval admins get read-only access). */
canAccessProjectSettings(projectDoc: SFProjectProfileDoc): boolean {
return projectDoc.data != null && (this.isServalAdmin || this.canEditProjectSettings(projectDoc));
}

/** Whether the user can open the sync page (serval admins get read-only access). */
canAccessSync(projectDoc: SFProjectProfileDoc): boolean {
return projectDoc.data != null && (this.isServalAdmin || this.canInitiateSync(projectDoc));
}

/** Whether the user has the project role required to edit project settings and manage project users. */
canEditProjectSettings(projectDoc: SFProjectProfileDoc, userId?: string): boolean {
return this.isProjectAdmin(projectDoc, userId);
}

/** Whether the user can initiate a synchronization of the project. */
canInitiateSync(projectDoc: SFProjectProfileDoc, userId?: string): boolean {
if (projectDoc.data == null) return false;
if (
SF_PROJECT_RIGHTS.hasRight(
projectDoc.data,
userId ?? this.userService.currentUserId,
SFProjectDomain.Texts,
Operation.Edit
)
) {
return true;
}
// The backend allows anyone with any Paratext role to sync a DBL resource, so serval admins can sync resources
// they have read access to
return (
this.isServalAdmin &&
isResource(projectDoc.data) &&
SF_PROJECT_RIGHTS.hasRight(
projectDoc.data,
userId ?? this.userService.currentUserId,
SFProjectDomain.Texts,
Operation.View
)
);
}

canSync(projectDoc: SFProjectProfileDoc, userId?: string): boolean {
if (projectDoc.data == null) {
return false;
Expand All @@ -122,9 +175,13 @@ export class PermissionsService {

/** Whether the user is allowed to configure drafting sources for the project. */
canConfigureSources(projectDoc?: SFProjectProfileDoc, userId?: string): boolean {
return this.isProjectAdmin(projectDoc, userId);
}

/** Whether the user's role on the project is Paratext administrator. */
private isProjectAdmin(projectDoc?: SFProjectProfileDoc, userId?: string): boolean {
if (projectDoc?.data == null) return false;
const role = projectDoc.data.userRoles[userId ?? this.userService.currentUserId];
return role === SFProjectRole.ParatextAdministrator;
return projectDoc.data.userRoles[userId ?? this.userService.currentUserId] === SFProjectRole.ParatextAdministrator;
}

canAccessBiblicalTerms(projectDoc: SFProjectProfileDoc): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { QueryParameters, QueryResults } from 'xforge-common/query-parameters';
import { RealtimeService } from 'xforge-common/realtime.service';
import { RetryingRequest, RetryingRequestService } from 'xforge-common/retrying-request.service';
import { EventMetric } from '../event-metrics/event-metric';
import { SyncMetricsDisplay } from '../sync/sync-log/sync-metrics-display';
import { BookProgressWithChapterProgress } from '../shared/progress-service/progress.service';
import { expandNumbers } from '../shared/utils';
import { BiblicalTermDoc } from './models/biblical-term-doc';
Expand Down Expand Up @@ -432,6 +433,14 @@ export class SFProjectService extends ProjectService<SFProject, SFProjectDoc> {
return await this.onlineInvoke<QueryResults<EventMetric>>('eventMetrics', { projectId, pageIndex, pageSize });
}

async onlineSyncMetrics(
projectId: string,
pageIndex: number,
pageSize: number
): Promise<QueryResults<SyncMetricsDisplay>> {
return await this.onlineInvoke<QueryResults<SyncMetricsDisplay>>('syncMetrics', { projectId, pageIndex, pageSize });
}

async onlineAllEventMetricsForConstructingDraftJobs(
eventTypes: string[],
projectId?: string,
Expand Down
Loading
Loading