Skip to content
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
21 changes: 19 additions & 2 deletions firestore/firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down
39 changes: 39 additions & 0 deletions web/src/app/converters/firebase-data-converter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import { Role } from 'app/models/role.model';
import { UserType } from 'app/models/user.model';

import { FirebaseDataConverter } from './firebase-data-converter';

Expand All @@ -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);
});
});
});
9 changes: 7 additions & 2 deletions web/src/app/converters/firebase-data-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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
);
}

Expand Down
8 changes: 7 additions & 1 deletion web/src/app/models/user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {}
}
47 changes: 46 additions & 1 deletion web/src/app/services/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
});
6 changes: 5 additions & 1 deletion web/src/app/services/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion web/src/locale/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading