Skip to content

Authentication & Database

Authentication Flow

  1. User logs in via Butler Platform's shared auth UI (auth.saikoku-studio.com)
  2. Firebase Auth issues a JWT
  3. Frontend stores the JWT in a butler_session cookie scoped to .saikoku-studio.com
  4. Butler Tax backend reads the JWT and resolves the corporate context
  5. 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:

  1. From /settings, the user submits the new email + current password.
  2. POST /auth/me/change-email (cookie-authenticated) verifies the current password (identitytoolkit signInWithPassword), rejects an already-used address (firebase_auth.get_user_by_email), then issues a one-time token in the email_change_tokens collection (uuid4, 1-hour TTL, single-use). It sends a Butler-branded confirmation email to the new address and a notice to the old address.
  3. The confirmation link opens /auth/verify-email-change?token=..., which calls POST /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, records audit_logs (email.change_requested / email.change_confirmed), revokes refresh tokens, and clears the butler_session cookie.
  4. 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:

python
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_uid

get_corporate_context validates that:

  1. The caller is a corporate of type tax_firm
  2. The target corporate's advising_tax_firm_id matches the caller's UID

When validated, the context is built with:

  • corporate_id / firebase_uid set to the target client company
  • user_id set to the tax firm (used as the actor in audit_logs)
  • role fixed to accounting (admin-only endpoints are auto-blocked)
  • is_tax_firm_proxy = True
  • actual_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:

python
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):

python
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, including role and permissions
  • receipts / invoices — documents with approval_status, reconciliation_status
  • transactions — bank statement entries
  • matches — reconciliation records linking transactions and documents
  • audit_logs — append-only operation log
  • approval_rules / matching_rules / journal_rules — rule configurations
  • permission_settings — dynamic role overrides per feature_key

Firestore collections (Butler Tax PII)

  • company_profiles/{id} — company profile with firebase_uid, is_default
  • bank_accounts/{id} — bank account with owner_type (corporate | client) and either profile_id or client_id
  • clients/{id} — vendor/customer master with bank_display_names array

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.

Butler Series — Saikoku Studio