feat(provisioning): add Gmail OAuth/App Password guide and helper scripts

Add README.md with decision matrix, step-by-step provisioning flows,
security and compliance checklist, and rollback plan.

Add helper scripts:
- oauth_token_exchange.py: obtains and outputs a refresh token
- gmail_health_check.py: validates API or IMAP connectivity

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DevOps Engineer
2026-04-29 21:23:34 +00:00
commit 08bb0d51b2
5 changed files with 296 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
# Gmail OAuth / App Password Provisioning Guide
## Scope
This guide documents how the CTO or Chairman provisions OAuth 2.0 or App Password access to the `jonathan@cloudinit.dev` Google Workspace Gmail account, so that the MAILROOM_AUTOMATION ingestion pipeline can fetch mail in a secure, auditable way.
## Decision Matrix
| Path | When to use | Scope | Rotation |
|------|-------------|-------|----------|
| OAuth 2.0 (recommended) | Production, long-lived automation | `gmail.readonly` only | Refresh token rotation on suspicion or schedule |
| App Password | Fallback / legacy IMAP systems | Full mailbox via IMAP | Revoke the App Password and generate a new one |
## OAuth 2.0 Flow (Recommended)
### 1. Register a Google Cloud project (or reuse an existing one)
- Console: https://console.cloud.google.com/
- Ensure the project belongs to the same Workspace org as `jonathan@cloudinit.dev`.
### 2. Enable the Gmail API
- APIs & Services → Library → Gmail API → Enable.
### 3. Configure the OAuth consent screen
- APIs & Services → OAuth consent screen → Internal (since this is a Workspace-hosted account).
- Add the following scope:
- `https://www.googleapis.com/auth/gmail.readonly` (least privilege).
### 4. Create OAuth 2.0 credentials
- APIs & Services → Credentials → Create Credentials → OAuth client ID.
- Application type: **Desktop app** (or “Other non-UI” if offered).
- Download the JSON client secrets file and place it in a secrets manager (do NOT commit it).
### 5. Obtain a refresh token
- Run the helper at `scripts/oauth_token_exchange.py` to start the OAuth consent flow.
- Authenticate as `jonathan@cloudinit.dev`.
- Authorize the `gmail.readonly` scope.
- The script outputs a `refresh_token`; store it as a runtime secret (e.g., Gitea secret / vault).
### 6. Token hygiene
- Rotate the refresh token quarterly or on any security alert.
- To revoke, delete the token in the Google Account security settings.
### 7. IMAP enablement
- The Gmail API path does **not** require IMAP to be enabled.
- If you also need legacy IMAP (for testing or a dual path), keep the App Password section in mind as a fallback.
## App Password Flow (Fallback / IMAP)
### 1. Enable 2-Step Verification
- https://myaccount.google.com/security → 2-Step Verification → On.
### 2. Generate an App Password
- https://myaccount.google.com/apppasswords
- Select app: **Mail** (or choose “Other” and name it `mailroom-automation-imap`).
- Copy the 16-character password and store it as a secret immediately. It will not be shown again.
### 3. IMAP configuration
- Gmail IMAP host: `imap.gmail.com`
- Port: `993`
- TLS/SSL: Required
- Username: `jonathan@cloudinit.dev`
- Password: the 16-character App Password
### 4. Validate connectivity
- Use `scripts/gmail_health_check.py --mode imap` to verify the connection without touching the actual ingestion pipeline.
### 5. Least privilege for IMAP
- App Passwords grant full mailbox IMAP access. Use this path **only** if the OAuth 2.0 Gmail API path is infeasible.
- Restrict by filtering folders in the mail fetcher rules after connection.
## Security & Compliance
- **Secrets**: Never commit `client_secret.json`, refresh tokens, or App Passwords in plain text.
- **Rotation plan**: Quarterly rotation for OAuth refresh tokens; on-demand for App Passwords.
- **Rollback**: Remove/revoke the OAuth client in Google Cloud Credentials or delete the App Password in Google Account settings. No code redeploy needed because credentials are injected at runtime.
- **Audit**: Log every token exchange attempt and every health check run (see script exit codes).
- **Access model**: Automation credentials should be tied to a service account or a delegated admin account, not a personal account, where possible. Discuss with Cybersecurity-Board-Member if a dedicated service account is preferred.
## Helper Scripts
### `scripts/oauth_token_exchange.py`
Performs the OAuth consent flow for a Desktop app client ID and saves the refresh token.
Usage:
```bash
export GMAIL_CLIENT_SECRET_PATH=/run/secrets/gmail_client_secret.json
python scripts/oauth_token_exchange.py
```
Exit codes:
- `0` — success, token written to stdout and optionally a file
- `1` — missing client secret
- `2` — network or Google API error
### `scripts/gmail_health_check.py`
Validates connectivity via Gmail API (OAuth) or IMAP (App Password).
Usage:
```bash
# OAuth mode
export GMAIL_REFRESH_TOKEN="1//..."
export GMAIL_CLIENT_SECRET_PATH=/run/secrets/gmail_client_secret.json
python scripts/gmail_health_check.py --mode oauth
# IMAP mode
export GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
python scripts/gmail_health_check.py --mode imap --user jonathan@cloudinit.dev
```
Exit codes:
- `0` — connectivity verified, mailbox reachable
- `1` — credential or config missing/unreadable
- `2` — network or authentication failure
- `3` — mailbox accessible but unexpected folder structure (flag for review)
## Rollback Plan
| What changed | How to revert | Time to revert |
|--------------|---------------|----------------|
| OAuth client ID created | Delete client ID in Google Cloud Credentials | Immediate |
| Refresh token issued | Revoke token in Google Account → Third-party apps | Immediate |
| App Password created | Delete App Password in Google Account settings | Immediate |
| Gitea secret updated | Overwrite secret in Gitea with dummy value or delete | Immediate |
## Next Steps
1. Cybersecurity-Board-Member reviews this guide and confirms the chosen credential path.
2. The approved credential is stored in Gitea secrets or an external vault.
3. Ingestion pipeline references the secret via environment variable.
4. Onboarding test: run `gmail_health_check.py` in the target environment before enabling the full fetcher loop.
## Revision History
- `v1` — Initial guide (OAuth + App Password, helper scripts, security checklist)
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
Connectivity health check for Gmail via OAuth (Gmail API) or IMAP (App Password).
Usage:
# OAuth mode
export GMAIL_REFRESH_TOKEN="1//..."
export GMAIL_CLIENT_SECRET_PATH=/path/to/client_secret.json
python3 gmail_health_check.py --mode oauth
# IMAP mode
export GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
python3 gmail_health_check.py --mode imap --user jonathan@cloudinit.dev
Exit codes:
0 — mailbox reachable
1 — missing/unreadable credentials or bad args
2 — network/authentication error
3 — mailbox reachable but unexpected structure (review needed)
"""
import argparse
import os
import sys
def check_oauth():
refresh_token = os.getenv("GMAIL_REFRESH_TOKEN")
secret_path = os.getenv("GMAIL_CLIENT_SECRET_PATH")
if not refresh_token or not secret_path:
print("Error: GMAIL_REFRESH_TOKEN and GMAIL_CLIENT_SECRET_PATH must be set.", file=sys.stderr)
sys.exit(1)
try:
import google.auth
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
except ImportError as e:
print(f"Error: google-api-python-client and google-auth are required ({e}).", file=sys.stderr)
sys.exit(1)
try:
import json
with open(secret_path, "r") as f:
client_config = json.load(f)["installed"]
creds = Credentials(
None,
refresh_token=refresh_token,
token_uri=client_config["token_uri"],
client_id=client_config["client_id"],
client_secret=client_config["client_secret"],
scopes=["https://www.googleapis.com/auth/gmail.readonly"],
)
creds.refresh(Request())
service = build("gmail", "v1", credentials=creds, cache_discovery=False)
profile = service.users().getProfile(userId="me").execute()
labels = service.users().labels().list(userId="me").execute()
except Exception as e:
print(f"Error: Authentication or API call failed ({e}).", file=sys.stderr)
sys.exit(2)
expected_labels = {"INBOX", "SENT"}
found = {lbl["id"] for lbl in labels.get("labels", [])}
missing = expected_labels - found
if missing:
print(f"Warning: Expected labels missing: {missing}", file=sys.stderr)
print(f"Profile: {profile}")
sys.exit(3)
print(f"OK — OAuth connectivity verified. Profile: {profile.get('emailAddress')}")
sys.exit(0)
def check_imap(user: str):
password = os.getenv("GMAIL_APP_PASSWORD")
if not password:
print("Error: GMAIL_APP_PASSWORD must be set for IMAP mode.", file=sys.stderr)
sys.exit(1)
try:
import imaplib
except ImportError:
print("Error: imaplib is part of the standard library; if this fails your Python build is incomplete.", file=sys.stderr)
sys.exit(1)
try:
mail = imaplib.IMAP4_SSL("imap.gmail.com", 993)
mail.login(user, password)
status, _ = mail.select("INBOX")
if status != "OK":
print("Warning: INBOX not selectable. Review folder structure.", file=sys.stderr)
mail.logout()
sys.exit(3)
mail.logout()
except Exception as e:
print(f"Error: IMAP connection or authentication failed ({e}).", file=sys.stderr)
sys.exit(2)
print("OK — IMAP connectivity verified.")
sys.exit(0)
def main():
parser = argparse.ArgumentParser(description="Gmail connectivity health check")
parser.add_argument("--mode", required=True, choices=["oauth", "imap"], help="Which path to test")
parser.add_argument("--user", default="jonathan@cloudinit.dev", help="Gmail address for IMAP mode")
args = parser.parse_args()
if args.mode == "oauth":
check_oauth()
elif args.mode == "imap":
check_imap(args.user)
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""
Exchange an authorization code for a refresh token for Gmail readonly scope.
Requires: pip install google-auth-oauthlib
Usage:
export GMAIL_CLIENT_SECRET_PATH=/path/to/client_secret.json
python3 oauth_token_exchange.py
"""
import os
import sys
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
def main():
secret_path = os.getenv("GMAIL_CLIENT_SECRET_PATH")
if not secret_path:
print("Error: Set GMAIL_CLIENT_SECRET_PATH to the Google client_secret JSON file.", file=sys.stderr)
sys.exit(1)
try:
from google_auth_oauthlib.flow import InstalledAppFlow
except ImportError as e:
print(f"Error: google-auth-oauthlib is required. Install with: pip install google-auth-oauthlib ({e})", file=sys.stderr)
sys.exit(1)
try:
flow = InstalledAppFlow.from_client_secrets_file(secret_path, SCOPES)
credentials = flow.run_local_server(port=0)
except Exception as e:
print(f"Error during OAuth flow: {e}", file=sys.stderr)
sys.exit(2)
refresh_token = credentials.refresh_token
if not refresh_token:
print("Warning: No refresh token returned. You may need to revoke previous access and retry.", file=sys.stderr)
sys.exit(2)
print("\n=== REFRESH TOKEN ===")
print(refresh_token)
print("=====================\n")
print("Store this token securely (e.g., in a Gitea secret or vault). Do not commit it.")
sys.exit(0)
if __name__ == "__main__":
main()