From c484334a32e1c99035f009c5daf1c23fe540175e Mon Sep 17 00:00:00 2001 From: Dennis Alund Date: Wed, 17 Jul 2024 16:52:29 +0200 Subject: [PATCH 1/8] Adding tanam user class --- functions/src/models/TanamUser.ts | 31 ++++++++++++++++++++++++++ functions/src/models/TanamUserAdmin.ts | 26 +++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 functions/src/models/TanamUser.ts create mode 100644 functions/src/models/TanamUserAdmin.ts 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(), + }); + } +} From 0058835fbe549a8fb75b810d3104122879581b91 Mon Sep 17 00:00:00 2001 From: Dennis Alund Date: Wed, 17 Jul 2024 16:52:42 +0200 Subject: [PATCH 2/8] Removing super admin role from rules --- firestore.rules | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/firestore.rules b/firestore.rules index 5048beaf..2ffe38c0 100644 --- a/firestore.rules +++ b/firestore.rules @@ -24,12 +24,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() { From 761ebb6369b3bf1e81501b3b68a02a7bfb8976d1 Mon Sep 17 00:00:00 2001 From: Dennis Alund Date: Wed, 17 Jul 2024 17:22:29 +0200 Subject: [PATCH 3/8] Adding trigger methods --- functions/src/triggers/users.ts | 76 +++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 functions/src/triggers/users.ts diff --git a/functions/src/triggers/users.ts b/functions/src/triggers/users.ts new file mode 100644 index 00000000..4d0ae497 --- /dev/null +++ b/functions/src/triggers/users.ts @@ -0,0 +1,76 @@ +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 validateAndAssignRole = 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 customClaims = (await auth.getUser(uid)).customClaims || {}; + return Promise.all([ + auth.setCustomUserClaims(uid, {...customClaims, tanamRole: tanamUser.role}), + 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 enforceRoleManagement = 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 removeRoleOnDelete = 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 = null; + await auth.setCustomUserClaims(uid, customClaims); +}); From 9597e7789fef06418dbcda2a6191e796a73ceb2a Mon Sep 17 00:00:00 2001 From: Dennis Alund Date: Wed, 17 Jul 2024 17:22:58 +0200 Subject: [PATCH 4/8] Exporting the user trigger functions --- functions/src/index.ts | 1 + 1 file changed, 1 insertion(+) 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"; From 78a191d1e0501d56e757a58cc1166e10a7843d2e Mon Sep 17 00:00:00 2001 From: Dennis Alund Date: Thu, 18 Jul 2024 16:18:41 +0200 Subject: [PATCH 5/8] Updating firestore rules --- firestore.rules | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/firestore.rules b/firestore.rules index 2ffe38c0..bb404b26 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); + } + match /tanam-documents/{documentId} { allow read: if hasAnyRole(); allow write: if isPublisher(); From d38496d7d4eb02791bd61c50dbaa39311454cc23 Mon Sep 17 00:00:00 2001 From: Dennis Alund Date: Thu, 18 Jul 2024 16:19:22 +0200 Subject: [PATCH 6/8] Renaming functions --- functions/src/triggers/users.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/functions/src/triggers/users.ts b/functions/src/triggers/users.ts index 4d0ae497..06b2755f 100644 --- a/functions/src/triggers/users.ts +++ b/functions/src/triggers/users.ts @@ -11,7 +11,7 @@ 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 validateAndAssignRole = onDocumentCreated("tanam-users/{docId}", async (event) => { +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() || {}; @@ -42,7 +42,7 @@ export const validateAndAssignRole = onDocumentCreated("tanam-users/{docId}", as // Function to enforce role management on document update // This function will apply changes to custom claims when the role field is updated -export const enforceRoleManagement = onDocumentUpdated("tanam-users/{docId}", async (event) => { +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(); @@ -66,7 +66,7 @@ export const enforceRoleManagement = onDocumentUpdated("tanam-users/{docId}", as }); // Function to remove role on document deletion -export const removeRoleOnDelete = onDocumentDeleted("tanam-users/{docId}", async (event) => { +export const onTanamUserDeleted = onDocumentDeleted("tanam-users/{docId}", async (event) => { const uid = event.params.docId; console.log(`Document deleted: ${uid}, removing custom claims`); From 62087fbc3f2fb8d14bded97ea698c91ec9149748 Mon Sep 17 00:00:00 2001 From: Dennis Alund Date: Thu, 18 Jul 2024 16:19:38 +0200 Subject: [PATCH 7/8] Adding some logging --- functions/src/triggers/users.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/functions/src/triggers/users.ts b/functions/src/triggers/users.ts index 06b2755f..afe2166b 100644 --- a/functions/src/triggers/users.ts +++ b/functions/src/triggers/users.ts @@ -33,11 +33,15 @@ export const onTanamUserCreated = onDocumentCreated("tanam-users/{docId}", async }); logger.info("Creating User", tanamUser.toJson()); - const customClaims = (await auth.getUser(uid)).customClaims || {}; - return Promise.all([ - auth.setCustomUserClaims(uid, {...customClaims, tanamRole: tanamUser.role}), - docRef.set(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 @@ -71,6 +75,11 @@ export const onTanamUserDeleted = onDocumentDeleted("tanam-users/{docId}", async console.log(`Document deleted: ${uid}, removing custom claims`); const customClaims = (await auth.getUser(uid)).customClaims || {}; - customClaims.tanamRole = null; + customClaims.tanamRole = undefined; + + logger.info(`Tanam user deleted, removing custom claims for ${uid}`, { + customClaims, + }); + await auth.setCustomUserClaims(uid, customClaims); }); From ff9ec97fc73008a88baaf4afd983d518ca64d12f Mon Sep 17 00:00:00 2001 From: Dennis Alund Date: Thu, 18 Jul 2024 16:24:55 +0200 Subject: [PATCH 8/8] Prevent user from changing roles by themselves --- firestore.rules | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firestore.rules b/firestore.rules index bb404b26..36e4248b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -9,7 +9,7 @@ service cloud.firestore { match /tanam-users/{uid} { allow read: if isSignedInAs(uid); // Delete and create must be done manually - allow update: if isSignedInAs(uid); + allow update: if isSignedInAs(uid) && request.resource.data.role == resource.data.role; } match /tanam-documents/{documentId} {