Skip to content

Where enforcement lives

The one thing to understand about this system before changing anything: the database is the security boundary. Everything else is presentation.

Rules: ACC-001 (LAW-grade, non-negotiable), AUD-001, AUD-002 (LAW).


The shell: finding a page, and a tab left open across a release

The sidebar's box narrows the menu — type "voucher" and the menu shows Vouchers, type "finance" and it shows the whole Finance section. It matches on any word and on the address, ignores case, and says so when nothing matches. It is not a record search: it finds pages (src/components/layout/navFilter.ts).

Each build names its screens with its own hash, so a tab opened before a release asks for a screen that is no longer part of the build; the server answers with the app page and the browser refuses it as the wrong type. src/lib/staleBuild.ts listens for that failure and reloads the tab once onto the current build — once, because a reload loop would be worse than the error. Everything else is left to surface as it is.

1. There is no application server

Browser (React + Vite)
  └── src/lib/api.ts   ← route handlers that run ON THE CLIENT
        └── supabase-js
              ├── PostgREST  →  Postgres: RLS, constraints, triggers, SECURITY DEFINER functions
              ├── GoTrue (Auth)
              └── Edge Functions (Deno) — privileged work, secrets, webhooks, public forms,
                                            and every file (the company Shared Drive; no Supabase Storage)

src/lib/api.ts looks like a server. It has route strings, handlers, and await requirePermission('x.y') at the top of every write. It is not a server. It is bundled into the page and runs in the user's browser, where the user can change it.

This file runs in the browser, so none of the code below is a security boundary: the RPCs check the caller's permission and confine a partner login to its own bookings themselves. — src/lib/api.ts

So requirePermission() does a real and useful job — it shapes the screen, gives a clean 403, and documents intent — and it stops nobody. Anything that protects money, data or access is enforced again in Postgres or in an edge function.

The rule for contributors

A check that exists only in the browser does not count (ACC-001). When you add a write path, add the permission check in src/lib/api.ts and make sure the database refuses the same thing — an RLS policy using auth_user_has_permission('x.y'), a constraint, a trigger, or a SECURITY DEFINER function that takes the actor from the session.

2. The four mechanisms

Row-level security. Every table has explicit policies per action, tied to a permission or to ownership. No table allows "any signed-in user" to read or write business data (ACC-002). Policies call auth_user_has_permission('x.y'), never a role name. See Row-level security.

Constraints. The cheap, unbypassable checks: a hold must have somebody to hold it for; a seat release's approver cannot be its requester; a refund must be positive; counters cannot go negative.

Triggers. Two jobs. First, guards — Booking_lifecycle_guard, Booking_credit_limit (a partner's credit limit on every booking write, PTR-030), Payment_guard, JournalEntry_guard, JournalEntry_limit_guard (approval limits, ACC-030), TicketRecord_guard, VisaCase_guard and their siblings refuse the writes a browser session must not make, and silently correct the fields that are derived rather than entered. Second, derivation — passenger category, booking counts, group capacity and inventory counters are recomputed from the underlying rows, so a value typed by a screen is replaced by the real one.

SECURITY DEFINER functions. Anything that needs more than a row write: a business action that must be all-or-nothing, that must take the actor from the session, or that must enforce maker-checker. submit_booking, verify_payment, approve_refund, approve_journal_entries, issue_ticket, change_visa_status, request_seat_release, open_incident, partner_create_booking — the same shape every time: check the permission, take the actor from auth.uid(), lock what needs locking, validate, write, audit.

The native app adds nothing outside this pattern. Its tables (TripGuideStep, GroupActivity, GroupNotice, TravellerLocationShare, TravellerLocation, TravellerPassportSubmission) are readable under row security and writable by nobody but their functions: trv_my_trips, trv_trip_programme, trv_upsert_guide_step / trv_delete_guide_step (programme.manage), trv_upsert_activity / trv_delete_activity / trv_post_notice (groups.edit or the group's leader), trv_group_audience, trv_set_location_sharing / trv_report_location (the traveller's own login, only while trv_booking_is_travelling), fld_group_locations (the leader or groups.view), trv_submit_passport / trv_review_passport_submission (bookings.edit to apply). Rules TRV-001 … TRV-006 and FLD-006; test supabase/tests/the_app_for_travellers.sql. The guide's parts, translations and each traveller's language and tradition (TripGuidePart, TripGuideTranslation, TravellerGuidePreference) have row security and no policy at all: only functions read or write them — trv_guide for the traveller, trv_guide_editor / trv_guide_preview for staff, trv_guide_set_status for approval (guide.religious.approve for religious text) — rules TRV-018 … TRV-021, test supabase/tests/guide_languages.sql.

Leave and the employee agreement follow the same pattern (22 · Leave and the employee agreement):

  • Row security. LeaveApplication, LeaveEvent, LeaveLedger, CompOffCredit, LeaveYearClose, EmployeeAgreement and AgreementTemplate grant SELECT only. An application and its timeline are read by the applicant, by HR and management through auth_user_has_permission(), by the applicant's reporting manager (leave_may_read()) and by the person it is routed to; the ledger and comp-off credits by their owner and HR only; the year close by HR; an agreement by its employee, hr.agreements.view / hr.agreements.issue, and a countersignatory once the employee has signed. LeaveSettings, LeaveType, Holiday and PeakSeason are readable by any staff login. The anonymous key reaches none of it (ACC-053).
  • Functions. Every write is a SECURITY DEFINER function that takes the actor from auth.uid() through leave_actor(), refuses a paused login, checks the permission and the routing, and writes an AuditLog row: leave_apply (all checks in leave_evaluate, the same answer leave_preview shows), leave_decide (leave_can_decide_hr, leave_can_decide_mgmt — never the applicant), leave_cancel, leave_decide_cancellation, the HR functions, and agreement_issue, agreement_sign, agreement_countersign (the password checked against auth.users in agreement_check_password, the text's SHA-256 compared).
  • Triggers. LeaveLedger_append_only, LeaveEvent_append_only, LeaveYearClose_append_only, LeaveApplication_no_delete; EmployeeAgreement_guard (no delete, no change to the text, hash, values or a given signature, status only forward), EmployeeAgreement_no_truncate, AgreementTemplate_guard.
  • Constraints. An application's paid and unpaid days add up to its days; one comp-off per person per day; one ledger row per period key, which makes the accruals idempotent; an agreement's signature hashes equal its content hash, and nobody countersigns their own.
  • The schedule. pg_cron job alhuda-leave-nightly runs leave_nightly() at 19:00 UTC (00:30 IST): yearly credits, monthly EL accrual, comp-off expiry (LV-062). The migration skips it, with a notice, on a database without pg_cron; HR runs the same catch-up by hand (runbook).

Tests: supabase/tests/leave_follows_the_agreement.sql, supabase/tests/an_agreement_is_signed_and_kept.sql.

A guard function that needs to know who is calling is deliberately SECURITY INVOKER, so current_user is the real caller and fin_direct_client() / bl_is_client_write() can tell a browser session from a definer function.

3. Edge functions

Thirty-three Deno functions in supabase/functions/. They exist for work the browser must not do: holding a provider secret, verifying a webhook signature, serving a public form, using the service role.

Notable ones: whatsapp-send (consent and the 24-hour window), whatsapp-webhook, mailer, communications-dispatcher, visa-intake (public, captcha fails closed, rate limited), lead-intake, admin-users, permissions-admin, auth-admin-mfa-reset, session-manager, upload-customer-doc, drive-upload (every other file onto the company Shared Drive, gated per kind), drive-file (opens any file with a ten-minute link to itself, checked per kind, TRV-011, ACC-074), extract-passport, razorpay-webhook and razorpay-order (the app's online payment: the order here, the receipt only from Razorpay's signed event — API → Online payment), flight-lookup (the AirLabs flight schedule for the inventory screens, holding the key the browser used to carry — PLT-015), finance-copilot, integration-test, secret-selftest.

Each must verify the caller's identity and permission and fail closed (ACC-003); a function called by an outside service verifies a signature instead. A function whose security depends on a secret must refuse the request when the secret is missing, not skip the check (PLT-013).

Two known gaps, recorded rather than hidden

admin-users runs with verify_jwt = false and full service privileges — it must do its own authentication and fail closed, and ACC-003 flags it as CRITICAL. whatsapp-webhook fails open when its signing secret is missing, which PLT-013 forbids. Check supabase/config.toml before deploying any function: a function with verify_jwt = false must call guardRequest or verify a signature.

4. The audit trail

Every change to a business or accounting record is written by the database, with the old value, the new value, who and when — so it cannot be skipped by any screen or API path (AUD-001, Companies (Accounts) Rules r.3(1), LAW). Append-only triggers cover 73 tables, and privileges are revoked so that no user or role, administrator included, can edit, delete or wipe an audit record (AUD-002). If the audit row cannot be written, the business change is rejected.

The reason typed into a confirmation dialog travels with the change and is stored on the audit row (UX-001).

Identity numbers — passport, Aadhaar, PAN, bank account — appear in full only on the record they belong to. Audit rows, logs, exports and notifications show the last four characters (AUD-020). Incident audit rows carry no customer, booking or group tag at all, so health data never surfaces on a timeline that an ordinary bookings.view holder can read.

Books of account and their audit trail are kept for at least eight financial years, and no automated purge may remove them (AUD-003).

5. Lists are a promise

"This is what exists." Every list endpoint returns a shared paged shape, pages by keyset rather than offset, filters and counts in the database, and throws rather than returning a truncated export. See Lists, paging and search.

6. Intelligence is computed, then explained

Anything that can be derived exactly from records — deadlines, balances, readiness, capacity, drift, duplicates — is computed by rules in the database or shared library code, tested, and cites the rule it implements. A language model is used only for language. A model never produces a number, status or decision that the system then trusts (INT-002).

The system may suggest, rank and warn. It never moves money, changes a status, grants access or sends an external message on its own (INT-003). The one exception is purely internal reminders and escalations defined by a rule.

Every signal shows why — the facts it used and the rule that raised it (INT-004).

7. How a change reaches production

Briefly: a branch, the CI gates, a squash merge to main, then a manually approved pipeline run that backs up first and applies migrations. Nobody runs supabase db push or dashboard SQL against production by hand. See Releases and testing and the deploy runbook.

8. Where to go next