User adding is now configured and should work
This commit is contained in:
parent
6d43326562
commit
b260255980
|
|
@ -94,7 +94,7 @@ resource "google_identity_platform_config" "default" {
|
||||||
}
|
}
|
||||||
|
|
||||||
email {
|
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" {
|
resource "google_identity_platform_default_supported_idp_config" "google" {
|
||||||
provider = google-beta
|
provider = google-beta
|
||||||
project = var.project_id
|
project = var.project_id
|
||||||
enabled = true
|
enabled = false
|
||||||
idp_id = "google.com"
|
idp_id = "google.com"
|
||||||
client_id = data.google_secret_manager_secret_version.oauth_client_id.secret_data
|
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
|
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]
|
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
|
# Firestore Security Rules
|
||||||
resource "google_firebaserules_ruleset" "firestore" {
|
resource "google_firebaserules_ruleset" "firestore" {
|
||||||
provider = google
|
provider = google
|
||||||
|
|
@ -163,8 +141,8 @@ resource "google_firebaserules_ruleset" "firestore" {
|
||||||
rules_version = '2';
|
rules_version = '2';
|
||||||
service cloud.firestore {
|
service cloud.firestore {
|
||||||
match /databases/{database}/documents {
|
match /databases/{database}/documents {
|
||||||
match /config/allowlist {
|
match /{document=**} {
|
||||||
allow read: if request.auth != null;
|
allow read, write: if false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"scriptId":"1rKL7JeNXhVPh6uX8DZxQOx7I4QJDUPHL4YH4_4qLSELcOgj_mm6XmaZA","rootDir":"./src"}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue