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:
@@ -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)
|
||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user