diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..31a5bdc
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,73 @@
+name: Run unit tests
+run-name: Unit tests - @${{ github.actor }}
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ test:
+ permissions:
+ contents: read
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ name: Run Unit Tests
+ steps:
+ - uses: actions/checkout@v4
+ env:
+ HUSKY: "0"
+
+ - name: Setup Terraform
+ uses: hashicorp/setup-terraform@v3
+ with:
+ terraform_version: 1.12.2
+
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22.x
+ cache: "yarn"
+
+ - name: Restore Yarn Cache
+ uses: actions/cache@v4
+ with:
+ path: node_modules
+ key: yarn-modules-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-dev
+ restore-keys: |
+ yarn-modules-${{ runner.arch }}-${{ runner.os }}-
+
+ - name: Run unit testing
+ run: make test_unit
+
+ build:
+ permissions:
+ contents: read
+ runs-on: ubuntu-24.04-arm
+ timeout-minutes: 15
+ name: Build Application
+ steps:
+ - uses: actions/checkout@v4
+ env:
+ HUSKY: "0"
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22.x
+ cache: "yarn"
+
+ - name: Restore Yarn Cache
+ uses: actions/cache@v4
+ with:
+ path: node_modules
+ key: yarn-modules-${{ runner.arch }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-dev
+ restore-keys: |
+ yarn-modules-${{ runner.arch }}-${{ runner.os }}-
+
+ - name: Run build
+ run: make build
+ env:
+ HUSKY: "0"
+ RunEnvironment: prod
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 855250a..dbda2d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -139,4 +139,5 @@ dist
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
-dist/
\ No newline at end of file
+dist/
+.keep/
\ No newline at end of file
diff --git a/README.md b/README.md
index 6f977b3..85ad761 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,65 @@
# Entra ID to Google Workspace External Contact Sync
+## Purpose
-# Creating certificates
+This Lambda function syncs members from our Entra ID (Azure AD) tenant to Google Workspace as external domain shared contacts.
+
+### Why This Exists
+
+In the `acm.illinois.edu` Google Workspace tenant, we cannot use people chips or autocomplete for `@illinois.edu` email addresses because they're in a separate identity system (GSuite for UIUC). This creates friction when trying to email or mention Illinois users.
+
+**This sync solves that problem** by:
+- Automatically pulling all active users from the University of Illinois Entra ID tenant
+- Creating them as external contacts in Google Workspace's domain shared contacts
+- Making Illinois email addresses searchable and autocomplete-able in Gmail, Calendar, Drive, etc.
+- Providing a seamless experience where ACM members can easily find and contact other Illinois users
+
+Users will now see Illinois emails appear in autocomplete suggestions and people chips work correctly across all Google Workspace apps.
+
+## Architecture
+
+- **Source**: ACM @ UIUC Entra ID tenant
+- **Destination**: Google Workspace Domain Shared Contacts for `acm.illinois.edu`
+- **Sync Frequency**: Configurable via EventBridge schedule (default: every hour)
+- **Fields Synced**: First name, last name, display name, email (primary)
+
+## Configuration
+
+Configuration is stored in AWS Secrets Manager under the secret `gsuite-dirsync-config`:
+
+## How It Works
+
+1. **Fetch**: Retrieves all enabled users from Entra ID using certificate authentication
+2. **Parse**: Extracts name and email fields, intelligently parsing various display name formats
+3. **Compare**: Fetches existing domain shared contacts from Google Workspace
+4. **Sync**: Creates, updates, or deletes contacts as needed
+5. **Log**: Reports statistics on synced contacts
+
+## Contact Format
+
+Contacts are created with:
+- **Primary email**: The user's mail field from Entra ID
+- **Name fields**: Given name, family name, and display name
+- **Smart parsing**: Automatically parses display names like "First Last", "Last, First", etc. when individual name fields are missing
+
+## Deployment
+
+The Lambda is deployed via Terraform. Set the Makefile.
+
+## Monitoring
+
+View logs in CloudWatch Logs:
+- Log group: `/aws/lambda/gsuite-dirsync-engine`
+- Structured JSON logging via Pino
+- Contains detailed sync statistics and any errors
+
+## Development
+
+Run locally:
```bash
-openssl req -x509 -newkey rsa:2048 -keyout private-key.pem -out certificate.pem -days 7350 -nodes -subj "/CN=DirSync"
-cat private-key.pem certificate.pem > combined.pem
-base64 -i combined.pem -o combined-base64.txt
+yarn -D
+make local
```
+---
-Upload `certificate.pem` to Azure, and the contents of `combined-base64.txt` to Secrets Manager.
+For detailed setup instructions, see the setup documentation.
\ No newline at end of file
diff --git a/docs/setup.md b/docs/setup.md
new file mode 100644
index 0000000..ff0b535
--- /dev/null
+++ b/docs/setup.md
@@ -0,0 +1,76 @@
+
+## Setup
+
+### 1. Entra ID Certificate Authentication
+
+Generate a certificate for authenticating with Entra ID:
+
+```bash
+# Generate certificate and private key
+openssl req -x509 -newkey rsa:2048 -keyout private-key.pem -out certificate.pem -days 7350 -nodes -subj "/CN=DirSync"
+
+# Combine them
+cat private-key.pem certificate.pem > combined.pem
+
+# Base64 encode for storage
+base64 -i combined.pem -o combined-base64.txt
+```
+
+**Upload to Azure:**
+1. Go to Azure Portal → App Registrations
+2. Select your app registration
+3. Navigate to **Certificates & secrets** → **Certificates**
+4. Upload `certificate.pem`
+
+**Upload to AWS Secrets Manager:**
+- Store the contents of `combined-base64.txt` as the `entraClientCertificate` field in your secret
+
+### 2. Azure App Registration Setup
+
+1. Create an app registration in Azure Portal
+2. Grant API permissions:
+ - **Microsoft Graph** → `User.Read.All` (Application permission)
+3. Grant admin consent for the tenant
+4. Note the **Tenant ID** and **Application (client) ID**
+
+### 3. Google Workspace Setup
+
+#### Create Service Account
+1. Go to [Google Cloud Console](https://console.cloud.google.com)
+2. Create a new project (or use existing)
+3. Enable the **Contacts API**
+4. Create a service account
+5. Download the service account JSON key
+
+#### Enable Domain-Wide Delegation
+1. In the service account details, enable **Domain-wide delegation**
+2. Note the **Client ID** (numeric)
+
+#### Authorize in Google Workspace
+1. Go to [admin.google.com](https://admin.google.com)
+2. Navigate to **Security** → **Access and data control** → **API controls**
+3. Click **Manage Domain Wide Delegation**
+4. Add new:
+ - **Client ID**: The numeric client ID from your service account
+ - **OAuth Scopes**: `https://www.google.com/m8/feeds`
+5. Click **Authorize**
+
+#### Set Delegated User
+The `googleDelegatedUser` must be a **Super Admin** in your Google Workspace domain.
+
+## Troubleshooting
+
+### "Not Authorized to access this resource/api"
+- Verify the delegated user is a Super Admin
+- Check domain-wide delegation is properly configured
+- Ensure the Client ID and scopes are correct
+
+### "Failed to fetch contacts: Forbidden"
+- Verify the scope `https://www.google.com/m8/feeds` is authorized
+- Check that domain-wide delegation is enabled for the service account
+- Wait 5-10 minutes after authorization for changes to propagate
+
+### Contacts not appearing
+- Domain Shared Contacts sync can take a few minutes to propagate
+- Verify contacts are being created (check CloudWatch logs)
+- Try signing out and back in to Google Workspace
\ No newline at end of file
diff --git a/package.json b/package.json
index 46324bc..8ac1495 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"version": "1.0.0",
"description": "Syncs EntraID directory to GSuite",
"type": "module",
- "main": "index.js",
+ "main": "dist/sync.js",
"author": "ACM@UIUC",
"license": "BSD-3-Clause",
"scripts": {
@@ -29,4 +29,4 @@
"prettier": "^3.6.2",
"zod": "^4.1.11"
}
-}
+}
\ No newline at end of file
diff --git a/src/config.ts b/src/config.ts
index 5bef62a..4025ed6 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -17,7 +17,7 @@ const SecretsConfigSchema = z.object({
googleServiceAccountJson: z
.string()
.min(1, "googleServiceAccountJson is required"),
- deleteRemovedContacts: z.boolean().default(false),
+ deleteRemovedContacts: z.boolean().default(true),
});
const EnvironmentSchema = z.enum(["dev", "prod"]);
diff --git a/src/gsuite.ts b/src/gsuite.ts
index b9d2ecf..b8443f7 100644
--- a/src/gsuite.ts
+++ b/src/gsuite.ts
@@ -303,15 +303,6 @@ const contactToAtomXml = (contact: GoogleContact): string => {
);
}
- if (
- contact.upn &&
- contact.upn.toLowerCase() !== contact.email.toLowerCase()
- ) {
- emails.push(
- ` `,
- );
- }
-
return `
diff --git a/tsconfig.json b/tsconfig.json
index 38487f0..5afdfb0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,9 +2,9 @@
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"module": "Node16",
- "rootDir": "../",
- "outDir": "../../dist",
- "baseUrl": "../"
+ "rootDir": "./",
+ "outDir": "./dist",
+ "baseUrl": "./"
},
"ts-node": {
"esm": true