Authentication & Database
Authentication Flow
- User logs in via Butler Platform's shared auth UI (
auth.saikoku-studio.com) - Firebase Auth issues a JWT
- Frontend stores the JWT in a
butler_sessioncookie scoped to.saikoku-studio.com - Butler Tax backend reads the JWT and resolves the corporate context
- All data access is scoped to the resolved
CorporateContext
In development, the Authorization: Bearer <token> header is also accepted. Mock tokens (test-token, tax-test-token) are honored when ENVIRONMENT=development.
Account email change (Platform, #138)
Email changes do not use Firebase's client-side verifyBeforeUpdateEmail (which routes through Firebase's hosted action handler and briefly shows its generic screen). Instead the Platform owns the whole flow, so it is branded and identical across dev / staging / prod with no Firebase Console configuration:
- From
/settings, the user submits the new email + current password. POST /auth/me/change-email(cookie-authenticated) verifies the current password (identitytoolkitsignInWithPassword), rejects an already-used address (firebase_auth.get_user_by_email), then issues a one-time token in theemail_change_tokenscollection (uuid4, 1-hour TTL, single-use). It sends a Butler-branded confirmation email to the new address and a notice to the old address.- The confirmation link opens
/auth/verify-email-change?token=..., which callsPOST /auth/verify-email-change(unauthenticated — may be opened on another device). The token is atomically marked used, then the backend updates Firebase Auth (update_user(uid, email=..., email_verified=True)), MongoDB (platform_accounts.email), and the Stripe customer email, recordsaudit_logs(email.change_requested/email.change_confirmed), revokes refresh tokens, and clears thebutler_sessioncookie. - The user signs in again with the new address.
platform_accounts.email is the source of truth and is written synchronously at step 3, so GET /auth/me does not lazy-sync email from the JWT (the old #136 behavior was removed). Otherwise a stale pre-change token (old email claim) hitting /me on a second device within the token's ≤1h lifetime would revert the DB email back to the old address until that token expired.
Old sessions are not force-killed across devices: refresh tokens are revoked at step 3 and the butler_session cookie is cleared on the device that opened the link, but an already-issued ID token on another device stays valid until it naturally expires (≤1h). This window is accepted; it no longer mutates data.
Password changes still use the Firebase Client SDK (updatePassword); see auth/frontend/AGENTS.md.
CorporateContext
Every Butler Tax API endpoint that accesses business data uses:
from app.api.helpers import CorporateContext, get_corporate_context
async def my_endpoint(
ctx: CorporateContext = Depends(get_corporate_context)
):
# ctx.corporate_id — MongoDB corporate document ID
# ctx.firebase_uid — Firebase Auth UID (data scope)
# ctx.user_id — MongoDB user (employee or corporate) ID
# ctx.role — staff / approver / accounting / manager / admin
# ctx.db — Motor (async MongoDB) database connection
# ctx.fs — LazyFirestore wrapper (init on first access)
# ctx.is_tax_firm_proxy — True during tax firm impersonation
# ctx.actual_uid — original tax firm UID during impersonation
...Tax Firm Impersonation
Tax firms access client company data by adding the ?as_corporate={firebase_uid} query parameter:
GET /api/v1/receipts?as_corporate=corp_a_uidget_corporate_context validates that:
- The caller is a corporate of type
tax_firm - The target corporate's
advising_tax_firm_idmatches the caller's UID
When validated, the context is built with:
corporate_id/firebase_uidset to the target client companyuser_idset to the tax firm (used as the actor inaudit_logs)rolefixed toaccounting(admin-only endpoints are auto-blocked)is_tax_firm_proxy = Trueactual_uid= tax firm UID
Audit log entries from impersonation include proxy_actor_uid so the frontend can render "Tax Firm X is operating on behalf of Client Y."
Database Access
MongoDB (Motor async)
Used for all business data:
async for doc in ctx.db["receipts"].find({"corporate_id": ctx.corporate_id}):
...Firestore (Sync client + lazy init)
Used for PII (company_profiles, bank_accounts, clients):
import asyncio
# LazyFirestore wraps the synchronous firestore.client(). The actual
# `firestore.client()` call happens only on first `.collection()` access.
docs = await asyncio.to_thread(
lambda: list(
ctx.fs.collection("clients")
.where("firebase_uid", "==", ctx.firebase_uid)
.stream()
)
)Why lazy init?
get_corporate_context runs on every authenticated request. Eagerly calling firestore.client() there would fail with DefaultCredentialsError in any environment without Firebase credentials, blocking even routes that don't touch PII. LazyFirestore defers credential resolution until a route actually accesses Firestore.
Database Layout
MongoDB collections (Butler Tax)
corporates— corporate entity records (firebase_uid → MongoDB _id)employees— employee records, includingroleandpermissionsreceipts/invoices— documents withapproval_status,reconciliation_statustransactions— bank statement entriesmatches— reconciliation records linking transactions and documentsaudit_logs— append-only operation logapproval_rules/matching_rules/journal_rules— rule configurationspermission_settings— dynamic role overrides perfeature_key
Firestore collections (Butler Tax PII)
company_profiles/{id}— company profile withfirebase_uid,is_defaultbank_accounts/{id}— bank account withowner_type(corporate|client) and eitherprofile_idorclient_idclients/{id}— vendor/customer master withbank_display_namesarray
Document IDs are 24-char ObjectId-compatible hex strings, generated with bson.ObjectId() for compatibility with audit_logs.document_id references.
is_default uniqueness is enforced via @firestore.transactional — the transaction reads existing defaults within the same scope, sets them to false, and atomically writes the new default.
