diff --git a/firestore/firestore.rules b/firestore/firestore.rules index 8e3cd4dfd..014a00033 100644 --- a/firestore/firestore.rules +++ b/firestore/firestore.rules @@ -123,6 +123,22 @@ rules_version = '2'; ]); } + /** + * Returns true iff the current user has the ADMIN user type. + */ + function isAdmin() { + let userDoc = get(/databases/$(database)/documents/users/$(request.auth.uid)); + return userDoc != null && userDoc.data.userType == "ADMIN"; + } + + /** + * Returns true iff the incoming survey data does not set general_access to PUBLIC, + * or the current user is an admin. + */ + function canSetGeneralAccess() { + return request.resource.data["8"] != 3 /* PUBLIC */ || isAdmin(); + } + /** * Returns true iff the current user with the given email can contribute LOIs * and submissions to the specified survey. @@ -164,9 +180,10 @@ rules_version = '2'; // Apply passlist and survey-level ACLs to survey documents. match /surveys/{surveyId} { - allow create: if isSignedIn() && isPasslisted(); + allow create: if isSignedIn() && isPasslisted() && canSetGeneralAccess(); allow read: if canViewSurvey(resource.data); - allow update, delete: if canManageSurvey(getSurvey(surveyId)); + allow update: if canManageSurvey(getSurvey(surveyId)) && canSetGeneralAccess(); + allow delete: if canManageSurvey(getSurvey(surveyId)); } // Allow all signed in users to access Terms of Service and other config. diff --git a/web/src/app/components/shared/general-access-control/general-access-control.component.ts b/web/src/app/components/shared/general-access-control/general-access-control.component.ts index ad4eced74..0d0fdf510 100644 --- a/web/src/app/components/shared/general-access-control/general-access-control.component.ts +++ b/web/src/app/components/shared/general-access-control/general-access-control.component.ts @@ -43,6 +43,14 @@ const generalAccessLabels = Map< label: $localize`:@@app.labels.unlisted:Unlisted`, }, ], + [ + SurveyGeneralAccess.PUBLIC, + { + description: $localize`:@@app.texts.generalAccess.public:Anyone can find and collect data for this survey`, + icon: 'public', + label: $localize`:@@app.labels.public:Public`, + }, + ], ]); @Component({ @@ -76,7 +84,9 @@ export class GeneralAccessControlComponent { } get generalAccessKeys(): SurveyGeneralAccess[] { - return Array.from(this.generalAccessLabels.keys()); + return Array.from(this.generalAccessLabels.keys()).filter( + key => key !== SurveyGeneralAccess.PUBLIC || this.authService.isAdmin() + ); } changeGeneralAccess(generalAccess: SurveyGeneralAccess) { diff --git a/web/src/app/converters/firebase-data-converter.spec.ts b/web/src/app/converters/firebase-data-converter.spec.ts index 7f5dc31be..1d8c3566e 100644 --- a/web/src/app/converters/firebase-data-converter.spec.ts +++ b/web/src/app/converters/firebase-data-converter.spec.ts @@ -15,6 +15,7 @@ */ import { Role } from 'app/models/role.model'; +import { UserType } from 'app/models/user.model'; import { FirebaseDataConverter } from './firebase-data-converter'; @@ -31,4 +32,42 @@ describe('FirebaseDataConverter', () => { expect(FirebaseDataConverter.toRoleId(Role.VIEWER)).toEqual('VIEWER'); }); }); + + describe('toUser()', () => { + const uid = 'user123'; + const baseData = { + email: 'test@example.com', + displayName: 'Test User', + photoURL: 'https://example.com/photo.jpg', + }; + + it('sets userType to ADMIN when field is ADMIN', () => { + const user = FirebaseDataConverter.toUser( + { ...baseData, userType: 'ADMIN' }, + uid + ); + expect(user?.userType).toEqual(UserType.ADMIN); + }); + + it('sets userType to UNDEFINED when field is UNDEFINED', () => { + const user = FirebaseDataConverter.toUser( + { ...baseData, userType: 'UNDEFINED' }, + uid + ); + expect(user?.userType).toEqual(UserType.UNDEFINED); + }); + + it('defaults userType to UNDEFINED when field is absent', () => { + const user = FirebaseDataConverter.toUser({ ...baseData }, uid); + expect(user?.userType).toEqual(UserType.UNDEFINED); + }); + + it('defaults userType to UNDEFINED when field is an unrecognized value', () => { + const user = FirebaseDataConverter.toUser( + { ...baseData, userType: 'UNKNOWN_VALUE' }, + uid + ); + expect(user?.userType).toEqual(UserType.UNDEFINED); + }); + }); }); diff --git a/web/src/app/converters/firebase-data-converter.ts b/web/src/app/converters/firebase-data-converter.ts index d9f803a14..b290c6523 100644 --- a/web/src/app/converters/firebase-data-converter.ts +++ b/web/src/app/converters/firebase-data-converter.ts @@ -33,7 +33,7 @@ import { TaskConditionMatchType, } from 'app/models/task/task-condition.model'; import { Task, TaskType } from 'app/models/task/task.model'; -import { User } from 'app/models/user.model'; +import { User, UserType } from 'app/models/user.model'; const TASK_TYPE_ENUMS_BY_STRING = Map([ [TaskType.TEXT, 'text_field'], @@ -162,12 +162,17 @@ export class FirebaseDataConverter { if (!data) { return; } + const userType = + data.userType in UserType + ? (data.userType as UserType) + : UserType.UNDEFINED; return new User( uid, data.email, data.isAuthenticated || true, data.displayName, - data.photoURL + data.photoURL, + userType ); } diff --git a/web/src/app/models/user.model.ts b/web/src/app/models/user.model.ts index 4cda26abc..8b5243b9a 100644 --- a/web/src/app/models/user.model.ts +++ b/web/src/app/models/user.model.ts @@ -14,12 +14,18 @@ * limitations under the License. */ +export enum UserType { + UNDEFINED = 'UNDEFINED', + ADMIN = 'ADMIN', +} + export class User { constructor( readonly id: string, readonly email: string, readonly isAuthenticated: boolean, readonly displayName?: string, - readonly photoURL?: string + readonly photoURL?: string, + readonly userType?: UserType ) {} } diff --git a/web/src/app/services/auth/auth.service.spec.ts b/web/src/app/services/auth/auth.service.spec.ts index bca546e5a..a8fc3db78 100644 --- a/web/src/app/services/auth/auth.service.spec.ts +++ b/web/src/app/services/auth/auth.service.spec.ts @@ -29,7 +29,7 @@ import { of } from 'rxjs'; import { AuthService } from 'app/services/auth/auth.service'; import { DataStoreService } from 'app/services/data-store/data-store.service'; -import { User } from 'app/models/user.model'; +import { User, UserType } from 'app/models/user.model'; import { environment } from 'environments/environment'; import { HttpClientService } from '../http-client/http-client.service'; @@ -206,3 +206,48 @@ describe('AuthService session cookie invalidation', () => { expect(localStorage.getItem(SESSION_COOKIE_EXPIRES_AT_KEY)).not.toBeNull(); }); }); + +describe('AuthService isAdmin()', () => { + let service: AuthService; + + beforeEach(() => { + const mockAuth = createMockAuth( + jasmine.createSpy('onIdTokenChanged').and.returnValue(() => {}) + ); + + TestBed.configureTestingModule({ + providers: mockAuthProviders(mockAuth, jasmine.createSpy('postWithAuth')), + }); + + service = TestBed.inject(AuthService); + }); + + it('returns true when current user has ADMIN userType', () => { + service['currentUser'] = new User( + 'uid', + 'admin@example.com', + true, + 'Admin', + undefined, + UserType.ADMIN + ); + expect(service.isAdmin()).toBeTrue(); + }); + + it('returns false when current user has UNDEFINED userType', () => { + service['currentUser'] = new User( + 'uid', + 'user@example.com', + true, + 'User', + undefined, + UserType.UNDEFINED + ); + expect(service.isAdmin()).toBeFalse(); + }); + + it('returns false when current user has no userType', () => { + service['currentUser'] = new User('uid', 'user@example.com', true); + expect(service.isAdmin()).toBeFalse(); + }); +}); diff --git a/web/src/app/services/auth/auth.service.ts b/web/src/app/services/auth/auth.service.ts index deb569508..3893c2a62 100644 --- a/web/src/app/services/auth/auth.service.ts +++ b/web/src/app/services/auth/auth.service.ts @@ -38,7 +38,7 @@ import { AclEntry } from 'app/models/acl-entry.model'; import { DataCollectionStrategy, Job } from 'app/models/job.model'; import { Role } from 'app/models/role.model'; import { Survey } from 'app/models/survey.model'; -import { User } from 'app/models/user.model'; +import { User, UserType } from 'app/models/user.model'; import { DataStoreService } from 'app/services/data-store/data-store.service'; import { NavigationService } from 'app/services/navigation/navigation.service'; import { environment } from 'environments/environment'; @@ -225,6 +225,10 @@ export class AuthService { ); } + isAdmin(): boolean { + return this.currentUser?.userType === UserType.ADMIN; + } + isManager(role: Role): boolean { return [Role.OWNER, Role.SURVEY_ORGANIZER].includes(role); } diff --git a/web/src/locale/messages.json b/web/src/locale/messages.json index f9e855e87..64d1e86ca 100644 --- a/web/src/locale/messages.json +++ b/web/src/locale/messages.json @@ -108,6 +108,8 @@ "app.labels.restricted": "Restricted", "app.texts.generalAccess.unlisted": "Everyone with the survey QR code or link can collect data for this survey", "app.labels.unlisted": "Unlisted", + "app.texts.generalAccess.public": "Anyone can find and collect data for this survey", + "app.labels.public": "Public", "app.labels.signOut": "Sign out", "app.labels.manageSurvey": "Manage survey", "app.labels.publishChanges": "Publish changes", @@ -141,7 +143,6 @@ "app.labels.createSurvey": "Create survey", "app.labels.createSurveyDescription": "Define jobs and sites for data collectors", "app.labels.all": "All", - "app.labels.public": "Public", "app.labels.ifTheAnswerTo": "If the answer to", "app.taskEditor.condition.selectQuestion": "Select question", "app.controls.errors.requiredQuestion": "A question must be selected as a condition",