Work / Chitragupt / Wiki / Decisions
2026-06-28-ca-share-scope-rules-side
Decisioncanonicalverified 2026-06-28
DECISION.2026-06-28.CA-SHARE-SCOPE-RULES-SIDECA 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:
- 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.
- The scope doc is already denormalized to
users/{ownerUid}/shares_granted/{caUid}, so the rule'sget()is cheap (single doc, hot). - Tests live next to the rules in
apps/mobile/tests/firestore-rules/, so the binary check is auditable.
Impact
firebase/firestore.rules— new helperscaShareScope,ayWithinShare,accountWithinShare,caCanReadAy,caCanReadAccount,caCanReadScoped. Six match blocks updated (documents,accounts,ledger_entries,tax_reviews,quick_check_answers,capital_gains).- Existing
isCaOfmembership 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.tsare 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é