Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,5 @@ dist
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
dist/
dist/
.keep/
64 changes: 59 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 76 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -29,4 +29,4 @@
"prettier": "^3.6.2",
"zod": "^4.1.11"
}
}
}
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
9 changes: 0 additions & 9 deletions src/gsuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,15 +303,6 @@ const contactToAtomXml = (contact: GoogleContact): string => {
);
}

if (
contact.upn &&
contact.upn.toLowerCase() !== contact.email.toLowerCase()
) {
emails.push(
` <gd:email rel="http://schemas.google.com/g/2005#other" address="${escapeXml(contact.upn)}" />`,
);
}

return `<?xml version="1.0" encoding="UTF-8"?>
<atom:entry xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:gd="http://schemas.google.com/g/2005">
Expand Down
6 changes: 3 additions & 3 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"module": "Node16",
"rootDir": "../",
"outDir": "../../dist",
"baseUrl": "../"
"rootDir": "./",
"outDir": "./dist",
"baseUrl": "./"
},
"ts-node": {
"esm": true
Expand Down
Loading