-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): Google OAuth with domain-based access control #226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
69db61d
cfe47c0
c1f35d3
df31cb6
4e8f935
883c855
2132108
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |||||||||||||||||||
|
|
||||||||||||||||||||
| _pool: asyncpg.Pool | None = None | ||||||||||||||||||||
| _pool_lock: asyncio.Lock | None = None | ||||||||||||||||||||
| _users_table_ensured: bool = False | ||||||||||||||||||||
| _DEFAULT_POOL_MIN_SIZE = 2 | ||||||||||||||||||||
| _DEFAULT_POOL_MAX_SIZE = 10 | ||||||||||||||||||||
| _DEFAULT_POOL_RETRIES = 5 | ||||||||||||||||||||
|
|
@@ -31,8 +32,28 @@ def _float_env(name: str, default: float, minimum: float = 0.0) -> float: | |||||||||||||||||||
| return default | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| async def ensure_users_table(pool: asyncpg.Pool) -> None: | ||||||||||||||||||||
| """Create the users table if it does not already exist (idempotent).""" | ||||||||||||||||||||
| async with pool.acquire() as conn: | ||||||||||||||||||||
| await conn.execute( | ||||||||||||||||||||
| """ | ||||||||||||||||||||
| CREATE TABLE IF NOT EXISTS users ( | ||||||||||||||||||||
| id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, | ||||||||||||||||||||
| email TEXT UNIQUE NOT NULL, | ||||||||||||||||||||
| name TEXT, | ||||||||||||||||||||
| image TEXT, | ||||||||||||||||||||
| approved BOOLEAN NOT NULL DEFAULT FALSE, | ||||||||||||||||||||
| domain TEXT NOT NULL, | ||||||||||||||||||||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||||||||||||||||||||
| last_login_at TIMESTAMPTZ NOT NULL DEFAULT now() | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); | ||||||||||||||||||||
| """ | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| async def get_pool() -> asyncpg.Pool: | ||||||||||||||||||||
| global _pool, _pool_lock | ||||||||||||||||||||
| global _pool, _pool_lock, _users_table_ensured | ||||||||||||||||||||
| if _pool_lock is None: | ||||||||||||||||||||
| _pool_lock = asyncio.Lock() | ||||||||||||||||||||
| if _pool is not None: | ||||||||||||||||||||
|
|
@@ -65,6 +86,16 @@ async def get_pool() -> asyncpg.Pool: | |||||||||||||||||||
| await asyncio.sleep(retry_delay * (attempt + 1)) | ||||||||||||||||||||
| if _pool is None and last_error is not None: | ||||||||||||||||||||
| raise last_error | ||||||||||||||||||||
| if _pool is not None and not _users_table_ensured: | ||||||||||||||||||||
| try: | ||||||||||||||||||||
| await ensure_users_table(_pool) | ||||||||||||||||||||
| _users_table_ensured = True | ||||||||||||||||||||
| except Exception: | ||||||||||||||||||||
| import logging | ||||||||||||||||||||
|
|
||||||||||||||||||||
| logging.getLogger(__name__).warning( | ||||||||||||||||||||
| "Could not create users table — DB may not be ready yet" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
Comment on lines
+93
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Swallowing the exception makes it difficult to diagnose why the table creation failed (e.g., permission issues or syntax errors). Using
Suggested change
Comment on lines
+89
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
여기서 예외를 삼키고 계속 진행하면, Line 59가 이후 호출에서 🔧 수정 방향 예시 async def get_pool() -> asyncpg.Pool:
global _pool, _pool_lock, _users_table_ensured
if _pool_lock is None:
_pool_lock = asyncio.Lock()
- if _pool is not None:
+ if _pool is not None and _users_table_ensured:
return _pool
async with _pool_lock:
- if _pool is not None:
+ if _pool is not None and _users_table_ensured:
return _pool
database_url = os.environ.get("DATABASE_URL", "")
if not database_url:
raise RuntimeError("DATABASE_URL environment variable is required")
@@
- if _pool is not None and not _users_table_ensured:
+ if _pool is not None and not _users_table_ensured:
try:
await ensure_users_table(_pool)
_users_table_ensured = True
- except Exception:
+ except Exception:
import logging
logging.getLogger(__name__).warning(
- "Could not create users table — DB may not be ready yet"
+ "Could not create users table — DB may not be ready yet",
+ exc_info=True,
)
return _pool🤖 Prompt for AI Agents |
||||||||||||||||||||
| return _pool | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1970,6 +1970,91 @@ async def zero_prompt_build_status(session_id: str, card_id: str): | |
| } | ||
|
|
||
|
|
||
| # ── Dashboard Auth ──────────────────────────────────────────────────── | ||
|
|
||
|
|
||
| class UpsertUserRequest(BaseModel): | ||
| email: str | ||
| name: str = "" | ||
| image: str = "" | ||
|
|
||
|
|
||
| class UpsertUserResponse(BaseModel): | ||
| id: str | ||
| email: str | ||
| name: str | ||
| approved: bool | ||
| domain: str | ||
|
Comment on lines
+1988
to
+1999
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OAuth 프로필의 nullable 필드와 API 계약이 맞지 않습니다. 웹 쪽에서는 🔧 수정 예시 class UpsertUserRequest(BaseModel):
email: str
- name: str = ""
- image: str = ""
+ name: str | None = None
+ image: str | None = None
@@
class UpsertUserResponse(BaseModel):
id: str
email: str
- name: str
+ name: str | None = None
approved: bool
domain: str🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| class CheckUserResponse(BaseModel): | ||
| approved: bool | ||
| domain: str | ||
| email: str | ||
|
|
||
|
|
||
| @app.post("/dashboard/auth/upsert-user") | ||
| async def dashboard_auth_upsert_user(body: UpsertUserRequest): | ||
| from .db.connection import get_pool | ||
|
|
||
| if not body.email or "@" not in body.email: | ||
| raise HTTPException(status_code=400, detail="invalid_email") | ||
|
|
||
| domain = body.email.rsplit("@", 1)[1].lower() | ||
| approved = domain == "2weeks.co" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| pool = await get_pool() | ||
| async with pool.acquire() as conn: | ||
| row = await conn.fetchrow( | ||
| """ | ||
| INSERT INTO users (email, name, image, approved, domain) | ||
| VALUES ($1, $2, $3, $4, $5) | ||
| ON CONFLICT (email) DO UPDATE | ||
| SET name = EXCLUDED.name, | ||
| image = EXCLUDED.image, | ||
| last_login_at = now() | ||
| RETURNING id, email, name, approved, domain | ||
| """, | ||
| body.email, | ||
| body.name, | ||
| body.image, | ||
| approved, | ||
| domain, | ||
| ) | ||
|
|
||
| return UpsertUserResponse( | ||
| id=row["id"], | ||
| email=row["email"], | ||
| name=row["name"], | ||
| approved=row["approved"], | ||
| domain=row["domain"], | ||
| ) | ||
|
|
||
|
|
||
| @app.get("/dashboard/auth/check-user") | ||
| async def dashboard_auth_check_user(email: str): | ||
| from .db.connection import get_pool | ||
|
|
||
| if not email or "@" not in email: | ||
| raise HTTPException(status_code=400, detail="invalid_email") | ||
|
|
||
| pool = await get_pool() | ||
| async with pool.acquire() as conn: | ||
| row = await conn.fetchrow( | ||
| "SELECT approved, domain, email FROM users WHERE email = $1", | ||
| email, | ||
| ) | ||
|
Comment on lines
+2020
to
+2058
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🤖 Prompt for AI Agents |
||
|
|
||
| if row is None: | ||
| raise HTTPException(status_code=404, detail="user_not_found") | ||
|
|
||
| return CheckUserResponse( | ||
| approved=row["approved"], | ||
| domain=row["domain"], | ||
| email=row["email"], | ||
| ) | ||
|
Comment on lines
+2008
to
+2067
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major 사용자 저장/조회 SQL은 라우트 밖으로 빼는 편이 좋겠습니다. 이번 변경은 As per coding guidelines 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| uvicorn.run( | ||
| "agent.server:app", | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import { handlers } from "@/lib/auth"; | ||
|
|
||
| export const { GET, POST } = handlers; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This index is redundant. The
emailcolumn is already defined with aUNIQUEconstraint on line 42, and PostgreSQL automatically creates a unique index for every unique constraint. Removing it will save storage and slightly improve write performance.