Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class CohortSettingsDialog extends MobxLitElement {
Delete cohort
</pr-button>
<pr-button
?disabled=${!this.experimentManager.isCreator}
@click=${() => {
this.analyticsService.trackButtonClick(
ButtonClick.COHORT_SAVE_EXISTING,
Expand Down
19 changes: 13 additions & 6 deletions functions/src/cohort.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,12 @@ export const updateCohortMetadata = onCall(async (request) => {
await app.firestore().runTransaction(async (transaction) => {
const cohortConfig = (await document.get()).data() as CohortConfig;

// Verify that the experimenter is the creator
// before updating.
if (cohortConfig.metadata.creator !== request.auth?.token.email) {
const canUpdate = await AuthGuard.isCreatorOrAdmin(
request,
cohortConfig.metadata.creator,
);

if (!canUpdate) {
success = false;
return;
}
Expand Down Expand Up @@ -128,7 +131,6 @@ export const deleteCohort = onCall(async (request) => {
return {success: false};
}

// Verify that experimenter is the creator before enabling delete
const experiment = (
await app.firestore().collection('experiments').doc(data.experimentId).get()
).data();
Expand All @@ -139,8 +141,13 @@ export const deleteCohort = onCall(async (request) => {
);
}

if (request.auth?.token.email?.toLowerCase() !== experiment.metadata.creator)
return;
const canDelete = await AuthGuard.isCreatorOrAdmin(
request,
experiment.metadata.creator,
);
if (!canDelete) {
return {success: false};
}

// Delete document
const doc = app
Expand Down
29 changes: 28 additions & 1 deletion functions/src/experiment.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ export const updateExperiment = onCall(async (request) => {

// Use shared utility to update experiment
// TODO: Enable admins to update experiment?

// Define document reference
const document = app
.firestore()
.collection(data.collectionName)
.doc(data.experimentTemplate.id);

// If experiment does not exist, return false
const oldExperiment = await document.get();
if (!oldExperiment.exists) {
return {success: false};
}

// Use shared utility to update experiment
const result = await updateExperimentFromTemplate(
app.firestore(),
data.experimentTemplate,
Expand Down Expand Up @@ -97,8 +111,21 @@ export const deleteExperiment = onCall(async (request) => {

const experimenterId = request.auth?.token.email?.toLowerCase() || '';

const experiment = (
await app
.firestore()
.collection(data.collectionName)
.doc(data.experimentId)
.get()
).data();
if (!experiment) {
throw new HttpsError(
'not-found',
`Experiment ${data.experimentId} not found in collection ${data.collectionName}`,
);
}

// Use shared utility to delete experiment
// TODO: Enable admins to delete?
const result = await deleteExperimentById(
app.firestore(),
data.experimentId,
Expand Down
17 changes: 13 additions & 4 deletions functions/src/experiment.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@deliberation-lab/utils';
import {getExperimentDownload} from './data';
import {generateVariablesForScope} from './variables.utils';
import {AuthGuard} from './utils/auth-guard';

/**
* Options for writing an experiment from a template
Expand Down Expand Up @@ -284,9 +285,13 @@ export async function updateExperimentFromTemplate(
return {success: false, error: 'not-found'};
}

// Verify that the experimenter is the creator
// Verify that the experimenter is the creator or an admin
if (experimenterId !== oldExperiment.data()?.metadata.creator) {
return {success: false, error: 'not-owner'};
const isAdmin = await AuthGuard.isAdminEmail(firestore, experimenterId);

if (!isAdmin) {
return {success: false, error: 'not-owner'};
}
}

// Regenerate variable values based on current variable configs
Expand Down Expand Up @@ -384,10 +389,14 @@ export async function deleteExperimentById(
return {success: false, error: 'not-found'};
}

// Verify ownership
// Verify ownership or admin status
const experiment = experimentDoc.data();
if (experimenterId !== experiment?.metadata?.creator) {
return {success: false, error: 'not-owner'};
const isAdmin = await AuthGuard.isAdminEmail(firestore, experimenterId);

if (!isAdmin) {
return {success: false, error: 'not-owner'};
}
}

// Delete experiment and all subcollections
Expand Down
30 changes: 30 additions & 0 deletions functions/src/utils/auth-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,34 @@ export class AuthGuard {

throwUnauthIfNot(isAdmin);
}

public static async isCreatorOrAdmin(
request: CallableRequest,
creatorEmail: string,
) {
const email = request.auth?.token.email?.toLowerCase();
if (!email) return false;
if (email === creatorEmail) return true;

const allowlistDoc = await app
.firestore()
.collection('allowlist')
.doc(email)
.get();

return allowlistDoc.exists && allowlistDoc.data()?.isAdmin === true;
}

public static async isAdminEmail(
firestore: FirebaseFirestore.Firestore,
email: string,
) {
if (!email) return false;
const allowlistDoc = await firestore
.collection('allowlist')
.doc(email)
.get();

return allowlistDoc.exists && allowlistDoc.data()?.isAdmin === true;
}
}