diff --git a/firestore.rules b/firestore.rules index 5048beaf..36e4248b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -6,6 +6,12 @@ service cloud.firestore { allow write: if isAtLeastAdmin(); } + match /tanam-users/{uid} { + allow read: if isSignedInAs(uid); + // Delete and create must be done manually + allow update: if isSignedInAs(uid) && request.resource.data.role == resource.data.role; + } + match /tanam-documents/{documentId} { allow read: if hasAnyRole(); allow write: if isPublisher(); @@ -24,12 +30,8 @@ service cloud.firestore { return isSignedIn() && request.auth.token.tanamRole != null; } - function isSuperAdmin() { - return hasUserRole("superAdmin"); - } - function isAtLeastAdmin() { - return isSuperAdmin() || hasUserRole("admin"); + return hasUserRole("admin"); } function isPublisher() { diff --git a/functions/src/index.ts b/functions/src/index.ts index 3cd59b25..c530726c 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -4,3 +4,4 @@ const app = admin.initializeApp(); app.firestore().settings({ignoreUndefinedProperties: true}); export * from "./genkit"; +export * from "./triggers/users"; diff --git a/functions/src/models/TanamUser.ts b/functions/src/models/TanamUser.ts new file mode 100644 index 00000000..8f5f545d --- /dev/null +++ b/functions/src/models/TanamUser.ts @@ -0,0 +1,31 @@ +export type TanamRole = "publisher" | "admin"; + +export interface ITanamUser { + role?: TanamRole; + createdAt: TimestampType; + updatedAt: TimestampType; +} + +export abstract class TanamUser { + constructor(id: string, json: ITanamUser) { + this.id = id; + this.role = json.role ?? "publisher"; + this.createdAt = json.createdAt; + this.updatedAt = json.updatedAt; + } + + public readonly id: string; + public role: TanamRole; + public readonly createdAt: TimestampType; + public readonly updatedAt: TimestampType; + + protected abstract getServerTimestamp(): FieldValueType; + + toJson(): object { + return { + role: this.role, + createdAt: this.createdAt ?? this.getServerTimestamp(), + updatedAt: this.getServerTimestamp(), + }; + } +} diff --git a/functions/src/models/TanamUserAdmin.ts b/functions/src/models/TanamUserAdmin.ts new file mode 100644 index 00000000..40d3af19 --- /dev/null +++ b/functions/src/models/TanamUserAdmin.ts @@ -0,0 +1,26 @@ +import {FieldValue, Timestamp} from "firebase-admin/firestore"; +import {DocumentSnapshot} from "firebase-functions/v2/firestore"; +import {ITanamUser, TanamUser} from "./TanamUser"; + +export class TanamUserAdmin extends TanamUser { + constructor(id: string, json: ITanamUser) { + super(id, json); + } + + getServerTimestamp(): FieldValue { + return FieldValue.serverTimestamp(); + } + + static fromFirestore(snap: DocumentSnapshot): TanamUserAdmin { + const data = snap.data(); + if (!data) { + throw new Error("Document data is undefined"); + } + + return new TanamUserAdmin(snap.id, { + role: data.role, + createdAt: data.createdAt || Timestamp.now(), + updatedAt: data.updatedAt || Timestamp.now(), + }); + } +} diff --git a/functions/src/triggers/users.ts b/functions/src/triggers/users.ts new file mode 100644 index 00000000..afe2166b --- /dev/null +++ b/functions/src/triggers/users.ts @@ -0,0 +1,85 @@ +import * as admin from "firebase-admin"; +import {Timestamp} from "firebase-admin/firestore"; +import {logger} from "firebase-functions/v2"; +import {onDocumentCreated, onDocumentDeleted, onDocumentUpdated} from "firebase-functions/v2/firestore"; +import {TanamRole} from "../models/TanamUser"; +import {TanamUserAdmin} from "../models/TanamUserAdmin"; + +const auth = admin.auth(); +const db = admin.firestore(); + +// Function to validate and assign role on document creation +// This funciton will scaffold and create a new user document with a role field +// and assert that all the document fields are populated. +export const onTanamUserCreated = onDocumentCreated("tanam-users/{docId}", async (event) => { + const uid = event.params.docId; + const docRef = db.collection("tanam-users").doc(uid); + const docData = (await docRef.get()).data() || {}; + + logger.info(`Validating User ID: ${uid}`); + try { + await auth.getUser(uid); + } catch (error) { + console.log("Document ID does not match any Firebase Auth UID, deleting document"); + return docRef.delete(); + } + + const existingDocs = await db.collection("tanam-users").get(); + const tanamUser = new TanamUserAdmin(uid, { + ...docData, + role: existingDocs.size === 1 ? "admin" : "publisher", + createdAt: Timestamp.now(), + updatedAt: Timestamp.now(), + }); + logger.info("Creating User", tanamUser.toJson()); + + const customClaimsBefore = (await auth.getUser(uid)).customClaims || {}; + const customClaimsAfter = {...customClaimsBefore, tanamRole: tanamUser.role}; + + logger.info(`Setting custom claims for ${uid}`, { + customClaimsBefore, + customClaimsAfter, + }); + + return Promise.all([auth.setCustomUserClaims(uid, customClaimsAfter), docRef.set(tanamUser.toJson())]); +}); + +// Function to enforce role management on document update +// This function will apply changes to custom claims when the role field is updated +export const onRoleChange = onDocumentUpdated("tanam-users/{docId}", async (event) => { + const uid = event.params.docId; + const beforeData = event?.data?.before.data(); + const afterData = event?.data?.after.data(); + if (!beforeData || !afterData) { + throw new Error("Document data is undefined"); + } + + if (beforeData.role === afterData.role) { + logger.debug(`Role unchanged for ${uid}. Stopping here.`); + return; + } + + const supportedRoles: TanamRole[] = ["admin", "publisher"]; + if (!supportedRoles.includes(afterData.role)) { + logger.error(`Role ${afterData.role} is not supported. Doing nothing.`); + return; + } + + logger.info(`Role change detected for ${uid}.`, {before: beforeData.role, after: afterData.role}); + return auth.setCustomUserClaims(uid, {tanamRole: afterData.role}); +}); + +// Function to remove role on document deletion +export const onTanamUserDeleted = onDocumentDeleted("tanam-users/{docId}", async (event) => { + const uid = event.params.docId; + + console.log(`Document deleted: ${uid}, removing custom claims`); + const customClaims = (await auth.getUser(uid)).customClaims || {}; + customClaims.tanamRole = undefined; + + logger.info(`Tanam user deleted, removing custom claims for ${uid}`, { + customClaims, + }); + + await auth.setCustomUserClaims(uid, customClaims); +});