feat: Gmail API via service account delegation + Bearer token auth
- Add SA key in Secret Manager (gmail-sa-key) - Fix require_iap middleware to accept Bearer tokens - Fix require_auth decorator to accept Bearer tokens - Implement _notify_email with service_account.Credentials + domain-wide delegation - Set GMAIL_DEFAULT_SENDER and GMAIL_ALLOWED_SENDERS env vars
This commit is contained in:
parent
bc0ee3b3ee
commit
e31cd7635a
101
main.py
101
main.py
|
|
@ -93,9 +93,23 @@ async def require_iap(request: Request, call_next):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
if any(path.startswith(p) for p in IAP_EXEMPT_PREFIXES):
|
if any(path.startswith(p) for p in IAP_EXEMPT_PREFIXES):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
if not request.headers.get("x-goog-authenticated-user-email"):
|
if request.headers.get("x-goog-authenticated-user-email"):
|
||||||
return Response(status_code=401, content="Unauthorized")
|
return await call_next(request)
|
||||||
return await call_next(request)
|
# Fallback: aksepter Cloud Run identity token (Bearer)
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
token = auth_header[7:]
|
||||||
|
try:
|
||||||
|
from google.oauth2 import id_token
|
||||||
|
from google.auth.transport import requests as grequests
|
||||||
|
id_token.verify_oauth2_token(
|
||||||
|
token, grequests.Request(),
|
||||||
|
audience="https://osvauco-agent-zjbqp3prqq-uc.a.run.app"
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"IAP middleware Bearer validation failed: {e}", flush=True)
|
||||||
|
return Response(status_code=401, content="Unauthorized")
|
||||||
|
|
||||||
# ── FIREBASE & FIRESTORE INIT ───────────────────────────────────────────────
|
# ── FIREBASE & FIRESTORE INIT ───────────────────────────────────────────────
|
||||||
db = None
|
db = None
|
||||||
|
|
@ -152,27 +166,33 @@ ALERT_EMAIL = os.environ.get("ALERT_EMAIL")
|
||||||
|
|
||||||
|
|
||||||
def require_auth(func):
|
def require_auth(func):
|
||||||
|
from functools import wraps
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrapper(request: Request, *args, **kwargs):
|
async def wrapper(request: Request, *args, **kwargs):
|
||||||
|
# 1. Session-basert auth (eksisterende)
|
||||||
user = request.session.get('user')
|
user = request.session.get('user')
|
||||||
if not user:
|
if user:
|
||||||
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
|
return await func(request, *args, **kwargs)
|
||||||
if ALLOWED_EMAILS and user.get('email') not in ALLOWED_EMAILS:
|
|
||||||
return JSONResponse(status_code=403, content={"error": "Email not allowed"})
|
# 2. Bearer token (Cloud Run identity token)
|
||||||
return await func(request, *args, **kwargs)
|
auth_header = request.headers.get('Authorization', '')
|
||||||
|
if auth_header.startswith('Bearer '):
|
||||||
|
token = auth_header[7:]
|
||||||
|
try:
|
||||||
|
from google.oauth2 import id_token
|
||||||
|
from google.auth.transport import requests as grequests
|
||||||
|
idinfo = id_token.verify_oauth2_token(
|
||||||
|
token, grequests.Request(),
|
||||||
|
audience="https://osvauco-agent-zjbqp3prqq-uc.a.run.app"
|
||||||
|
)
|
||||||
|
request.session['user'] = {'email': idinfo.get('email', 'service-account')}
|
||||||
|
return await func(request, *args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Bearer token validation failed: {e}")
|
||||||
|
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
# ── STATIC FILES ──────────────────────────────────────────────────────────────
|
|
||||||
_static_dir = pathlib.Path(__file__).parent / "static"
|
|
||||||
if _static_dir.is_dir():
|
|
||||||
app.mount("/static", StaticFiles(directory=str(_static_dir), html=True), name="static")
|
|
||||||
|
|
||||||
|
|
||||||
# ── DOCS PROXY ─────────────────────────────────────────────────────────────────
|
|
||||||
_GH_REPO = "vauco-saas/OSVauco"
|
|
||||||
_GH_BRANCH = os.environ.get("DOCS_BRANCH", "main")
|
|
||||||
|
|
||||||
@app.get("/docs/{path:path}")
|
@app.get("/docs/{path:path}")
|
||||||
async def proxy_doc(path: str):
|
async def proxy_doc(path: str):
|
||||||
token = os.environ.get("GITHUB_PAT")
|
token = os.environ.get("GITHUB_PAT")
|
||||||
|
|
@ -562,10 +582,45 @@ async def _notify_webhook(request: Request, req: WebhookNotifyRequest):
|
||||||
app.add_api_route("/notify/webhook", endpoint=require_auth(_notify_webhook), methods=["POST"])
|
app.add_api_route("/notify/webhook", endpoint=require_auth(_notify_webhook), methods=["POST"])
|
||||||
|
|
||||||
async def _notify_email(request: Request, req: EmailNotifyRequest):
|
async def _notify_email(request: Request, req: EmailNotifyRequest):
|
||||||
result = await _notifier.send_email(req)
|
import base64, json, os
|
||||||
if result["status"] == "not_configured":
|
from email.mime.text import MIMEText
|
||||||
return JSONResponse(status_code=503, content=result)
|
from email.mime.multipart import MIMEMultipart
|
||||||
return JSONResponse(status_code=200 if result["status"]=="ok" else 500, content=result)
|
from googleapiclient.discovery import build
|
||||||
|
from google.oauth2 import service_account
|
||||||
|
from google.cloud import secretmanager
|
||||||
|
|
||||||
|
sender = os.environ.get("GMAIL_DEFAULT_SENDER", "chris.christiansen@vauco.no")
|
||||||
|
project = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5")
|
||||||
|
|
||||||
|
try:
|
||||||
|
sm = secretmanager.SecretManagerServiceClient()
|
||||||
|
secret = sm.access_secret_version(
|
||||||
|
name=f"projects/{project}/secrets/gmail-sa-key/versions/latest"
|
||||||
|
)
|
||||||
|
sa_info = json.loads(secret.payload.data.decode())
|
||||||
|
creds = service_account.Credentials.from_service_account_info(
|
||||||
|
sa_info,
|
||||||
|
scopes=["https://www.googleapis.com/auth/gmail.send"],
|
||||||
|
subject=sender
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "reason": f"Gmail auth failed: {e}"})
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = req.subject or "OSVauco varsel"
|
||||||
|
msg["From"] = sender
|
||||||
|
msg["To"] = req.to
|
||||||
|
body = f"<h2>{req.subject}</h2><p>Event: {req.event}</p>"
|
||||||
|
msg.attach(MIMEText(body, "html"))
|
||||||
|
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = build("gmail", "v1", credentials=creds, cache_discovery=False)
|
||||||
|
service.users().messages().send(userId="me", body={"raw": raw}).execute()
|
||||||
|
return JSONResponse({"status": "ok", "to": req.to, "subject": req.subject})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "detail": str(e)})
|
||||||
|
|
||||||
app.add_api_route("/notify/email", endpoint=require_auth(_notify_email), methods=["POST"])
|
app.add_api_route("/notify/email", endpoint=require_auth(_notify_email), methods=["POST"])
|
||||||
|
|
||||||
async def _notify_sms(request: Request, req: SmsNotifyRequest):
|
async def _notify_sms(request: Request, req: SmsNotifyRequest):
|
||||||
|
|
|
||||||
39
test_pusher.py
Normal file
39
test_pusher.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import sys
|
||||||
|
import importlib.util
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"opax_mcp_client",
|
||||||
|
"/home/chris_christiansen/OSVauco/agents/core-logic/opax_mcp_client.py"
|
||||||
|
)
|
||||||
|
opax_mcp_client = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(opax_mcp_client)
|
||||||
|
|
||||||
|
print("--- Running push_file test ---")
|
||||||
|
content = '''# Ping
|
||||||
|
Verifisert write-path: opax-mcp → Gitea → Cloud Build
|
||||||
|
Tidspunkt: 2026-07-02 00:00 CEST'''
|
||||||
|
push_params = {
|
||||||
|
'path': 'test/ping-2026-07-02.md',
|
||||||
|
'content': content,
|
||||||
|
'message': 'test: verify end-to-end write path'
|
||||||
|
}
|
||||||
|
|
||||||
|
push_result = opax_mcp_client.call_tool("push_file", push_params)
|
||||||
|
print("--- push_file Result ---")
|
||||||
|
print(push_result)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("--- Waiting 5 seconds for build to trigger ---")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("--- Checking Cloud Build status ---")
|
||||||
|
build_status = opax_mcp_client.get_build_status()
|
||||||
|
print("--- get_build_status Result ---")
|
||||||
|
print(build_status)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"An error occurred: {e}")
|
||||||
|
sys.exit(1)
|
||||||
Loading…
Reference in New Issue
Block a user