google-user-sync/lambda/index.js
2026-05-11 13:54:24 -04:00

290 lines
8.8 KiB
JavaScript

/**
* google-user-sync Lambda
*
* Pulls user profiles from Google Workspace Admin Directory API
* and syncs job title + phone to Front teammate custom fields.
*
* Environment variables:
* GOOGLE_SECRET_ARN - Secrets Manager ARN for Google service account JSON key
* FRONT_SECRET_ARN - Secrets Manager ARN for Front API token
* GOOGLE_ADMIN_EMAIL - Email of a Google Workspace admin to impersonate
* GOOGLE_DOMAIN - Domain to list users for (e.g. seahaven.com)
* GOOGLE_OUS - Comma-separated org unit paths to sync (e.g. /Office/Scheduling,/Office/Operations)
*/
const {
SecretsManagerClient,
GetSecretValueCommand,
} = require("@aws-sdk/client-secrets-manager");
const crypto = require("crypto");
const sm = new SecretsManagerClient({ region: "us-east-1" });
// ---------------------------------------------------------------------------
// Google Auth (JWT → Access Token using service account)
// ---------------------------------------------------------------------------
function base64url(buf) {
return Buffer.from(buf)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
async function getGoogleAccessToken(serviceAccountKey, adminEmail) {
const now = Math.floor(Date.now() / 1000);
const header = { alg: "RS256", typ: "JWT" };
const payload = {
iss: serviceAccountKey.client_email,
sub: adminEmail,
scope: "https://www.googleapis.com/auth/admin.directory.user.readonly",
aud: "https://oauth2.googleapis.com/token",
iat: now,
exp: now + 3600,
};
const segments = [
base64url(JSON.stringify(header)),
base64url(JSON.stringify(payload)),
];
const signingInput = segments.join(".");
const sign = crypto.createSign("RSA-SHA256");
sign.update(signingInput);
const signature = sign.sign(serviceAccountKey.private_key);
const jwt = signingInput + "." + base64url(signature);
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: jwt,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Google token exchange failed (${res.status}): ${text}`);
}
const data = await res.json();
return data.access_token;
}
// ---------------------------------------------------------------------------
// Google Admin Directory: list users for a specific OU
// ---------------------------------------------------------------------------
async function listGoogleUsersInOU(accessToken, domain, orgUnitPath) {
const users = [];
let pageToken = null;
do {
const params = new URLSearchParams({
domain,
maxResults: "500",
projection: "full",
orderBy: "email",
query: `orgUnitPath='${orgUnitPath}'`,
});
if (pageToken) params.set("pageToken", pageToken);
const res = await fetch(
`https://admin.googleapis.com/admin/directory/v1/users?${params}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!res.ok) {
const text = await res.text();
throw new Error(
`Google Directory API error for OU "${orgUnitPath}" (${res.status}): ${text}`
);
}
const data = await res.json();
if (data.users) users.push(...data.users);
pageToken = data.nextPageToken || null;
} while (pageToken);
return users;
}
async function listGoogleUsers(accessToken, domain, orgUnits) {
const allUsers = [];
const seen = new Set();
for (const ou of orgUnits) {
console.log(`Fetching users from OU: ${ou}`);
const users = await listGoogleUsersInOU(accessToken, domain, ou);
console.log(` Found ${users.length} users in ${ou}`);
for (const user of users) {
if (!seen.has(user.primaryEmail)) {
seen.add(user.primaryEmail);
allUsers.push(user);
}
}
}
return allUsers;
}
// ---------------------------------------------------------------------------
// Extract fields from a Google user object
// ---------------------------------------------------------------------------
function extractUserFields(googleUser) {
const email = googleUser.primaryEmail;
let jobTitle = "";
if (googleUser.organizations && googleUser.organizations.length > 0) {
const primaryOrg = googleUser.organizations.find((o) => o.primary) || googleUser.organizations[0];
jobTitle = primaryOrg.title || "";
}
let phone = "";
if (googleUser.phones && googleUser.phones.length > 0) {
const workPhone = googleUser.phones.find((p) => p.type === "work");
const primaryPhone = googleUser.phones.find((p) => p.primary) || googleUser.phones[0];
phone = workPhone
? workPhone.value
: primaryPhone.value || "";
}
return { email, jobTitle, phone };
}
// ---------------------------------------------------------------------------
// Front API: update teammate custom fields
// ---------------------------------------------------------------------------
async function updateFrontTeammate(frontToken, email, customFields) {
const url = `https://api2.frontapp.com/teammates/alt:email:${encodeURIComponent(email)}`;
const headers = {
Authorization: `Bearer ${frontToken}`,
"Content-Type": "application/json",
Accept: "application/json",
};
const getRes = await fetch(url, { headers });
if (getRes.status === 404) return { status: "not_in_front" };
if (!getRes.ok) {
const text = await getRes.text();
return { status: "error", code: getRes.status, message: text };
}
const teammate = await getRes.json();
const merged = { ...teammate.custom_fields, ...customFields };
const patchRes = await fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify({ custom_fields: merged }),
});
if (patchRes.status === 204) return { status: "updated" };
const text = await patchRes.text();
return { status: "error", code: patchRes.status, message: text };
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
exports.handler = async (event) => {
console.log("Starting Front ← Google Directory sync");
// 1. Fetch secrets
const [googleSecretRes, frontSecretRes] = await Promise.all([
sm.send(
new GetSecretValueCommand({ SecretId: process.env.GOOGLE_SECRET_ARN })
),
sm.send(
new GetSecretValueCommand({ SecretId: process.env.FRONT_SECRET_ARN })
),
]);
const googleKey = JSON.parse(googleSecretRes.SecretString);
const frontToken = frontSecretRes.SecretString;
// 2. Get Google access token
const accessToken = await getGoogleAccessToken(
googleKey,
process.env.GOOGLE_ADMIN_EMAIL
);
console.log("Obtained Google access token");
// 3. List Google Workspace users from specified OUs
const orgUnits = process.env.GOOGLE_OUS
.split(",")
.map((ou) => ou.trim())
.filter(Boolean);
console.log(`Syncing OUs: ${orgUnits.join(", ")}`);
const googleUsers = await listGoogleUsers(
accessToken,
process.env.GOOGLE_DOMAIN,
orgUnits
);
console.log(`Found ${googleUsers.length} total users across ${orgUnits.length} OUs`);
// 4. Sync each user to Front
const summary = {
updated: 0,
notInFront: 0,
noData: 0,
errors: 0,
errorDetails: [],
};
for (const googleUser of googleUsers) {
const { email, jobTitle, phone } = extractUserFields(googleUser);
// Skip users with no title AND no phone — nothing to sync
if (!jobTitle && !phone) {
console.log(`Skipping ${email} — no title or phone in Google`);
summary.noData++;
continue;
}
const customFields = {};
if (jobTitle) customFields["Job Title"] = jobTitle;
if (phone) customFields["Phone"] = phone;
try {
const result = await updateFrontTeammate(frontToken, email, customFields);
if (result.status === "updated") {
console.log(`Updated ${email}: ${JSON.stringify(customFields)}`);
summary.updated++;
} else if (result.status === "not_in_front") {
console.log(`Skipped ${email} — not a Front teammate`);
summary.notInFront++;
} else {
console.error(`Error updating ${email}: ${result.code} ${result.message}`);
summary.errors++;
summary.errorDetails.push({
email,
message: `${result.code}: ${result.message}`,
});
}
} catch (err) {
console.error(`Exception updating ${email}:`, err);
summary.errors++;
summary.errorDetails.push({ email, message: err.message });
}
// Simple rate-limit courtesy — Front API has rate limits
await new Promise((r) => setTimeout(r, 200));
}
console.log("Sync summary:", JSON.stringify(summary));
return {
statusCode: 200,
body: summary,
};
};