From b260255980ed180475ec70a8d802b0216e053c1a Mon Sep 17 00:00:00 2001 From: Moritz Graf Date: Sun, 26 Apr 2026 17:33:32 +0200 Subject: [PATCH] User adding is now configured and should work --- terraform/firebase.tf | 30 +-- user_creation/.clasp.json | 1 + user_creation/AGENTS.md | 22 +++ user_creation/README.md | 44 +++++ user_creation/src/Code.js | 305 ++++++++++++++++++++++++++++++ user_creation/src/appsscript.json | 13 ++ 6 files changed, 389 insertions(+), 26 deletions(-) create mode 100644 user_creation/.clasp.json create mode 100644 user_creation/AGENTS.md create mode 100644 user_creation/README.md create mode 100644 user_creation/src/Code.js create mode 100644 user_creation/src/appsscript.json diff --git a/terraform/firebase.tf b/terraform/firebase.tf index 7f02287..593d4f5 100644 --- a/terraform/firebase.tf +++ b/terraform/firebase.tf @@ -94,7 +94,7 @@ resource "google_identity_platform_config" "default" { } email { - enabled = false # We only want Google Sign-In + enabled = true } } @@ -105,7 +105,7 @@ resource "google_identity_platform_config" "default" { resource "google_identity_platform_default_supported_idp_config" "google" { provider = google-beta project = var.project_id - enabled = true + enabled = false idp_id = "google.com" client_id = data.google_secret_manager_secret_version.oauth_client_id.secret_data client_secret = data.google_secret_manager_secret_version.oauth_client_secret.secret_data @@ -130,28 +130,6 @@ resource "google_firestore_database" "database" { depends_on = [google_project_service.firestore] } -# Allowlist Configuration Document -resource "google_firestore_document" "allowlist" { - provider = google-beta - project = var.project_id - database = google_firestore_database.database.name - collection = "config" - document_id = "allowlist" - - # Serialize the list of emails into a JSON string map for the fields - fields = jsonencode({ - emails = { - arrayValue = { - values = [ - for email in var.allowed_users : { - stringValue = email - } - ] - } - } - }) -} - # Firestore Security Rules resource "google_firebaserules_ruleset" "firestore" { provider = google @@ -163,8 +141,8 @@ resource "google_firebaserules_ruleset" "firestore" { rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { - match /config/allowlist { - allow read: if request.auth != null; + match /{document=**} { + allow read, write: if false; } } } diff --git a/user_creation/.clasp.json b/user_creation/.clasp.json new file mode 100644 index 0000000..6deca07 --- /dev/null +++ b/user_creation/.clasp.json @@ -0,0 +1 @@ +{"scriptId":"1rKL7JeNXhVPh6uX8DZxQOx7I4QJDUPHL4YH4_4qLSELcOgj_mm6XmaZA","rootDir":"./src"} diff --git a/user_creation/AGENTS.md b/user_creation/AGENTS.md new file mode 100644 index 0000000..1ce2c40 --- /dev/null +++ b/user_creation/AGENTS.md @@ -0,0 +1,22 @@ +# AGENTS.md + +This document provides context for AI agents operating on the `user_creation` infrastructure. + +## Architecture & Tooling +- This directory contains a standalone Google Apps Script project managed via `@google/clasp`. +- Do NOT use TypeScript. We use vanilla `.js` (`src/Code.js`) to bypass local transpilation complexity. +- **Authentication**: We use `ScriptApp.getOAuthToken()` directly to authenticate against the Google Identity Toolkit REST API. **DO NOT** implement Web API Keys or Service Accounts in the code. The script relies on its link to the underlying GCP project to inherit the trigger owner's permissions. + +## Rules & Safeguards (CRITICAL) +1. **Never physically delete users.** + - The declarative logic must use Soft Deletes. If an email is removed from the Google Sheet, the script must issue an API update to set `disableUser: true` in Firebase. This preserves historical records and prevents data corruption. +2. **Respect `DRY_RUN`.** + - When `CONFIG.DRY_RUN` is true, the script must only evaluate state and log its intended API calls. It must completely bypass any `UrlFetchApp.fetch` calls that mutate Firebase state. +3. **Trigger Handling (`setup`).** + - The script uses dual triggers (`onFormSubmit` and `onChange`). Ensure both are cleared and re-created whenever `setup()` is called. + - `clasp push` does not update triggers. Always instruct the human user to run `setup()` manually in the IDE after a push. +4. **Conditional Email Logging.** + - Only dispatch the admin summary email (to `CONFIG.ADMIN_EMAIL`) if a mutation occurred. If the target state and current state are perfectly synchronized, exit silently to prevent inbox pollution. + +## Implementation Details +- **Email/Password Strategy**: The user requested that we do NOT send out official welcome/password reset emails during the initial implementation to avoid spamming end users. We will generate a highly secure random password locally during the `accounts:signUp` request. The admin will manually trigger password resets later when they are ready. diff --git a/user_creation/README.md b/user_creation/README.md new file mode 100644 index 0000000..aac7941 --- /dev/null +++ b/user_creation/README.md @@ -0,0 +1,44 @@ +# Haumdaucher User Creation + +This Google Apps Script automatically provisions local Google Firebase accounts (Email/Password) based on the target emails defined in the Haumdaucher Google Sheet. + +It is designed to run silently and declaratively alongside the `mail_forwarding` module. + +## Architecture +- **Environment**: Google Apps Script (Standalone). +- **Authentication**: Native Google Cloud Platform (GCP) linking. The script authenticates via `ScriptApp.getOAuthToken()` using the underlying GCP project's identity, avoiding hardcoded API keys. +- **API**: Google Identity Toolkit REST API (`accounts:batchGet`, `accounts:signUp`, `accounts:update`). +- **Triggers**: `onFormSubmit` (for real-time form entries) and `onChange` (for manual sheet edits). + +## Declarative Logic +The script compares the desired state (emails in the Sheet) with the current state (users in Firebase Auth): +- **Create**: User in sheet but not in Firebase -> Creates an account with a secure random password. +- **Disable**: User in Firebase but not in sheet -> Soft deletes the account (`disableUser: true`). +- **Re-Enable**: User in sheet and Firebase but disabled -> Re-enables the account. +- **Ignore**: User matches both states and is active -> No action. + +## Operational Instructions + +### 1. Linking to GCP (Required once) +To allow the script to call Firebase APIs securely without an API key: +1. Open the Apps Script project (`clasp open-script`). +2. Click the **Project Settings** (gear icon) on the left. +3. Under **Google Cloud Platform (GCP) Project**, click **Change project**. +4. Enter your GCP Project Number (e.g., `171880300854` - this is the `messagingSenderId` from Terraform outputs). +5. Click **Set Project**. + +### 2. Manual Triggers (setup) +If you modify the code or push a new version, you must reinstall the background triggers: +1. Open the IDE (`clasp open-script`). +2. Select the `setup` function from the dropdown. +3. Click **Run**. +*(Note: `clasp push` only updates code, it does not update running triggers).* + +### 3. Dry Run Mode +By default, the code is set to `DRY_RUN: true`. It will read states and print its intended actions to the Execution Logs, but will NOT mutate Firebase data. +To activate: +1. Change `DRY_RUN: false` in `src/Code.js`. +2. Run `clasp push`. + +### 4. Admin Reporting +The script will send an email to `moritz@haumdaucher.de` **only if** state changes occurred (creating, disabling, or re-enabling a user). If no changes are needed, it remains completely silent. diff --git a/user_creation/src/Code.js b/user_creation/src/Code.js new file mode 100644 index 0000000..227cd0f --- /dev/null +++ b/user_creation/src/Code.js @@ -0,0 +1,305 @@ +const CONFIG = { + SPREADSHEET_ID: "1q4r08nBA_ClWv3ypPCQ6GVCfMVkQwSKzDSRiokkQQ8Q", // ID from mail_forwarding + SHEET_NAME: "Form Responses 1", + COL_FORWARD_TO_ADDRESS: 4, // 1-indexed (Column D from mail_forwarding) + ADMIN_EMAIL: "moritz@haumdaucher.de", + PROJECT_ID: "haumdaucher", // Used in the Identity Toolkit REST API + DRY_RUN: false, + SEND_EMAIL_ON_CREATION: false, +}; + +/** + * Run this function from the Apps Script IDE after pushing code + * to establish the required background triggers. + */ +function setup() { + const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID); + + // Clear old triggers + const existingTriggers = ScriptApp.getProjectTriggers(); + for (const trigger of existingTriggers) { + if (trigger.getHandlerFunction() === 'syncUsers') { + ScriptApp.deleteTrigger(trigger); + } + } + + // Install triggers + ScriptApp.newTrigger('syncUsers') + .forSpreadsheet(ss) + .onFormSubmit() + .create(); + + ScriptApp.newTrigger('syncUsers') + .forSpreadsheet(ss) + .onChange() + .create(); + + const triggers = ScriptApp.getProjectTriggers(); + console.log(`Setup complete. ${triggers.length} trigger(s) are now active for 'syncUsers'.`); +} + +/** + * Main Entry Point + */ +function syncUsers() { + console.log(`Starting syncUsers... (DRY_RUN: ${CONFIG.DRY_RUN})`); + + try { + const desiredEmails = getDesiredState(); + console.log(`Desired state: ${desiredEmails.length} valid email(s) found in sheet.`); + + const currentState = getCurrentState(); + console.log(`Current state: ${Object.keys(currentState).length} user(s) found in Firebase.`); + + const actions = reconcileUsers(desiredEmails, currentState); + + if (actions.length > 0) { + console.log(`Found ${actions.length} action(s) to execute.`); + sendAdminReport(actions); + } else { + console.log("States are synchronized. No actions required."); + } + + } catch (err) { + console.error("Error during syncUsers:", err); + MailApp.sendEmail({ + to: CONFIG.ADMIN_EMAIL, + subject: "Haumdaucher Firebase Sync Failed", + body: `The Firebase User sync script failed with the following error:\n\n${err.toString()}` + }); + } +} + +/** + * Extracts valid emails from the Google Sheet. + */ +function getDesiredState() { + const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID); + const sheet = ss.getSheetByName(CONFIG.SHEET_NAME); + const data = sheet.getDataRange().getValues(); + + const emails = new Set(); + + // Start from row 1 (skipping header row 0) + for (let i = 1; i < data.length; i++) { + const row = data[i]; + const email = row[CONFIG.COL_FORWARD_TO_ADDRESS - 1]; + + if (email && typeof email === 'string' && email.trim() !== "") { + const cleanEmail = email.trim().toLowerCase(); + emails.add(cleanEmail); + } + } + + return Array.from(emails); +} + +/** + * Fetches all users from Firebase Identity Platform. + * Returns an object map: { "email@example.com": { localId: "...", disabled: false } } + */ +function getCurrentState() { + const token = ScriptApp.getOAuthToken(); + const url = `https://identitytoolkit.googleapis.com/v1/projects/${CONFIG.PROJECT_ID}/accounts:query`; + + // We use query with an empty payload to list users. + // Note: For very large datasets, pagination (nextPageToken) would be required. + const payload = { + returnUserInfo: true, + maxResults: 1000 // Safely assuming < 1000 users as per requirements + }; + + const options = { + method: 'post', + contentType: 'application/json', + headers: { + "Authorization": "Bearer " + token + }, + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + + console.log("Calling Identity Toolkit accounts:query..."); + const response = UrlFetchApp.fetch(url, options); + const json = JSON.parse(response.getContentText()); + + if (response.getResponseCode() !== 200) { + throw new Error(`Firebase API Error: ${response.getContentText()}`); + } + + const userMap = {}; + if (json.userInfo) { + json.userInfo.forEach(user => { + if (user.email) { + userMap[user.email.toLowerCase()] = { + localId: user.localId, + disabled: user.disabled || false + }; + } + }); + } + return userMap; +} + +/** + * Evaluates differences and executes mutations against Firebase. + */ +function reconcileUsers(desiredEmails, currentState) { + const token = ScriptApp.getOAuthToken(); + const executedActions = []; + + // 1. Check for Creations and Re-enables + for (const email of desiredEmails) { + const fbUser = currentState[email]; + + if (!fbUser) { + // Missing in Firebase -> CREATE + console.log(`[ACTION: CREATE] User missing in Firebase: ${email}`); + if (!CONFIG.DRY_RUN) { + const password = generateSecurePassword(); + const success = createUserInFirebase(email, password, token); + if (success) { + executedActions.push(`Created user: ${email}`); + if (CONFIG.SEND_EMAIL_ON_CREATION) { + sendPasswordReset(email, token); + executedActions.push(`Sent password reset email to: ${email}`); + } + } + } else { + executedActions.push(`[DRY RUN] Would create user: ${email}`); + } + } else if (fbUser.disabled) { + // Exists but disabled -> RE-ENABLE + console.log(`[ACTION: RE-ENABLE] User is disabled in Firebase: ${email}`); + if (!CONFIG.DRY_RUN) { + const success = updateUserStatus(fbUser.localId, false, token); + if (success) executedActions.push(`Re-enabled user: ${email}`); + } else { + executedActions.push(`[DRY RUN] Would re-enable user: ${email}`); + } + } + } + + // 2. Check for Disablements + for (const [email, fbUser] of Object.entries(currentState)) { + if (!desiredEmails.includes(email) && !fbUser.disabled) { + // Exists in Firebase, but NOT in sheet -> DISABLE (Soft Delete) + console.log(`[ACTION: DISABLE] User not in sheet: ${email}`); + if (!CONFIG.DRY_RUN) { + const success = updateUserStatus(fbUser.localId, true, token); + if (success) executedActions.push(`Disabled user: ${email}`); + } else { + executedActions.push(`[DRY RUN] Would disable user: ${email}`); + } + } + } + + return executedActions; +} + +/** + * Calls Identity Toolkit to create a user. + */ +function createUserInFirebase(email, password, token) { + const url = `https://identitytoolkit.googleapis.com/v1/projects/${CONFIG.PROJECT_ID}/accounts`; + + const payload = { + email: email, + password: password, + emailVerified: true // Pre-verified since it comes from our trusted sheet + }; + + const options = { + method: 'post', + contentType: 'application/json', + headers: { "Authorization": "Bearer " + token }, + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + + const res = UrlFetchApp.fetch(url, options); + if (res.getResponseCode() !== 200) { + console.error(`Failed to create ${email}: ${res.getContentText()}`); + return false; + } + return true; +} + +/** + * Calls Identity Toolkit to update user status (enable/disable). + */ +function updateUserStatus(localId, disabled, token) { + const url = `https://identitytoolkit.googleapis.com/v1/projects/${CONFIG.PROJECT_ID}/accounts:update`; + + const payload = { + localId: localId, + disableUser: disabled + }; + + const options = { + method: 'post', + contentType: 'application/json', + headers: { "Authorization": "Bearer " + token }, + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + + const res = UrlFetchApp.fetch(url, options); + if (res.getResponseCode() !== 200) { + console.error(`Failed to update ${localId}: ${res.getContentText()}`); + return false; + } + return true; +} + +/** + * Calls Identity Toolkit to send a password reset email. + */ +function sendPasswordReset(email, token) { + const url = `https://identitytoolkit.googleapis.com/v1/projects/${CONFIG.PROJECT_ID}/accounts:sendOobCode`; + + const payload = { + requestType: "PASSWORD_RESET", + email: email + }; + + const options = { + method: 'post', + contentType: 'application/json', + headers: { "Authorization": "Bearer " + token }, + payload: JSON.stringify(payload), + muteHttpExceptions: true + }; + + const res = UrlFetchApp.fetch(url, options); + if (res.getResponseCode() !== 200) { + console.error(`Failed to send reset email to ${email}: ${res.getContentText()}`); + } +} + +/** + * Generates a 24-character secure random password. + */ +function generateSecurePassword() { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?"; + let pwd = ""; + for (let i = 0; i < 24; i++) { + pwd += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return pwd; +} + +/** + * Sends the final admin report. + */ +function sendAdminReport(actions) { + const subject = `Firebase User Sync Report ${CONFIG.DRY_RUN ? '[DRY RUN]' : ''}`; + const body = `The synchronization process has completed.\n\nActions taken:\n` + + actions.map(a => `- ${a}`).join("\n"); + + MailApp.sendEmail({ + to: CONFIG.ADMIN_EMAIL, + subject: subject, + body: body + }); +} diff --git a/user_creation/src/appsscript.json b/user_creation/src/appsscript.json new file mode 100644 index 0000000..c7cc978 --- /dev/null +++ b/user_creation/src/appsscript.json @@ -0,0 +1,13 @@ +{ + "timeZone": "Europe/Berlin", + "dependencies": {}, + "exceptionLogging": "STACKDRIVER", + "oauthScopes": [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/script.external_request", + "https://www.googleapis.com/auth/script.send_mail", + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/script.scriptapp" + ], + "runtimeVersion": "V8" +} \ No newline at end of file