diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index bae3f935..79770ed9 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -24,10 +24,14 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") async def get_current_user( - db: Annotated[AsyncSession, Depends(get_db)], + db: Annotated[AsyncSession, Depends(get_admin_db)], token: Annotated[str, Depends(oauth2_scheme)] ) -> User: - """Get current authenticated user from JWT token.""" + """Get current authenticated user from JWT token. + + Must use get_admin_db (BYPASSRLS): this dep runs before require_tenant_context + sets app.current_account_id, so the users table RLS would block the lookup. + """ credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -77,10 +81,14 @@ async def get_refresh_token_payload( async def get_current_active_user( request: Request, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[AsyncSession, Depends(get_db)], + db: Annotated[AsyncSession, Depends(get_admin_db)], ) -> User: """Ensure user is active (not disabled). Auto-downgrades expired trials. - Enforces must_change_password — blocks all routes except allowlist.""" + Enforces must_change_password — blocks all routes except allowlist. + + Uses get_admin_db: runs before require_tenant_context sets the ContextVar, + so tenant-scoped tables (subscriptions) would return 0 rows via app role. + """ if not current_user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/backend/app/api/endpoints/accounts.py b/backend/app/api/endpoints/accounts.py index fd49ec48..66148952 100644 --- a/backend/app/api/endpoints/accounts.py +++ b/backend/app/api/endpoints/accounts.py @@ -9,6 +9,7 @@ from sqlalchemy import select from pydantic import BaseModel from app.core.database import get_db +from app.core.admin_database import get_admin_db from app.core.subscriptions import get_account_subscription, get_plan_limits, get_account_usage from app.core.audit import log_audit from app.models.refresh_token import RefreshToken @@ -148,7 +149,7 @@ async def update_member_role( @router.post("/me/transfer-ownership", response_model=AccountResponse) async def transfer_ownership( data: TransferOwnershipRequest, - db: Annotated[AsyncSession, Depends(get_db)], + db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_account_owner)] ): """Transfer account ownership to another member (owner only).""" @@ -377,7 +378,7 @@ async def list_invites( @router.post("/me/leave") async def leave_account( - db: Annotated[AsyncSession, Depends(get_db)], + db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(get_current_active_user)] ): """Leave the current account (non-owners only). Creates a personal account.""" @@ -423,7 +424,7 @@ class DeleteAccountRequest(BaseModel): @router.delete("/me") async def delete_account( data: DeleteAccountRequest, - db: Annotated[AsyncSession, Depends(get_db)], + db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(require_account_owner)] ): """Delete the current account and soft-delete the user (owner only, no other members).""" diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index ed913441..2634a6ef 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update as sa_update from app.core.config import settings from app.core.settings_manager import SettingsManager -from app.core.database import get_db +from app.core.admin_database import get_admin_db from app.core.rate_limit import limiter from app.core.security import ( verify_password, @@ -67,7 +67,7 @@ def _generate_display_code() -> str: async def register( request: Request, user_data: UserCreate, - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Register a new user. @@ -232,7 +232,7 @@ async def register( async def login( request: Request, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Login and get access token.""" # Find user by email @@ -270,7 +270,7 @@ async def login( async def login_json( request: Request, credentials: UserLogin, - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Login with JSON body (alternative to form data).""" result = await db.execute(select(User).where(User.email == credentials.email)) @@ -304,7 +304,7 @@ async def login_json( async def refresh_token( request: Request, payload: Annotated[dict, Depends(get_refresh_token_payload)], - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Refresh access token using refresh token (rotation: old token is revoked).""" user_id = payload.get("sub") @@ -368,7 +368,7 @@ async def get_me( async def update_me( data: UserUpdate, current_user: Annotated[User, Depends(get_current_active_user)], - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Update current user's profile (name, email).""" update_fields = data.model_fields_set - {"current_password"} @@ -415,7 +415,7 @@ async def update_me( @router.post("/logout") async def logout( payload: Annotated[dict, Depends(get_refresh_token_payload)], - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Logout user by revoking the refresh token.""" jti = payload.get("jti") @@ -438,7 +438,7 @@ async def change_password( request: Request, data: ChangePasswordRequest, current_user: Annotated[User, Depends(get_current_active_user)], - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Change the current user's password.""" if not verify_password(data.current_password, current_user.password_hash): @@ -478,7 +478,7 @@ async def change_password( async def forgot_password( request: Request, data: ForgotPasswordRequest, - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Request a password reset email. Always returns success (anti-enumeration).""" result = await db.execute(select(User).where(User.email == data.email)) @@ -513,7 +513,7 @@ async def forgot_password( @router.post("/password/verify-reset-token", response_model=VerifyResetTokenResponse) async def verify_reset_token( data: VerifyResetTokenRequest, - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Verify a password reset token is valid.""" payload = decode_token(data.token) @@ -544,7 +544,7 @@ async def verify_reset_token( async def reset_password( request: Request, data: ResetPasswordRequest, - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Reset password using a valid reset token.""" payload = decode_token(data.token) @@ -611,7 +611,7 @@ async def reset_password( @router.get("/email/verification-status") async def get_verification_status( - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Check if email verification is enabled on the platform.""" enabled = await SettingsManager.get("email_verification_enabled", db, default=True) @@ -623,7 +623,7 @@ async def get_verification_status( async def send_verification_email( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Send an email verification link to the current user.""" verification_enabled = await SettingsManager.get("email_verification_enabled", db, default=True) @@ -662,7 +662,7 @@ async def send_verification_email( @router.post("/email/verify") async def verify_email( data: dict, - db: Annotated[AsyncSession, Depends(get_db)] + db: Annotated[AsyncSession, Depends(get_admin_db)] ): """Verify an email using a token. Public endpoint.""" token = data.get("token") diff --git a/backend/app/api/endpoints/onboarding.py b/backend/app/api/endpoints/onboarding.py index fdb07cd8..534f58a6 100644 --- a/backend/app/api/endpoints/onboarding.py +++ b/backend/app/api/endpoints/onboarding.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_active_user from app.core.database import get_db +from app.core.admin_database import get_admin_db from app.models.assistant_chat import AssistantChat from app.models.psa_connection import PsaConnection from app.models.session import Session @@ -98,7 +99,7 @@ async def get_onboarding_status( @router.post("/onboarding-status/dismiss", response_model=OnboardingStatus) async def dismiss_onboarding( - db: Annotated[AsyncSession, Depends(get_db)], + db: Annotated[AsyncSession, Depends(get_admin_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> OnboardingStatus: """Dismiss the onboarding checklist for the current user."""