Split databases and Redis — prepare infrastructure for per-domain isolation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m20s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m20s
Redis: per-app DB index (0-5) with shared auth DB 15 for SSO keys; flushdb replaces flushall so deploys don't wipe cross-app auth state. Postgres: drop 13 cross-domain FK constraints (migration v2t0p8q9r0), remove dead ORM relationships, add explicit joins for 4 live ones. Multi-engine sessions (account + federation) ready for per-domain DBs via DATABASE_URL_ACCOUNT / DATABASE_URL_FEDERATION env vars. All URLs initially point to the same appdb — zero behaviour change until split-databases.sh is run to migrate data to per-domain DBs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
37
shared/infrastructure/auth_redis.py
Normal file
37
shared/infrastructure/auth_redis.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Shared auth Redis connection (DB 15).
|
||||
|
||||
All cross-app auth keys live here so that per-app FLUSHDB on deploy
|
||||
doesn't wipe SSO state:
|
||||
- did_auth:{device_id} — login signal timestamp
|
||||
- grant:{grant_token} — grant validity cache (ok/revoked)
|
||||
- prompt:{app}:{device_id} — prompt=none cooldown
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from redis import asyncio as aioredis
|
||||
|
||||
_AUTH_REDIS_URL = os.getenv("REDIS_AUTH_URL", "redis://redis:6379/15")
|
||||
|
||||
_auth_redis: aioredis.Redis | None = None
|
||||
|
||||
|
||||
async def get_auth_redis() -> aioredis.Redis:
|
||||
"""Return the shared auth Redis connection (lazy init)."""
|
||||
global _auth_redis
|
||||
if _auth_redis is None:
|
||||
_auth_redis = aioredis.Redis.from_url(
|
||||
_AUTH_REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=False,
|
||||
)
|
||||
return _auth_redis
|
||||
|
||||
|
||||
async def close_auth_redis() -> None:
|
||||
"""Close the auth Redis connection (call on app shutdown)."""
|
||||
global _auth_redis
|
||||
if _auth_redis is not None:
|
||||
await _auth_redis.close()
|
||||
_auth_redis = None
|
||||
@@ -162,17 +162,20 @@ def create_base_app(
|
||||
uid = qs.get("uid")
|
||||
grant_token = qs.get("grant_token")
|
||||
|
||||
from shared.browser.app.redis_cacher import get_redis
|
||||
redis = get_redis()
|
||||
from shared.infrastructure.auth_redis import get_auth_redis
|
||||
try:
|
||||
auth_redis = await get_auth_redis()
|
||||
except Exception:
|
||||
auth_redis = None
|
||||
|
||||
# Case 1: logged in — verify grant still valid (direct DB, cached)
|
||||
if uid and grant_token:
|
||||
cache_key = f"grant:{grant_token}"
|
||||
if redis:
|
||||
if auth_redis:
|
||||
# Quick check: if did_auth was cleared (logout), skip cache
|
||||
device_id = g.device_id
|
||||
did_auth_present = await redis.get(f"did_auth:{device_id}") if device_id else True
|
||||
cached = await redis.get(cache_key)
|
||||
did_auth_present = await auth_redis.get(f"did_auth:{device_id}") if device_id else True
|
||||
cached = await auth_redis.get(cache_key)
|
||||
if cached == b"ok" and did_auth_present:
|
||||
return
|
||||
if cached == b"revoked":
|
||||
@@ -183,10 +186,10 @@ def create_base_app(
|
||||
return
|
||||
|
||||
from sqlalchemy import select
|
||||
from shared.db.session import get_session
|
||||
from shared.db.session import get_account_session
|
||||
from shared.models.oauth_grant import OAuthGrant
|
||||
try:
|
||||
async with get_session() as s:
|
||||
async with get_account_session() as s:
|
||||
grant = await s.scalar(
|
||||
select(OAuthGrant).where(OAuthGrant.token == grant_token)
|
||||
)
|
||||
@@ -194,8 +197,8 @@ def create_base_app(
|
||||
except Exception:
|
||||
return # DB error — don't log user out
|
||||
|
||||
if redis:
|
||||
await redis.set(cache_key, b"ok" if valid else b"revoked", ex=60)
|
||||
if auth_redis:
|
||||
await auth_redis.set(cache_key, b"ok" if valid else b"revoked", ex=60)
|
||||
if not valid:
|
||||
qs.pop("uid", None)
|
||||
qs.pop("grant_token", None)
|
||||
@@ -214,8 +217,8 @@ def create_base_app(
|
||||
|
||||
# Check if account signalled a login after we cached "not logged in"
|
||||
# (blog_did == account_did — same value set during OAuth callback)
|
||||
if device_id and redis and pnone_at:
|
||||
auth_ts = await redis.get(f"did_auth:{device_id}")
|
||||
if device_id and auth_redis and pnone_at:
|
||||
auth_ts = await auth_redis.get(f"did_auth:{device_id}")
|
||||
if auth_ts:
|
||||
try:
|
||||
if float(auth_ts) > pnone_at:
|
||||
@@ -226,8 +229,8 @@ def create_base_app(
|
||||
|
||||
if pnone_at and (now - pnone_at) < 300:
|
||||
return
|
||||
if device_id and redis:
|
||||
cached = await redis.get(f"prompt:{name}:{device_id}")
|
||||
if device_id and auth_redis:
|
||||
cached = await auth_redis.get(f"prompt:{name}:{device_id}")
|
||||
if cached == b"none":
|
||||
return
|
||||
return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}")
|
||||
@@ -279,6 +282,8 @@ def create_base_app(
|
||||
@app.after_serving
|
||||
async def _stop_event_processor():
|
||||
await _event_processor.stop()
|
||||
from shared.infrastructure.auth_redis import close_auth_redis
|
||||
await close_auth_redis()
|
||||
|
||||
# --- favicon ---
|
||||
@app.get("/favicon.ico")
|
||||
|
||||
@@ -78,12 +78,14 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
|
||||
qsession["_pnone_at"] = _time.time()
|
||||
device_id = g.device_id
|
||||
if device_id:
|
||||
from shared.browser.app.redis_cacher import get_redis
|
||||
_redis = get_redis()
|
||||
if _redis:
|
||||
await _redis.set(
|
||||
from shared.infrastructure.auth_redis import get_auth_redis
|
||||
try:
|
||||
_auth_r = await get_auth_redis()
|
||||
await _auth_r.set(
|
||||
f"prompt:{app_name}:{device_id}", b"none", ex=300
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return redirect(next_url)
|
||||
|
||||
code = request.args.get("code")
|
||||
|
||||
@@ -31,5 +31,17 @@ async def load_current_user():
|
||||
g.rights = {"admin": False}
|
||||
return
|
||||
|
||||
g.user = await load_user_by_id(g.s, uid)
|
||||
# User table lives in the account DB — use account session when
|
||||
# the per-request session (g.s) targets a different database.
|
||||
from shared.db.session import DATABASE_URL, DATABASE_URL_ACCOUNT
|
||||
if DATABASE_URL_ACCOUNT != DATABASE_URL:
|
||||
from shared.db.session import get_account_session
|
||||
async with get_account_session() as s:
|
||||
g.user = await load_user_by_id(s, uid)
|
||||
# Expunge so the object is usable outside this session
|
||||
if g.user:
|
||||
s.expunge(g.user)
|
||||
else:
|
||||
g.user = await load_user_by_id(g.s, uid)
|
||||
|
||||
g.rights = {l.name: True for l in g.user.labels} if g.user else {}
|
||||
|
||||
Reference in New Issue
Block a user