Automate Google Workspace User Lifecycle
Build Python scripts to provision, deprovision, and audit Google Workspace users.
Introduction
30 Second Summary
Every time a company hires or fires someone, an IT admin manually clicks through dozens of form fields to create, configure, or disable that person's account. Multiply that by a batch of 50 new hires on the same Monday morning, and you have a recipe for missed steps, security gaps, and hours of repetitive work.
In this project, you will build a Python automation toolkit that handles the entire user lifecycle in Google Workspace. You will provision users in bulk from a CSV, safely deprovision departures, and generate a compliance audit report.
What You'll Build
You will run a single terminal command and watch five new hires appear in your Google Workspace console, correctly placed in their departments with group memberships assigned, ready to work.
By the end of this project, you'll have:
- A bulk provisioning script that creates users from a CSV file, placing each in the correct organizational unit with appropriate group membership in a single command.
- A deprovisioning workflow that suspends a departed user, signs out all their sessions, and relocates them to a "Departed" OU automatically.
- A compliance audit script that scans your domain, flags stale accounts and 2-Step Verification violations, and outputs a shareable HTML report you can hand to stakeholders.
- Secret Mission: Enhanced deprovisioning with third-party app token revocation and Slack webhook notifications for team visibility.
Are there any prerequisites?
You need Python 3.10 or higher installed on your machine and a Google account (a personal Gmail works). You will also purchase a cheap domain during signup, which costs between $1-12 per year.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters for your career in IT administration.
Set Up Google Workspace Trial and Python Environment
The Admin SDK Directory API lets you manage users, groups, and organizational units programmatically. To call it, you need a real Google Workspace domain where you have super admin privileges.
In this step, you will set up a Workspace trial with a purchased domain, create a Google Cloud project for API access, and write a Python script that proves the connection works by listing your domain's users.
In this step, get ready to:
- Sign up for a Google Workspace Business Starter 14-day free trial with a domain.
- Create a Google Cloud project with the Admin SDK API enabled and OAuth credentials.
- Write and run quickstart.py to confirm API connectivity.
Sign up for a Google Workspace trial
Google Workspace requires a domain you own. During the signup flow you can purchase a cheap domain (typically $1-12/year for a .xyz or similar TLD). The trial is free for 14 days with up to 10 users.
- Open your browser and navigate to workspace.google.com/pricing.
- Find the **Business Starter** plan and click Start a trial.
- Sign in with your personal Google account when prompted.
- Enter a business name (anything is fine for this project, e.g. "NextWork Lab").
- When asked about your domain, choose the option to purchase a new domain.
- Pick a cheap domain (a .xyz domain is often under $2/year). This is the one real cost of the project.
- Complete the account setup wizard, including entering a payment method. Your card will not be charged during the 14-day trial.
- Once setup finishes, confirm you can access the Admin console by navigating to admin.google.com and signing in with your new Workspace admin account.
Set a reminder to cancel
Your Workspace trial converts to a paid subscription after 14 days. Set a calendar reminder now so you can cancel in Before You Go if you do not want ongoing charges.
What is Google Workspace?
Google Workspace is Google's suite of productivity and collaboration tools (Gmail, Drive, Calendar) bundled with an Admin console for managing users and security policies. As a super admin, you control who has accounts, what groups they belong to, and which security policies apply.
You should now see the Admin console dashboard showing your domain name and your admin account listed as the first user.
Can't access the Admin console?
Make sure you are signed in with the Workspace admin account (your new domain email), not your personal Gmail. If you are stuck at domain verification, check that your domain purchase completed successfully in the signup flow.
Help me troubleshoot Admin console access
Create a Google Cloud project and install Python libraries
Your Python scripts talk to Workspace through the Admin SDK API, which lives in Google Cloud. You need a Cloud project with the API enabled and OAuth credentials that let your scripts authenticate as you.
- Open a terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir workspace-automation
cd workspace-automation
- Confirm the folder exists by running:
ls
You should see an empty directory (no output, or just the folder name listed if you ran ls from Desktop). This is your project's home.
Now set up the Google Cloud project:
- Navigate to console.cloud.google.com in your browser. Sign in with your new Workspace admin account.
- Click the project selector dropdown at the top of the page, then click New Project.
- Name it workspace-automation and click Create.
- Once created, make sure the project is selected in the top dropdown.
- Navigate to the API Library: click the hamburger menu, then APIs & Services, then Library.
- Search for Admin SDK API, click on it, then click Enable.
You should see the Admin SDK API listed as enabled with a blue checkmark on its overview page.
Next, create the OAuth credentials your script will use to authenticate:
- In the Google Cloud Console, navigate to the hamburger menu, then Google Auth platform, then Clients.
- Click Create Client.
- Select Application type as Desktop app.
- Enter workspace-automation in the Name field.
- Click Create.
- Download the JSON file and save it as credentials.json in your workspace-automation folder on your Desktop.
What is OAuth 2.0?
OAuth 2.0 is an authorization protocol that lets your script act on your behalf without hardcoding your password. When you run the script, it opens a browser window where you grant permission. After that, a token file handles future authentication automatically.
- Back in your terminal (still inside workspace-automation), install the required Python libraries by running:
pip install google-api-python-client google-auth-oauthlib google-auth-httplib2
What do these libraries do?
- google-api-python-client provides the client for calling Google APIs, including the Admin SDK Directory API.
- google-auth-oauthlib handles the OAuth 2.0 browser-based login flow.
- google-auth-httplib2 bridges authentication with HTTP transport.
You should see output ending with Successfully installed followed by the package names.
Pip command not found?
Try pip3 instead of pip. On some systems, Python 3's package manager is only available as pip3.
Help me fix pip install issues
- Verify credentials.json is in your project folder by running:
ls
You should see credentials.json listed in the output. If not, move the downloaded file into your workspace-automation folder now.
Write and run quickstart.py
With your credentials in place, you can write a script that authenticates via OAuth and calls the Directory API to list users. This confirms the full chain works: credentials, scopes, API access, and your admin permissions.
- Create a new file called quickstart.py in your workspace-automation folder. Open it in your preferred text editor.
- Add the imports and scopes by pasting this code at the top of the file:
import os.path
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
# Scopes define what your script can access in the Admin console
SCOPES = [
"https://www.googleapis.com/auth/admin.directory.user",
"https://www.googleapis.com/auth/admin.directory.group",
"https://www.googleapis.com/auth/admin.directory.group.member",
"https://www.googleapis.com/auth/admin.directory.orgunit",
"https://www.googleapis.com/auth/admin.directory.user.security",
]
What does this code do?
- The imports bring in Google's authentication and API client libraries.
- SCOPES declares the permissions your script requests. Each URL grants access to a different part of the Admin SDK (users, groups, org units, security tokens). You request all five upfront so later scripts work without re-authenticating.
- Below the SCOPES list, add the authentication function:
def get_service():
# Load cached credentials if they exist
creds = None
if os.path.exists("token.json"):
creds = Credentials.from_authorized_user_file("token.json", SCOPES)
# If no valid creds, run the OAuth flow
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)
creds = flow.run_local_server(port=0)
with open("token.json", "w") as token:
token.write(creds.to_json())
return build("admin", "directory_v1", credentials=creds)
What does this code do?
- get_service() handles all authentication. It first checks for a saved token.json file (so you do not have to re-authenticate every run).
- If no valid token exists, it launches a browser-based OAuth flow using your credentials.json file. After you approve, it saves the token for future use.
- Finally, it builds and returns an Admin SDK service object you can use to call API methods.
- Below get_service(), add the main function that lists domain users:
def main():
service = get_service()
print("Getting users in the domain...")
# List up to 10 users in the domain
results = (
service.users()
.list(customer="my_customer", maxResults=10, orderBy="email")
.execute()
)
users = results.get("users", [])
if not users:
print("No users in the domain.")
else:
print("Users:")
for user in users:
print(f" {user['primaryEmail']} ({user['name']['fullName']})")
if __name__ == "__main__":
main()
What does this code do?
- main() calls get_service() to authenticate, then uses service.users().list() to fetch users from your domain.
- customer="my_customer" is a special alias meaning "the domain this admin account belongs to".
- The script prints each user's email and full name. Right now, you should only have your admin account.
- Save quickstart.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved quickstart.py with all three sections (imports/SCOPES, get_service(), and main()) in your workspace-automation folder alongside credentials.json.
ⓧ I'd like to double check the full code
Your quickstart.py should contain all three sections in this order:
- The imports and SCOPES list (5 scope URLs).
- The get_service() function that handles OAuth authentication and returns the service object.
- The main() function that lists users, plus the if __name__ == "__main__": entry point.
Scroll up and confirm each chunk is present and in order. Make sure there are no extra blank lines between sections beyond what was shown above.
Before you run this, what do you think will happen? Will the script just print output immediately, or will something else happen first?
- Run the script:
python quickstart.py
What will happen when you run this?
Because this is your first run, the script will open a browser window asking you to sign in with your Workspace admin account and grant the requested permissions. After you approve, the browser will show a confirmation message and the script continues in your terminal.
If your browser shows a warning that the app is unverified, click Advanced, then Go to workspace-automation (unsafe). This is expected for development OAuth apps you own.
- When the browser opens, sign in with your Workspace admin account (the domain email, not your personal Gmail).
- Grant all requested permissions by clicking Allow.
Back in your terminal, you should see output like this:
Getting users in the domain...
Users:
admin@yourdomain.xyz (Admin User)
Your admin email appears because it is the only user in the domain so far. The connection is working end to end.
Seeing an error instead of users?
- If you see a file-not-found error mentioning credentials.json, make sure the file is in the same folder where you are running the script (workspace-automation).
- If the browser flow fails or you accidentally denied permissions, delete token.json from the folder and run the script again to restart the OAuth flow.
- If you see a 403 error, confirm you signed in with the Workspace admin account (the super admin of your domain) during the OAuth flow.
Help me debug my quickstart.py errors
- Confirm that token.json was created in your project folder by running:
ls
You should now see credentials.json, quickstart.py, and token.json listed. The token file means future runs will not require the browser login again.
Your API connection is live and authenticated. Next up, you will configure your Workspace domain with organizational units and security policies that mirror a real company structure.
Build OU Structure, Enforce 2FA, and Create a User Manually
In Step 1 you connected Python to your Workspace domain. Now you need to configure the domain itself so your scripts have something meaningful to manage.
Every enterprise organizes users into departments with different security requirements. Before you automate that process, you need to experience it manually. In this step you will build the organizational structure by hand and feel the friction of creating even a single user through the console.
In this step, get ready to:
- Create an OU hierarchy that mirrors a typical company structure.
- Enforce 2-Step Verification at the OU level so all staff must enroll.
- Manually create a test user and place them in the correct OU.
Create the OU hierarchy in the Admin console
An Organizational Unit (OU) is a container that lets you apply settings to a group of users. You will create a tree that mirrors a typical company: a Staff branch with Engineering and Sales departments, plus a Departed branch for offboarded accounts.
- Return to the Admin console at admin.google.com.
- In the left sidebar, click **Menu**, then navigate to **Directory**, then **Organizational units**.
- Hover over your top-level organization name and click **Create new organizational unit**.
- In the **Name of organizational unit** field, type Staff.
- Click **Create**.
You now have a Staff OU directly under your top-level organization. Next, create its two child OUs.
- Hover over the **Staff** OU you just created and click **Create new organizational unit**.
- In the name field, type Engineering.
- Click **Create**.
- Hover over **Staff** again and click **Create new organizational unit**.
- In the name field, type Sales.
- Click **Create**.
Finally, create the Departed OU at the same level as Staff.
- Hover over your top-level organization name and click **Create new organizational unit**.
- In the name field, type Departed.
- Click **Create**.
You should now see four OUs in your tree: Staff (with Engineering and Sales nested inside it) and Departed at the top level.
What is an Organizational Unit?
An OU is like a folder for user accounts. Any security or service setting you apply to a parent OU automatically flows down to its children. That inheritance is why you will enforce 2FA on Staff and have it automatically apply to Engineering and Sales.
Don't see the Create option?
Make sure you are signed in as the super admin account you created during Workspace setup. Only accounts with the Organizational Units administrator privilege can create OUs.
help me troubleshoot why I can't create organizational units in the Admin console
Enforce 2-Step Verification for the Staff OU
With the OU tree in place, you can now apply a security policy that forces every user under Staff (including Engineering and Sales) to enroll in 2-Step Verification. This is how real IT teams harden their domain.
- In the Admin console, click **Menu** in the left sidebar.
- Navigate to **Security**, then **Authentication**, then **2-step verification**.
- On the left side panel, select the **Staff** organizational unit.
- Check the **Allow users to turn on 2-Step Verification** box.
- Under **Enforcement**, select **On**.
- Click **Override** to apply this setting specifically to the Staff OU (overriding the top-level inherited setting).
Because Engineering and Sales are children of Staff, they automatically inherit this enforcement. Any user placed in those OUs must enroll in 2FA.
Why enforce at the Staff OU and not the top level?
Enforcing at the Staff level means the Departed OU stays unaffected. Suspended accounts in Departed do not need 2FA enforcement. This mirrors how production environments segment security policies by department.
Don't see the Enforcement options?
You must be signed in as a super administrator to change 2-Step Verification settings. If you see the page but the controls are greyed out, confirm your account role under **Account**, then **Admin roles**.
help me troubleshoot why I can't change 2-Step Verification enforcement settings
Create a test user manually
Now experience what it takes to add just one user through the console. Pay attention to how many fields and clicks this requires. In Step 3 you will automate this for five users at once.
- In the Admin console, click **Menu**, then navigate to **Directory**, then **Users**.
- At the top of the user list, click **Add new user**.
- Enter a first name. Use Test.
- Enter a last name. Use Engineer.
- The primary email auto-fills. Accept the suggested email or adjust it.
- Click **Manage user's password, organizational unit, and profile photo**.
- Next to **Organizational unit**, click **Edit**.
- Select **Staff**, then **Engineering**.
- Click **Done**.
- Click **Add New User**.
The console shows a confirmation with the new user's temporary password. That was 10 clicks and field entries for a single user.
Why does this matter?
You just onboarded one person. Imagine repeating this for 50 new hires on the same day. Each user needs the correct OU, a group membership, and a temporary password. Manual provisioning does not scale. The script you build in Step 3 will do all of this in a single command.
Before you move on, confirm your test user landed in the right place.
- Navigate back to **Directory**, then **Users**.
- Click the **Staff** OU in the left panel, then **Engineering**.
- Confirm your test user (Test Engineer) appears in the list.
User not showing in Engineering?
It can take a few seconds for the user to appear. Refresh the page. If the user still appears in the top-level OU, you may have skipped the organizational unit selection during creation. Click the user's name, then under **User information** change their OU to /Staff/Engineering.
help me move a user to the correct organizational unit
Your domain now has a proper OU hierarchy, 2FA enforcement on all staff OUs, and a test user in place. Next up, you will replace all of that clicking with a Python script that provisions five users from a CSV in one shot.
Bulk Provision Users from CSV
In the previous step, you created a single test user by clicking through the Admin console. You counted every field and every click it took.
Now imagine repeating that process for 50 new hires on their first day. Manually creating users does not scale. A CSV-driven provisioning script is how IT teams actually onboard cohorts of new hires. In this step, you will automate what took multiple clicks per user into a single command.
In this step, get ready to:
- Create a CSV file defining 5 new hires with their OU and group assignments.
- Write a Python provisioning script that reads the CSV and creates users, groups, and memberships via the Admin SDK.
- Run the script and verify all 5 users appear in the correct OUs and groups.
Create the new hires CSV
The provisioning script needs structured data to work from. A CSV file acts as the source of truth for each new hire's name, email, OU placement, and group membership.
- Create a new file called new_hires.csv in your project directory with the following content:
first_name,last_name,email,ou_path,group_email
Alice,Johnson,alice.johnson@yourdomain.xyz,/Staff/Engineering,engineering@yourdomain.xyz
Bob,Martinez,bob.martinez@yourdomain.xyz,/Staff/Engineering,engineering@yourdomain.xyz
Carol,Williams,carol.williams@yourdomain.xyz,/Staff/Sales,sales@yourdomain.xyz
David,Brown,david.brown@yourdomain.xyz,/Staff/Sales,sales@yourdomain.xyz
Eve,Davis,eve.davis@yourdomain.xyz,/Staff/Engineering,engineering@yourdomain.xyz
What does this CSV define?
Each row represents one new hire. The columns map directly to Admin SDK fields:
- ou_path tells the script which OU to place the user in (matching the hierarchy you built in Step 2).
- group_email determines which Google Group the user joins for team-based email distribution.
- Replace every instance of yourdomain.xyz with your actual Workspace domain name (the one you purchased during setup).
- Save new_hires.csv.
- Confirm the file exists by running:
cat new_hires.csv
You should see the 5 rows of user data printed to your terminal, with your domain name in the email and group columns.
Seeing strange characters or no output?
- Make sure the file is saved as plain text (not rich text or a spreadsheet format).
- Confirm you are in the correct project directory where quickstart.py lives.
- help me debug my new_hires.csv file
Write the provisioning helper functions
The provisioning script needs two helper functions: one to create a Google Group if it does not already exist, and one to add a user as a member of a group. These helpers keep the main loop clean and handle API edge cases.
- Create a new file called provision.py and add the imports and the group-creation helper:
import csv
import time
from googleapiclient.errors import HttpError
from quickstart import get_service
def create_group_if_not_exists(service, group_email, group_name):
try:
service.groups().get(groupKey=group_email).execute()
except HttpError as e:
if e.resp.status == 404:
service.groups().insert(
body={"email": group_email, "name": group_name}
).execute()
print(f" Created group: {group_email}")
time.sleep(5)
else:
raise
What does this code do?
- from quickstart import get_service reuses the OAuth authentication you already built, so you do not duplicate auth logic.
- create_group_if_not_exists attempts to fetch the group first. If the API returns a 404, the group does not exist yet and gets created.
- time.sleep(5) adds a delay after group creation because Google's API needs time to propagate the new group before you can add members to it.
- Add the group membership helper below create_group_if_not_exists in provision.py:
def add_member_to_group(service, group_email, user_email):
try:
service.members().insert(
groupKey=group_email,
body={"email": user_email, "role": "MEMBER"},
).execute()
except HttpError as e:
if e.resp.status == 409:
pass # already a member
else:
raise
What does this code do?
- members().insert() adds a user to the specified group with the MEMBER role.
- The HTTP 409 catch handles the case where the user is already a group member. Instead of crashing, it silently moves on.
- Save provision.py.
- Verify the module syntax is correct by running:
python -c "import provision"
If you see no output and no error, the file parses correctly and the import from quickstart resolved successfully.
Seeing an ImportError or SyntaxError?
- If you see ModuleNotFoundError: No module named 'quickstart', confirm provision.py is saved in the same folder as quickstart.py.
- If you see a SyntaxError, double-check that your parentheses and brackets are balanced in the helper functions.
- help me fix this import or syntax error in provision.py
Write the main provisioning loop and run the script
The main function ties everything together. It reads each row from the CSV, creates the user account with the correct OU, ensures the group exists, and adds the user as a member. It also handles the case where a user already exists (HTTP 409) so the script is safe to re-run.
- Add the main() function below add_member_to_group in provision.py:
def main():
service = get_service()
results = []
with open("new_hires.csv", newline="") as f:
reader = csv.DictReader(f)
for row in reader:
email = row["email"]
status = "created"
try:
service.users().insert(
body={
"primaryEmail": email,
"name": {
"givenName": row["first_name"],
"familyName": row["last_name"],
},
"password": "Welcome2025!",
"changePasswordAtNextLogin": True,
"orgUnitPath": row["ou_path"],
}
).execute()
time.sleep(2)
group_email = row["group_email"]
group_name = group_email.split("@")[0].capitalize()
create_group_if_not_exists(service, group_email, group_name)
add_member_to_group(service, group_email, email)
except HttpError as e:
if e.resp.status == 409:
status = "skipped (exists)"
else:
status = f"error ({e.resp.status})"
results.append((email, row["ou_path"], row["group_email"], status))
What does this code do?
- service.users().insert() creates each user with their email, name, a temporary password, and the correct OU path.
- changePasswordAtNextLogin: True forces the new hire to set their own password on first sign-in.
- time.sleep(2) pauses between user creations because the API needs propagation time before subsequent operations on that user.
- The HTTP 409 catch means if a user already exists, the script marks them as "skipped" and continues. This makes the script idempotent.
- Add the summary output and entry point below the main() function (still inside provision.py):
print("\n--- Provisioning Summary ---")
print(f"{'Email':<35} {'OU':<25} {'Group':<30} {'Status'}")
print("-" * 100)
for email, ou, group, status in results:
print(f"{email:<35} {ou:<25} {group:<30} {status}")
if __name__ == "__main__":
main()
What does this code do?
- The print statements format a table showing each user's email, OU, group, and whether they were created, skipped, or errored. This gives you a clear summary after the script finishes.
- if __name__ == "__main__" ensures the script only runs when executed directly, not when imported by another file.
- Save provision.py.
Before you run the script, what do you expect to see for each of the 5 users? Do you think any will be skipped?
- Run the provisioning script:
python provision.py
You should see output showing each user being created, followed by a summary table with 5 rows. Each row shows the user's email, their OU path, their group, and the status "created".
What should I see?
The script takes about 30-40 seconds to complete because of the deliberate delays between API calls. You will see group creation messages the first time engineering@yourdomain.xyz and sales@yourdomain.xyz are encountered. The final summary table lists all 5 users with status "created".
- Open the Admin console at admin.google.com.
- Navigate to Directory then Users.
- Click the Staff OU in the left sidebar, then check Engineering and Sales to confirm users appear in the correct OUs.
- Navigate to Directory then Groups to confirm the engineering and sales groups were created with the correct members.
Seeing errors during provisioning?
- If you see HttpError 403, your OAuth token may not have the required scopes. Delete token.json and run the script again to re-authenticate with all scopes.
- If you see Entity already exists. for all users (status "skipped (exists)"), the script already ran successfully once. The 409 handling means this is safe.
- If group membership fails, the group may not have finished propagating. Wait 30 seconds and run the script again. Existing users will be skipped and group membership will retry.
- help me debug errors in my provision.py script
✔️ Awesome, I've got everything!
Great. Double check you have saved both new_hires.csv and provision.py.
ⓧ I'd like to double check the full code
Your provision.py should contain four sections in order: the imports with create_group_if_not_exists, then add_member_to_group, then main() with the CSV loop and summary print, and finally the if __name__ guard. Scroll up through this step and confirm each chunk is present in your file in that order.
Your new_hires.csv should have a header row followed by exactly 5 data rows, with your domain replacing yourdomain.xyz.
Five users created with a single command. Your provisioning script now handles new hires at scale. Next up, you will build the other half of the lifecycle: deprovisioning departed users safely.
Script Deprovisioning Workflow
You have a working provisioning script that creates users in bulk. But onboarding is only half of the user lifecycle.
Offboarding is where security incidents happen. A manual process means forgetting to revoke access, leaving sessions active, or losing track of departed accounts. In this step, you will automate deprovisioning so that a single command suspends an account, kills all active sessions, and relocates the user to a safe holding OU.
In this step, get ready to:
- Write a deprovisioning function that suspends a user, signs out all sessions, and moves them to the Departed OU.
- Add command-line argument handling and API error catching.
- Test the script against one of your provisioned users and verify the result in the Admin console.
Write the deprovisioning function
When an employee leaves, IT needs to do three things immediately: lock the account, kill any active browser or device sessions, and move the user out of a production OU. Your function will do all three with timestamped logging so you have an audit trail.
- Create a new file called deprovision.py in your project folder.
- Add the following imports, constant, and deprovisioning function:
import sys
from datetime import datetime, timezone
from googleapiclient.errors import HttpError
from quickstart import get_service
DEPARTED_OU = "/Departed"
def deprovision_user(service, email):
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
print(f"\n[{timestamp}] Starting deprovision for: {email}")
# Step 1: Suspend the user
service.users().update(
userKey=email, body={"suspended": True}
).execute()
print(f" [OK] Account suspended")
# Step 2: Sign out all sessions
service.users().signOut(userKey=email).execute()
print(f" [OK] Signed out of all sessions")
# Step 3: Move to Departed OU
service.users().update(
userKey=email, body={"orgUnitPath": DEPARTED_OU}
).execute()
print(f" [OK] Moved to {DEPARTED_OU}")
print(f"[{timestamp}] Deprovision complete for: {email}")
What does this code do?
The function performs three API calls in sequence, each targeting the same user:
- service.users().update() with {"suspended": True} locks the account so the user cannot sign in.
- service.users().signOut() terminates all active web and device sessions immediately, resetting sign-in cookies.
- A second update() call sets orgUnitPath to /Departed, physically relocating the user so they no longer inherit production policies.
- Each action prints a timestamped confirmation, giving you an audit trail you could pipe to a log file.
Add argument parsing and error handling
The function exists, but the script has no entry point yet. You need a main() block that reads the target email from the command line and catches API errors gracefully.
- Below the deprovision_user function in deprovision.py, add the following main() function and entry point:
def main():
if len(sys.argv) != 2:
print("Usage: python deprovision.py <user_email>")
sys.exit(1)
email = sys.argv[1]
service = get_service()
try:
deprovision_user(service, email)
except HttpError as e:
print(f" [ERROR] API error: {e.resp.status} - {e._get_reason()}")
sys.exit(1)
if __name__ == "__main__":
main()
What does this code do?
- sys.argv[1] reads the user's email from the command line so you can deprovision any user by passing their address as an argument.
- The try/except block catches HttpError from the API (like a 404 if the user does not exist) and prints a readable message instead of crashing with a traceback.
- Save deprovision.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved deprovision.py with both the deprovision_user function and the main() entry point.
ⓧ I'd like to double check the full code
Your deprovision.py should contain these two sections in order. Scroll up and confirm each is present:
- Imports (sys, datetime, HttpError, get_service), the DEPARTED_OU constant, and the deprovision_user function with suspend, sign out, and move logic.
- The main() function with argument check, try/except error handling, and the if __name__ == "__main__" entry point.
Seeing a syntax error when you save?
- Make sure the main() function is at the same indentation level as deprovision_user (top-level, no extra indentation).
- Check that the f-strings use curly braces {} around variables, not square brackets.
Help me debug a syntax error in my deprovision.py script.
Test the deprovisioning workflow
Time to run the script against one of the users you provisioned in the previous step. You will target alice.johnson (or any of your five provisioned users).
Before you run this, what do you expect to happen to Alice's account? Will she still be able to sign in afterwards?
- Run the deprovisioning script by running this command:
python deprovision.py alice.johnson@[[DOMAIN="yourdomain.xyz"]]
You should see output with three [OK] confirmations: account suspended, signed out of all sessions, and moved to /Departed. Each line is timestamped in UTC.
What should I see?
Your terminal output should look similar to this:
[2026-07-26 14:32:10 UTC] Starting deprovision for: alice.johnson@yourdomain.xyz
[OK] Account suspended
[OK] Signed out of all sessions
[OK] Moved to /Departed
[2026-07-26 14:32:10 UTC] Deprovision complete for: alice.johnson@yourdomain.xyz
- Now verify the result in the Admin console. Switch back to the Admin console at admin.google.com.
- Click Directory in the left sidebar.
- Click Users.
- In the left panel, click the Departed OU.
- Confirm that Alice Johnson appears here and her status shows as Suspended.
Seeing an API error instead of OK messages?
- A 404 error means the email address does not match any user in your domain. Double check the spelling and domain name match exactly what you used in new_hires.csv.
- A 403 error means your OAuth token does not have the required scope. Delete token.json and run the script again to re-authenticate with all scopes.
- If the user was never provisioned (you skipped Step 3), run python provision.py first to create the test users.
Help me troubleshoot my deprovision.py API error.
Why sign out AND suspend?
Suspending an account prevents new sign-ins, but it does not terminate existing sessions. A user who is already signed in on a device keeps access until their session token expires.
The signOut call immediately invalidates all active sessions and resets sign-in cookies across every device. Together, the two calls ensure zero continued access the moment you run the script.
Your deprovisioning workflow is locked in. With one command, you can safely offboard any user with a full audit trail. Next up, you will build an audit script that scans your entire domain for stale accounts and compliance violations.
Audit Stale Accounts and Generate Compliance Report
Your provisioning and deprovisioning scripts handle the two ends of the user lifecycle. But what about the silent middle?
Without automated auditing, stale accounts and policy violations accumulate without anyone noticing. An audit script gives you on-demand visibility into your domain's security posture and produces evidence for compliance reviews.
In this step, get ready to:
- Fetch all domain users with their security metadata using the Directory API.
- Apply audit rules that flag stale accounts, missing 2FA enrollment, and misplaced suspended users.
- Generate an HTML compliance report you can share with stakeholders.
Fetch all domain users with full metadata
The audit script needs every user's login history, 2FA status, suspension state, and OU placement. The Directory API provides all of this when you request the full projection.
- Create a new file called audit.py in your project directory with the following code:
from datetime import datetime, timezone, timedelta
from quickstart import get_service
STALE_THRESHOLD_DAYS = 30
def get_all_users(service):
users = []
request = service.users().list(
customer="my_customer", maxResults=100, orderBy="email", projection="full"
)
while request is not None:
response = request.execute()
users.extend(response.get("users", []))
request = service.users().list_next(request, response)
return users
What does this code do?
The projection="full" parameter tells the API to return extended user fields like lastLoginTime, isEnrolledIn2Sv, and isEnforcedIn2Sv. Without it, those security fields are omitted.
The list_next pagination loop ensures you fetch every user even if the domain has more than 100 accounts.
- Save audit.py.
- Confirm the file was created by running:
ls audit.py
You should see audit.py listed in the output.
Don't see audit.py?
Make sure you saved the file in the same directory as quickstart.py and provision.py. Run ls without arguments to see all files in your current directory and check you are in the right folder.
help me find where my audit.py file was saved
Write the audit rules function
The audit function iterates over every user and checks three conditions: stale login (inactive 30+ days), missing 2FA enrollment for users in an enforced OU, and suspended users who were never relocated to the Departed OU.
- In audit.py, add the first part of the audit_users function below get_all_users:
def audit_users(users):
findings = []
now = datetime.now(timezone.utc)
for user in users:
email = user.get("primaryEmail", "")
last_login = user.get("lastLoginTime", "")
enrolled_2sv = user.get("isEnrolledIn2Sv", False)
enforced_2sv = user.get("isEnforcedIn2Sv", False)
suspended = user.get("suspended", False)
ou_path = user.get("orgUnitPath", "/")
issues = []
# Check stale login
if last_login and last_login != "1970-01-01T00:00:00.000Z":
login_dt = datetime.fromisoformat(last_login.replace("Z", "+00:00"))
days_since = (now - login_dt).days
if days_since > STALE_THRESHOLD_DAYS:
issues.append(f"Stale ({days_since} days)")
elif not last_login or last_login == "1970-01-01T00:00:00.000Z":
issues.append("Never logged in")
What does this code do?
Each user's lastLoginTime is compared against the current UTC time. If the gap exceeds 30 days, the user is flagged as stale.
Google returns 1970-01-01T00:00:00.000Z for accounts that have never logged in. The script treats both empty and epoch values as "Never logged in."
- Directly below the stale-login check (still inside the for loop), add the remaining audit rules, the findings.append block, and a temporary main function so you can test:
# Check 2FA enrollment
if ou_path.startswith("/Staff") and not enrolled_2sv and not suspended:
issues.append("2FA Not Enrolled")
# Check misplaced suspended users
if suspended and not ou_path.startswith("/Departed"):
issues.append("Suspended but not in Departed OU")
findings.append({
"email": email,
"last_login": last_login or "Never",
"enrolled_2sv": enrolled_2sv,
"ou_path": ou_path,
"suspended": suspended,
"issues": issues,
})
return findings
def main():
service = get_service()
print("Fetching all users...")
users = get_all_users(service)
print(f"Found {len(users)} users. Running audit rules...")
findings = audit_users(users)
flagged = [f for f in findings if f["issues"]]
print(f"Flagged {len(flagged)} users with findings.")
if __name__ == "__main__":
main()
What do these rules check?
The 2FA rule flags any non-suspended user in /Staff (or its children) who has not enrolled in 2-Step Verification. Since you enforced 2FA on the Staff OU, these users are out of compliance.
The misplaced rule catches suspended users who still sit outside /Departed. This happens when someone suspends an account manually without relocating it.
- Save audit.py.
- Before you run this, how many of your provisioned users do you think will be flagged? Consider that they were created by a script and have never actually logged in.
- Run the audit by executing:
python audit.py
You should see output similar to:
Fetching all users...
Found 6 users. Running audit rules...
Flagged 5 users with findings.
Most provisioned users are flagged because they have never logged in and have not enrolled in 2FA. Your deprovisioned user is likely already in the Departed OU and suspended, so it passes the misplaced check.
Seeing an authentication error?
If you see a token refresh error, delete token.json and run the script again. The OAuth flow will re-authenticate you in the browser.
why is my audit.py getting an authentication or token error
Generate the HTML compliance report
Terminal output is useful for a quick check, but stakeholders expect a formatted report. You will add an HTML report generator that produces a shareable file with a summary dashboard and detailed user table.
- In audit.py, add the first part of generate_html_report between audit_users and main:
def generate_html_report(findings):
total = len(findings)
stale_count = sum(1 for f in findings if any("Stale" in i or "Never" in i for i in f["issues"]))
twofa_count = sum(1 for f in findings if any("2FA" in i for i in f["issues"]))
misplaced_count = sum(1 for f in findings if any("Misplaced" in i or "not in Departed" in i for i in f["issues"]))
flagged = [f for f in findings if f["issues"]]
rows = ""
for f in findings:
issue_text = ", ".join(f["issues"]) if f["issues"] else "None"
row_class = ' style="background-color: #fff3cd;"' if f["issues"] else ""
rows += f"""<tr{row_class}>
<td>{f['email']}</td>
<td>{f['last_login']}</td>
<td>{'Yes' if f['enrolled_2sv'] else 'No'}</td>
<td>{f['ou_path']}</td>
<td>{'Yes' if f['suspended'] else 'No'}</td>
<td>{issue_text}</td>
</tr>\n"""
What does this code do?
The counting section tallies how many users fall into each finding category. The row loop builds an HTML table row for each user, highlighting rows with findings using a yellow background.
- Immediately below the row-building loop (still inside generate_html_report), add the HTML template and return statement:
html = f"""<!DOCTYPE html>
<html>
<head><title>Workspace Audit Report</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
h1 {{ color: #1a73e8; }}
.summary {{ background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
th {{ background-color: #1a73e8; color: white; }}
</style></head>
<body>
<h1>Google Workspace Audit Report</h1>
<p>Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}</p>
<div class="summary">
<h2>Summary</h2>
<p>Total users: {total}</p>
<p>Stale accounts (30+ days inactive): {stale_count}</p>
<p>2FA not enrolled (in enforced OU): {twofa_count}</p>
<p>Misplaced suspended users: {misplaced_count}</p>
<p>Total findings: {len(flagged)}</p>
</div>
<h2>User Details</h2>
<table>
<tr><th>Email</th><th>Last Login</th><th>2FA Enrolled</th><th>OU</th><th>Suspended</th><th>Findings</th></tr>
{rows}
</table>
</body></html>"""
return html
Why plain f-strings instead of a template library?
For a single-page report, Python f-strings keep things simple with no extra dependency. The double curly braces ({{ and }}) escape literal braces in the CSS so Python does not try to interpolate them.
Now update main to call the report generator and write the HTML file.
- In audit.py, find this line inside main:
print(f"Flagged {len(flagged)} users with findings.")
- Add the following lines directly after it, so the end of main now looks like this:
print(f"Flagged {len(flagged)} users with findings.")
html = generate_html_report(findings)
with open("audit_report.html", "w") as f:
f.write(html)
print("Report saved to: audit_report.html")
- Save audit.py.
- Before you run this, what new output do you expect to see compared to the last run?
- Run the updated script:
python audit.py
You should now see the additional line:
Fetching all users...
Found 6 users. Running audit rules...
Flagged 5 users with findings.
Report saved to: audit_report.html
- Open audit_report.html in your browser.
You should see a styled report with a blue header, a summary section showing counts for stale accounts, 2FA violations, and misplaced users, and a detailed table listing every user with a yellow highlight on rows that have findings.
Report file not generated?
Check that the html = generate_html_report(findings) line is inside main (indented with 4 spaces). If it is at the module level, it will run before findings is defined and throw a NameError.
why isn't my audit_report.html file being generated
A note on lastLoginTime accuracy
Google's documentation warns that when retrieving all users in a domain, the value of lastLoginTime might be inaccurate. For a small trial domain this is fine. In production with hundreds of users, you would fetch each user individually for precise values.
✔️ Awesome, I've got everything!
Great. Double check you have saved audit.py and confirmed the HTML report opens correctly in your browser.
ⓧ I'd like to double check the full code
Your audit.py should contain these sections in order. Scroll up and confirm each one matches:
- Imports: datetime, timezone, timedelta from the standard library, plus get_service from quickstart.
- Constant: STALE_THRESHOLD_DAYS = 30.
- Function get_all_users(service): paginated user list call with projection="full".
- Function audit_users(users): loops over users, checks stale login, 2FA enrollment, and misplaced suspended users, appends findings dict.
- Function generate_html_report(findings): counts findings per category, builds HTML table rows, assembles the full HTML template, and returns the string.
- Function main(): calls get_all_users, audit_users, generate_html_report, writes audit_report.html, prints status messages.
- Entry point: if __name__ == "__main__": main().
Your audit toolkit is complete. You now have scripts that handle the entire user lifecycle: provisioning, deprovisioning, and compliance auditing. Next up, the Secret Mission takes your deprovisioning script further with token revocation and Slack notifications.
Secret mission
Token Revocation and Slack Webhook Notification
Your deprovision script suspends and relocates users, but production IT teams go further. They revoke every third-party app token and notify the team the instant someone is offboarded. In this secret mission, you will integrate token revocation and Slack webhook notifications into your deprovisioning workflow, turning a demo script into something deployable.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Your Google Workspace trial is free for 14 days, but it will convert to a paid subscription if not cancelled.
Cost warning
Your Google Workspace trial converts to a paid subscription after 14 days. If you do not plan to continue using Workspace, cancel before the trial ends to avoid charges.
Your domain purchase is a separate annual charge (~$1-12/year) that renews independently of your Workspace subscription.
Resources you used:
- Google Workspace Business Starter trial (14-day free trial, then per-user/month charges).
- Google Cloud project (free tier, no charges for Admin SDK API usage).
- Test users created in Workspace (count toward your 10-user trial limit).
- Local project files (quickstart.py, provision.py, deprovision.py, audit.py, new_hires.csv, credentials.json, token.json, audit_report.html).
- Slack workspace with Incoming Webhook (created during Secret Mission).
Keep everything running
No action needed. Choose this if you are still actively experimenting with your Workspace automation scripts.
- Set a calendar reminder to cancel your Workspace trial before day 14 if you do not want to be charged.
- Your domain will renew annually (~$1-12/year) regardless of your Workspace subscription status.
- Your Google Cloud project remains on the free tier with no charges for Admin SDK API usage.
- Your local project files, Python libraries, and Slack workspace remain on your machine and account.
Pause - I'll come back to this later
You cannot pause a Google Workspace trial. It either stays active or you cancel it. If you cancel, Workspace services stop but you keep your domain.
- Cancel the Workspace subscription before the 14-day trial ends to avoid charges. In the Admin console, navigate to Billing > Subscriptions > Cancel.
- Your domain stays active and renews annually. You can restart a Workspace trial or subscription later using the same domain.
- Your Google Cloud project can remain as-is on the free tier.
- Keep your local project files for when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to clean up everything.
- Delete all test users in the Admin console. Navigate to Directory > Users, select all test users, and click Delete.
- Delete your Google Cloud project. In the Google Cloud console, go to the Manage resources page, select your project, click Delete, enter the project ID, and click Shut down.
- Cancel the Workspace subscription. In the Admin console, navigate to Billing > Subscriptions > Cancel. You keep the domain (it renews annually) but Workspace services stop.
- Delete your Slack workspace. In the Slack desktop app, click Admin in the sidebar, select Workspace settings, scroll down to Delete Workspace, and confirm.
- Remove your local project files. On macOS/Linux, run the command below. On Windows, use rmdir /s /q on the same folder path.
- Uninstall the Python libraries by running the uninstall command below.
rm -rf ~/Desktop/workspace-automation
pip uninstall google-api-python-client google-auth-oauthlib google-auth-httplib2 -y
- Remove the SLACK_WEBHOOK_URL environment variable from your shell profile (.bashrc, .zshrc, or equivalent).
Nice Work!
Nice Work!
Nice work! You just built a production-grade user lifecycle automation toolkit for Google Workspace. From bulk onboarding to secure offboarding to compliance auditing, you now have the scripts that IT teams actually use to manage identities at scale.
You learned how to:
- Built a CSV-driven provisioning script that creates users, places them in the correct OUs, and assigns group membership with a single command.
- Automated user deprovisioning to suspend accounts, sign out all sessions, and relocate departed users to a dedicated OU, eliminating the security gaps of manual offboarding.
- Created a compliance audit script that flags stale accounts and 2FA violations, then generates a shareable HTML report for stakeholders.
- Secret Mission: Enhanced deprovisioning with third-party app token revocation and Slack webhook notifications for team visibility.
Ready to quiz yourself?