Skip to content

Commit b1b03b1

Browse files
authored
Merge pull request #17 from VectorInstitute/use_firestore_token
Update cli to use firestore token service
2 parents d8c8a4d + b2ac27b commit b1b03b1

File tree

3 files changed

+240
-29
lines changed

3 files changed

+240
-29
lines changed

services/token-service/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import jwt
1515
from firebase_admin import auth, credentials
1616
from flask import Flask, request
17-
from google.cloud import firestore # type: ignore[attr-defined]
17+
from google.cloud import firestore
1818

1919

2020
# Initialize Flask app

src/aieng_platform_onboard/cli.py

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@
1717
from rich.panel import Panel
1818

1919
from aieng_platform_onboard.utils import (
20+
check_onboarded_status,
2021
console,
2122
create_env_file,
22-
fetch_token_from_secret_manager,
23+
fetch_token_from_service,
2324
get_github_user,
2425
get_global_keys,
2526
get_participant_data,
2627
get_team_data,
2728
initialize_firestore_with_token,
2829
update_onboarded_status,
30+
validate_env_file,
2931
)
3032

3133

@@ -45,7 +47,7 @@ def run_integration_test(test_script: Path) -> tuple[bool, str]:
4547
"""
4648
try:
4749
result = subprocess.run(
48-
[sys.executable, str(test_script)],
50+
[sys.executable, "-m", "pytest", str(test_script)],
4951
check=False,
5052
capture_output=True,
5153
text=True,
@@ -63,7 +65,9 @@ def run_integration_test(test_script: Path) -> tuple[bool, str]:
6365

6466

6567
def _authenticate_and_connect(
66-
bootcamp_name: str, gcp_project: str, firebase_api_key: str | None
68+
bootcamp_name: str,
69+
gcp_project: str,
70+
firebase_api_key: str | None,
6771
) -> tuple[str, Any] | tuple[None, None]:
6872
"""
6973
Authenticate participant and connect to Firestore.
@@ -97,20 +101,17 @@ def _authenticate_and_connect(
97101

98102
# Step 2: Fetch authentication token
99103
console.print("[bold]Step 2: Fetch Authentication Token[/bold]")
100-
console.print("[cyan]Fetching token from Secret Manager...[/cyan]")
101-
102-
success, token, error = fetch_token_from_secret_manager(
103-
github_user, bootcamp_name, gcp_project
104-
)
104+
console.print("[cyan]Fetching fresh token from service...[/cyan]")
105+
success, token, error = fetch_token_from_service(github_user)
105106

106107
if not success or not token:
107108
console.print(
108109
f"[red]✗ Failed to fetch authentication token:[/red]\n"
109110
f" {error}\n\n"
110111
"[yellow]Possible reasons:[/yellow]\n"
111-
" • Tokens not yet generated by admin\n"
112-
" • Incorrect bootcamp name\n"
113-
" • Missing Secret Manager permissions\n\n"
112+
" • Token service not deployed or misconfigured\n"
113+
" • Missing permissions\n"
114+
" • Participant not found in Firestore\n\n"
114115
"[dim]Contact bootcamp admin for assistance[/dim]"
115116
)
116117
return None, None
@@ -323,7 +324,7 @@ def _run_tests_and_finalize(
323324
return True
324325

325326

326-
def main() -> int:
327+
def main() -> int: # noqa: PLR0911
327328
"""
328329
Onboard bootcamp participants with team-specific API keys.
329330
@@ -370,6 +371,11 @@ def main() -> int:
370371
type=str,
371372
help="Firebase Web API key for token exchange (or set FIREBASE_WEB_API_KEY env var)",
372373
)
374+
parser.add_argument(
375+
"--force",
376+
action="store_true",
377+
help="Force re-onboarding even if already onboarded",
378+
)
373379

374380
args = parser.parse_args()
375381

@@ -382,13 +388,60 @@ def main() -> int:
382388
)
383389
)
384390

391+
# Check if .env file already exists and is complete
392+
output_path = Path(args.output_dir) / ".env"
393+
if output_path.exists():
394+
console.print("\n[bold]Checking existing .env file...[/bold]")
395+
is_complete, missing = validate_env_file(output_path)
396+
397+
if is_complete:
398+
console.print(
399+
"[green]✓ .env file is already complete with all required keys[/green]\n"
400+
f"[dim]Location: {output_path}[/dim]\n"
401+
)
402+
console.print(
403+
Panel.fit(
404+
"[green bold]✓ ALREADY ONBOARDED[/green bold]\n\n"
405+
"Your environment is already set up. No action needed.\n\n"
406+
"[dim]To re-onboard, delete the .env file and run again.[/dim]",
407+
border_style="green",
408+
title="Success",
409+
)
410+
)
411+
return 0
412+
console.print(
413+
f"[yellow]⚠ .env file exists but is incomplete[/yellow]\n"
414+
f"[dim]Missing keys: {', '.join(missing)}[/dim]\n"
415+
"[cyan]Continuing with onboarding to regenerate .env file...[/cyan]\n"
416+
)
417+
385418
# Authenticate and connect
386419
github_user, db = _authenticate_and_connect(
387420
args.bootcamp_name, args.gcp_project, args.firebase_api_key
388421
)
389422
if not github_user or not db:
390423
return 1
391424

425+
# Check if already onboarded in Firestore
426+
console.print("[bold]Checking onboarded status...[/bold]")
427+
success_check, is_onboarded = check_onboarded_status(db, github_user)
428+
429+
skip_onboarding = (
430+
success_check and is_onboarded and not args.force and output_path.exists()
431+
)
432+
if skip_onboarding:
433+
console.print(
434+
"[green]✓ You are already marked as onboarded in Firestore[/green]\n"
435+
"[yellow]Use --force to re-onboard anyway[/yellow]\n"
436+
)
437+
return 0
438+
439+
if success_check and is_onboarded and not args.force:
440+
console.print(
441+
"[green]✓ You are already marked as onboarded in Firestore[/green]\n"
442+
"[cyan]But .env file is missing, continuing to create it...[/cyan]\n"
443+
)
444+
392445
# Fetch data
393446
participant_data, team_data, global_keys = _fetch_participant_and_team_data(
394447
db, github_user
@@ -397,9 +450,10 @@ def main() -> int:
397450
return 1
398451

399452
# Setup environment
400-
output_path = _setup_environment(args.output_dir, team_data, global_keys)
401-
if not output_path:
453+
env_output_path = _setup_environment(args.output_dir, team_data, global_keys)
454+
if not env_output_path:
402455
return 1
456+
output_path = env_output_path
403457

404458
# Run tests and finalize
405459
if not _run_tests_and_finalize(db, github_user, args.skip_test, args.test_script):

src/aieng_platform_onboard/utils.py

Lines changed: 171 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
"""Shared utilities for participant onboarding scripts."""
22

33
import os
4+
import subprocess
5+
import time
46
from datetime import datetime, timezone
57
from pathlib import Path
68
from typing import Any
79

10+
import google.auth
811
import requests
9-
from google.cloud import firestore, secretmanager # type: ignore[attr-defined]
12+
from google.auth import jwt as google_jwt
13+
from google.cloud import firestore
1014
from google.oauth2 import credentials as oauth2_credentials
1115
from rich.console import Console
1216

@@ -50,38 +54,111 @@ def get_github_user() -> str | None:
5054
return github_user
5155

5256

53-
def fetch_token_from_secret_manager(
54-
github_handle: str, bootcamp_name: str, project_id: str
57+
def fetch_token_from_service( # noqa: PLR0911
58+
github_handle: str, token_service_url: str | None = None
5559
) -> tuple[bool, str | None, str | None]:
5660
"""
57-
Fetch Firebase token from GCP Secret Manager.
61+
Fetch fresh Firebase token from Cloud Run token service.
62+
63+
This generates tokens on-demand using the workspace service account identity.
64+
Tokens are always fresh (< 1 hour old).
5865
5966
Parameters
6067
----------
6168
github_handle : str
6269
GitHub handle of the participant.
63-
bootcamp_name : str
64-
Name of the bootcamp.
65-
project_id : str
66-
GCP project ID.
70+
token_service_url : str | None, optional
71+
Token service URL. If not provided, reads from TOKEN_SERVICE_URL env var
72+
or .token-service-url file.
6773
6874
Returns
6975
-------
7076
tuple[bool, str | None, str | None]
7177
Tuple of (success, token_value, error_message).
7278
"""
7379
try:
74-
client = secretmanager.SecretManagerServiceClient()
75-
secret_name = f"{bootcamp_name}-token-{github_handle}"
76-
name = f"projects/{project_id}/secrets/{secret_name}/versions/latest"
80+
# Get token service URL
81+
if not token_service_url:
82+
token_service_url = os.environ.get("TOKEN_SERVICE_URL")
83+
84+
if not token_service_url:
85+
# Try reading from config file
86+
config_file = Path.home() / ".token-service-url"
87+
if config_file.exists():
88+
token_service_url = config_file.read_text().strip()
89+
90+
if not token_service_url:
91+
return (
92+
False,
93+
None,
94+
(
95+
"Token service URL not found. Set TOKEN_SERVICE_URL environment "
96+
"variable or ensure .token-service-url file exists."
97+
),
98+
)
99+
100+
# Get identity token for authentication
101+
# Try to get identity token
102+
# In workspaces, this will use the compute service account
103+
credentials, project_id = google.auth.default() # type: ignore[no-untyped-call]
104+
105+
# Request an identity token instead of an access token
106+
# This is required for Cloud Run authentication
107+
if hasattr(credentials, "signer"):
108+
# Service account - can create identity tokens
109+
now = int(time.time())
110+
payload = {
111+
"iss": credentials.service_account_email,
112+
"sub": credentials.service_account_email,
113+
"aud": token_service_url,
114+
"iat": now,
115+
"exp": now + 3600,
116+
}
117+
id_token = google_jwt.encode(credentials.signer, payload) # type: ignore[no-untyped-call]
118+
else:
119+
# User credentials or other - use gcloud
120+
try:
121+
result = subprocess.run(
122+
["gcloud", "auth", "print-identity-token"],
123+
check=False,
124+
capture_output=True,
125+
text=True,
126+
timeout=10,
127+
)
128+
if result.returncode != 0:
129+
return False, None, f"Failed to get identity token: {result.stderr}"
130+
id_token = result.stdout.strip()
131+
except Exception as e:
132+
return False, None, f"Failed to run gcloud: {str(e)}"
133+
134+
if not id_token:
135+
return False, None, "Failed to get identity token for authentication"
136+
137+
# Call token service
138+
url = f"{token_service_url.rstrip('/')}/generate-token"
139+
headers = {
140+
"Authorization": f"Bearer {id_token}",
141+
"Content-Type": "application/json",
142+
}
143+
payload = {"github_handle": github_handle}
144+
145+
response = requests.post(url, json=payload, headers=headers, timeout=30)
146+
147+
if response.status_code != 200:
148+
error_data = response.json() if response.content else {}
149+
error_msg = error_data.get("message", f"HTTP {response.status_code}")
150+
return False, None, f"Token service error: {error_msg}"
151+
152+
data = response.json()
153+
token = data.get("token")
77154

78-
response = client.access_secret_version(request={"name": name})
79-
token = response.payload.data.decode("UTF-8")
155+
if not token:
156+
return False, None, "No token in service response"
80157

81158
return True, token, None
82159

83160
except Exception as e:
84-
return False, None, str(e)
161+
return False, None, f"Failed to fetch token from service: {str(e)}"
85162

86163

87164
def exchange_custom_token_for_id_token(
@@ -359,6 +436,86 @@ def create_env_file(
359436
return False
360437

361438

439+
def check_onboarded_status(
440+
db: firestore.Client, github_handle: str
441+
) -> tuple[bool, bool]:
442+
"""
443+
Check if participant is already onboarded.
444+
445+
Parameters
446+
----------
447+
db : firestore.Client
448+
Firestore client instance.
449+
github_handle : str
450+
GitHub handle of the participant.
451+
452+
Returns
453+
-------
454+
tuple[bool, bool]
455+
Tuple of (success, is_onboarded).
456+
"""
457+
try:
458+
doc_ref = db.collection("participants").document(github_handle)
459+
doc = doc_ref.get()
460+
461+
if not doc.exists:
462+
return True, False
463+
464+
data = doc.to_dict()
465+
is_onboarded = data.get("onboarded", False) if data else False
466+
return True, is_onboarded
467+
468+
except Exception as e:
469+
console.print(f"[yellow]⚠ Failed to check onboarded status:[/yellow] {e}")
470+
return False, False
471+
472+
473+
def validate_env_file(env_path: Path) -> tuple[bool, list[str]]:
474+
"""
475+
Validate if .env file exists and contains all required keys.
476+
477+
Parameters
478+
----------
479+
env_path : Path
480+
Path to the .env file.
481+
482+
Returns
483+
-------
484+
tuple[bool, list[str]]
485+
Tuple of (is_complete, missing_keys).
486+
"""
487+
if not env_path.exists():
488+
return False, ["File does not exist"]
489+
490+
required_keys = [
491+
"OPENAI_API_KEY",
492+
"EMBEDDING_BASE_URL",
493+
"EMBEDDING_API_KEY",
494+
"LANGFUSE_SECRET_KEY",
495+
"LANGFUSE_PUBLIC_KEY",
496+
"LANGFUSE_HOST",
497+
"WEAVIATE_HTTP_HOST",
498+
"WEAVIATE_GRPC_HOST",
499+
"WEAVIATE_API_KEY",
500+
]
501+
502+
try:
503+
with open(env_path) as f:
504+
content = f.read()
505+
506+
missing_keys = []
507+
for key in required_keys:
508+
# Check if key exists and has a non-empty value
509+
if f'{key}=""' in content or key not in content:
510+
missing_keys.append(key)
511+
512+
return len(missing_keys) == 0, missing_keys
513+
514+
except Exception as e:
515+
console.print(f"[yellow]⚠ Failed to validate .env file:[/yellow] {e}")
516+
return False, [str(e)]
517+
518+
362519
def update_onboarded_status(
363520
db: firestore.Client, github_handle: str
364521
) -> tuple[bool, str | None]:

0 commit comments

Comments
 (0)