diff --git a/frontend/src/components/experiment_dashboard/cohort_settings_dialog.ts b/frontend/src/components/experiment_dashboard/cohort_settings_dialog.ts index adcf25c70..5a70e0354 100644 --- a/frontend/src/components/experiment_dashboard/cohort_settings_dialog.ts +++ b/frontend/src/components/experiment_dashboard/cohort_settings_dialog.ts @@ -61,6 +61,7 @@ export class CohortSettingsDialog extends MobxLitElement { Delete cohort { this.analyticsService.trackButtonClick( ButtonClick.COHORT_SAVE_EXISTING, diff --git a/functions/src/cohort.endpoints.ts b/functions/src/cohort.endpoints.ts index 0d1a4f9c2..7c507530d 100644 --- a/functions/src/cohort.endpoints.ts +++ b/functions/src/cohort.endpoints.ts @@ -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; } @@ -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(); @@ -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 diff --git a/functions/src/experiment.endpoints.ts b/functions/src/experiment.endpoints.ts index d61e43cd3..a0534d182 100644 --- a/functions/src/experiment.endpoints.ts +++ b/functions/src/experiment.endpoints.ts @@ -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, @@ -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, diff --git a/functions/src/experiment.utils.ts b/functions/src/experiment.utils.ts index f092e58d6..76b52f39f 100644 --- a/functions/src/experiment.utils.ts +++ b/functions/src/experiment.utils.ts @@ -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 @@ -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 @@ -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 diff --git a/functions/src/utils/auth-guard.ts b/functions/src/utils/auth-guard.ts index 749755cad..38429eb26 100644 --- a/functions/src/utils/auth-guard.ts +++ b/functions/src/utils/auth-guard.ts @@ -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; + } }