Skip to content
Ritesh FirodiyaGet in touch

Work / Chitragupt / Wiki / Decisions

2026-06-28-ca-share-scope-rules-side

Decisioncanonicalverified 2026-06-28

DECISION.2026-06-28.CA-SHARE-SCOPE-RULES-SIDE

CA share scope (year_max, account_ids) enforced in Firestore rules

Decision

The Phase 7a exit criterion "CA share (year_max, account_ids) enforced server-side" lands in firebase/firestore.rules rather than in a per-callable wrapper. CAs read most client data via the Firestore SDK directly (the CA portal subscribes to users/{ownerUid}/documents, tax_reviews, ledger_entries, capital_gains, accounts, etc.), so the rules file is where the scope actually has to bite.

Three new helpers in firestore.rules:

Helper Used for
caCanReadAy(ownerUid, ay) tax_reviews/{ay}, capital_gains/{ay}, ledger_entries/{entryId} (resource.data.ay), quick_check_answers/{answerId} (resource.data.ay)
caCanReadAccount(ownerUid, accountId) accounts/{accountId}
caCanReadScoped(ownerUid, ay, accountId) documents/{docId} (resource.data.ay + resource.data.account_id)

Each helper get()s users/{ownerUid}/shares_granted/{caUid}.scope once. AY comparison parses the first 4 chars of the AY string ("2026-27" → 2026) and compares against year_max. account_ids: null means "all accounts"; a non-null list means the CA may read only docs whose account_id is in the list, and docs with account_id == null (e.g., Form 16) require an unscoped share.

Collections that have no AY or account field on the resource — tax_concerns, portfolio_trades, document_versions, documents/{docId}/edits — keep the bare isCaOf(uid) membership check. The scope check at the parent layer (the document the entry was minted from, the engagement the concern belongs to) is the load-bearing one.

Why

The audit on 2026-06-01 flagged that the share schema (account_ids, year_max) was being written on grant but never enforced on read — any CA with an accepted share could subscribe to the owner's entire vault regardless of the scope the owner picked. Phase 7a closes that.

Rules-side enforcement is the right layer because:

  1. The CA portal reads directly from Firestore via the web SDK; there is no callable to attach the scope check to without a major refactor.
  2. The scope doc is already denormalized to users/{ownerUid}/shares_granted/{caUid}, so the rule's get() is cheap (single doc, hot).
  3. Tests live next to the rules in apps/mobile/tests/firestore-rules/, so the binary check is auditable.

Impact

  • firebase/firestore.rules — new helpers caShareScope, ayWithinShare, accountWithinShare, caCanReadAy, caCanReadAccount, caCanReadScoped. Six match blocks updated (documents, accounts, ledger_entries, tax_reviews, quick_check_answers, capital_gains).
  • Existing isCaOf membership predicate stays as the family-mate / engagement-side check. Renaming would have churned ~20 unrelated rule blocks.
  • Tests in apps/mobile/tests/firestore-rules/users.test.ts are extended to cover scoped reads (allow within scope, deny out of scope).
  • Schema-side change: none. The share schema already had both fields (packages/shared/src/schemas/share.ts).

Status

Active.

Sources

  • .context/features/index.md — Phase 7a exit criterion
  • packages/shared/src/schemas/share.ts (SharePermissionsSchema, scope shape)
  • apps/functions/src/marketplace/ca-share.ts (inviteCa, editShareScope)

Every project of mine is written down like this.

Read the résumé