Skip to content

SECURITY ARCHITECTURE

VietGara — Multi-Tenant SaaS Platform for Auto Garage Management & Maintenance

Version 1.6 — October 10, 2026 · Cloud: Google Cloud Platform (GCP) · Backend: Go

1. Introduction

This document describes the security architecture of the VietGara platform, implementing the requirements set out in the Non-Functional Requirements document (Section 5 — Security) and the Compliance Checklist. Scope: authentication/authorization, multi-tenant data isolation, encryption, API security, secrets management, and audit logging.

2. Authentication

2.1. Sign-up, Verification and Sign-in

sequenceDiagram
  actor U as User
  participant W as Owner web
  participant I as Identity module
  participant R as Redis
  participant M as Mailjet
  U->>W: sign up (e-mail, password, name)
  W->>I: POST /auth/register
  I->>I: bcrypt(password), user (unverified)
  I->>R: OTP hash, 2 min TTL
  I->>M: OTP e-mail
  I-->>W: session (email_verified = false)
  U->>W: enter OTP
  W->>I: POST /auth/email-verification
  I->>R: check hash, attempts ≤ 5
  I-->>W: new session (email_verified = true)
  Note over W,I: Later sign-ins: POST /auth/login (e-mail + password) or Google OAuth popup
  • Passwords: bcrypt, minimum 8 characters. Wrong credentials return a single INVALID_CREDENTIALS code (no user enumeration).
  • OTPs (e-mail verification and password reset): 6 digits from a CSPRNG, stored only as a hash bound to the user and purpose, valid 2 minutes, resend after 30 seconds, invalidated after 5 wrong attempts. Password-reset requests answer identically whether or not the e-mail exists; a verified reset OTP yields a single-use reset token valid 10 minutes, and a reset revokes all sessions.
  • Unverified users may only read/update their profile, request an OTP and verify; every other RPC fails with EMAIL_NOT_VERIFIED (the EmailVerified interceptor).
  • Google sign-in uses the authorization-code flow with a state cookie; only Google-verified e-mails are accepted.

2.2. Tokens

{
"sub": "user_id",
"email_verified": true,
"platform_admin": false,
"iat": 1790380000,
"exp": 1790381200
}
  • Access token: HS256 JWT, 20 minutes (JWT_ACCESS_TTL); it carries no garages or roles — authorization is resolved per request (Section 3), so revoking access or editing a role takes effect immediately.
  • Refresh token: opaque random value, 30 days (JWT_REFRESH_TTL), stored in Redis and rotated on every refresh; logout and password reset revoke it.
  • MFA (NFR-SE-04, FR-IAM-09) is provided from Release 6.1 as an option. See Section 2.4.

The one deliberate exception to “every request carries a bearer token”: a customer approving a quote through its public link is never signed in. The link’s opaque token (32 random bytes, hex-encoded) is itself the authorization for that one quote, in place of a JWT:

  • Generated by SendQuote for QUOTE_APPROVAL_MODE_LINK quotes only; only its SHA-256 hash is stored (quotes.public_token_hash), the same principle as OTPs — a stolen database dump cannot be replayed as a link.
  • Scoped to exactly one quote, expires after 30 days, and is cleared the moment the quote is decided (approved or rejected) — a used or expired token answers 422 QUOTE_LINK_INVALID on both public endpoints (GetPublicQuote, DecidePublicQuote; Event Design, API Specification Section 7.2).
  • The public endpoints carry the Public policy (Section 3.2) and so skip the Auth/Garage interceptors entirely; the use case itself resolves the token to a garage and quote (find_quote_by_public_token, a SECURITY DEFINER function scoped to return only those two ids, the same pattern owner_usage() uses to cross a normally garage-scoped RLS boundary — Database Design Section 4.1) and opens its own transaction for that one garage before reading or writing the quote.
  • The public quote response never includes internal ids (garage, customer, vehicle) — only the quote number, names and lines the customer needs to decide.

2.4. Multi-Factor Authentication (FR-IAM-09, Release 6.1)

  • Users that sign in with e-mail and password may enrol a TOTP authenticator app and receive one-time recovery codes (stored hashed). MFA is optional for everyone and is never enforced. A user without a password (Google only) cannot enrol and relies on Google’s 2-step verification.
  • For a user who turned it on, sign-in becomes two steps: the password check returns a short-lived challenge token (5 minutes, single use, not an access token); the TOTP or recovery code exchanges it for the access and refresh tokens. A refresh does not ask again, and Google sign-in never asks.
  • Five wrong codes (at sign-in, when confirming the enrolment, and when disabling or regenerating) lock the user’s code checks for 15 minutes. A TOTP step is accepted once, so an observed code cannot be replayed; each recovery code works once; resetting a password does not disable MFA.
  • Enabling, disabling and regenerating the recovery codes are audited (mfa.enabled, mfa.disabled, mfa.recovery_codes_regenerated).
  • The TOTP secret is encrypted at rest with AES-256-GCM; the key (MFA_ENCRYPTION_KEY) comes from the configuration, not from the database. Without the key the feature is off.

3. Authorization

3.1. RBAC Model

Permissions come from a fixed catalogue (garage.write, staff.read, staff.write, customer.read, customer.write, customer.correct, partner.read, partner.write (renamed supplier.read / supplier.write in Release 5.0), subscription.read, quote.read, quote.write from Release 1.2, and the repair order, inventory, billing and report permissions of later releases). The Catalogue module (the parts and, from Release 6.0, units, packaging, categories, part images) has its own pair, catalogue.read / catalogue.write, added in Release 6.0 together with their place in the default roles below; Inventory keeps inventory.read / inventory.write for stock, receipts and movements. A part’s selling price is visible to anyone with catalogue.read; cost needs inventory.cost.read (Release 7.0). The job catalogue of Phase 7 uses the quote permissions.

The catalogue is grouped by module, and every permission is named <module>.<action> with action read or write. A module offers only the actions that make sense for it: garage has only write (every active member may read the garage profile), and subscription and report have only read.

ModuleReadWrite
garage—garage.write: profile, configuration, logo
staffstaff.readstaff.write: invite, (de)activate, remove, change groups
customercustomer.readcustomer.write: customers and vehicles
partner → supplierpartner.readpartner.write: suppliers only; renamed supplier.* in Release 5.0. Insurers have their own directory in the Insurance module and are not suppliers
subscriptionsubscription.read—
quote, repair_order, inventory, billing<module>.read<module>.write (Release 1.2+)
inventory (cost)inventory.cost.read: part cost, average cost, stock value, the purchases and valuation reports and their Excel exports— (Release 7.0; a separate permission, granted by role)
data import (Release 7.2)—No permission of its own: an import needs what the same data needs. Parts: catalogue.write, plus inventory.write for opening stock and inventory.cost.read for costs. Goods receipts: inventory.write. Stock counts: inventory.count. The file is checked against the caller, so a column the role may not use is refused before anything is written. Each confirmed import is audited (import.completed) with the file name and its SHA-256, and a file larger than 5 MB or 5,000 rows is refused
inventory (count, adjust)— (reads stay on inventory.read)inventory.count: create, edit, merge, complete and cancel stock counts. inventory.adjust: write-offs, returns to supplier and repair order part returns. Both are Release 7.1, separate from inventory.write and from each other, and granted by role
job catalogue (Release 7.3)quote.readquote.write: the jobs and their suggested parts are quoting data, so they follow the quote permissions, not catalogue.*. Adding a job to a quote needs quote.write; cancelling an order and editing its lines need repair_order.write
cataloguecatalogue.read: see parts, units, categories, selling pricescatalogue.write (Release 6.0): create and edit parts, units, packaging, categories, prices and images
reportreport.read—

Write includes read. A role holding <module>.write always holds <module>.read too: the backend adds it whenever a role’s permissions are saved or loaded (access.Normalize), and the role editor ticks read along with write and unticks write along with read. A role therefore can never be allowed to change data it cannot see.

ConceptScopeRule
OwnerThe garages the user ownsHolds every permission; only the owner changes garage status and manages staff groups
RoleAll garages of one ownerA named set of permissions defined by the owner. Default roles are generated on e-mail verification: Manager, Accountant, Service Advisor, Technician, Inventory Staff
Staff groupOne garageAssigns one role to its members; a member may belong to several groups
MemberOne garageAn existing user that accepted an invitation; holds the union of its groups’ permissions while active and while the garage is active
Platform adminPlatform-widePlans and subscriptions, through the separate Admin Web (its own origin, so tokens and local storage are not shared with the Owner Web); no blanket access to a garage’s business data (read-only support access through an owner-approved, audited grant, Section 3.4)
Default roleTypical permissions
ManagerEvery catalogue permission (garage settings, staff, customers, partners/suppliers, documents, reports); not owner-only actions such as garage status or staff groups
AccountantRead customers, partners, subscription, quotes, repair orders, inventory and its cost, catalogue; billing (read/write); reports
Service AdvisorCustomers and vehicles (read/write), partners (read), quotes and repair orders (read/write), inventory (read; no cost), catalogue (read)
TechnicianRepair orders (read/write), inventory (read; no cost), catalogue (read)
Inventory StaffInventory (read/write), its cost, counts and adjustments (inventory.count, inventory.adjust, Release 7.1), catalogue (read/write), partners (read/write), repair orders (read)

3.2. Enforcement

flowchart LR
  r[Request] --> a{"Auth<br/>valid JWT?"}
  a -- no --> e401[401]
  a -- yes --> v{"EmailVerified?"}
  v -- no --> e403a["403 EMAIL_NOT_VERIFIED"]
  v -- yes --> p{"PlatformAdmin<br/>required?"}
  p -- "required, not admin" --> e403b[403]
  p -- ok --> g{"Garage-scoped?"}
  g -- no --> h[Handler]
  g -- yes --> m{"owner or active member<br/>of an active garage<br/>with the permission?"}
  m -- no --> e403c["403 GARAGE_ACCESS_DENIED /<br/>PERMISSION_DENIED / OWNER_ONLY"]
  m -- yes --> l{"License mode"}
  l -- locked --> e402["402 SUBSCRIPTION_SUSPENDED"]
  l -- "read-only and a write" --> e403d["403 SUBSCRIPTION_READ_ONLY"]
  l -- ok --> h
  • The gateway (Kong) routes, terminates TLS and limits request sizes; it does not make authorization decisions.
  • Every RPC must have a declared policy; a missing policy fails closed.
  • Authorization data (membership, groups, role permissions) is read in the same transaction as the request, under RLS.

3.4. Platform Roles and Support Access (Phase 8)

  • Two kinds of platform user. A Platform Admin holds every platform permission and is bootstrapped from PLATFORM_ADMIN_EMAILS (at least two are recommended); it cannot be removed through the UI. Admin staff are users whose platform permissions come from platform roles assigned to platform staff groups, mirroring garage roles and staff groups (Section 3.1). The platform catalogue (plan, subscription, stats, garage_directory, user, platform_staff, support, audit) is separate from the garage catalogue, and authorization is evaluated per request from the database (ADR-007), so removing a user from a group applies at once.
  • No blanket access to garage data. Admin staff read a garage’s business data only through a support grant: one garage, read-only, time-boxed (default 24 hours, at most 7 days), with a reason or ticket, approved by the garage owner. A Platform Admin needs no approval but must give a reason, which is logged, and the owner is notified.
  • Session rules. Grant sessions run in a read-only transaction, cannot impersonate a user, show a banner in the UI, and expose diagnostic views (trace lookup, outbox status, notification log). Sensitive-field masking is a backlog item; write access is not offered.
  • Visibility and audit. Every read is written to the immutable audit log and shown in the owner’s Support access log; the owner can revoke a grant at once.
  • MFA is optional, the Platform Admin and the platform staff included (Section 2.4).

3.5. Supplier Portal Scope (Phase 17)

  • A Supplier is not a garage member and not a customer. A user becomes a supplier user only through a confirmed supplier_user_links row (its own consent), created by the garage.
  • The portal reads and writes only the purchase orders addressed to the linked supplier and their attachments; it never exposes costs, stock, customers, vehicles or settlements, and it cannot call garage or admin endpoints.
  • One user can hold several roles at once (supplier of one garage, customer of another, staff of a third); each is a separate link with a separate surface, and the access checks run per request from the database (ADR-007).
  • Attachments are private files of the registry served through signed URLs; every supplier action is audited.

4. Multi-Tenant Data Isolation

The Row-Level Security mechanism is detailed in Database Design, Sections 1.2 and 7. Execution flow:

  1. The request names its garage in the path (/api/v1/garages/{garageId}/…); there is no tenant header.
  2. The Garage interceptor opens a transaction and runs set_config('app.current_garage_id', …, true) and set_config('app.current_user_id', …, true) (transaction-local, safe with connection pooling).
  3. The caller’s access to that garage is checked (Section 3.2).
  4. Every query in the transaction is filtered by the RLS policies; the application connects as a login in vietgara_app (no SUPERUSER, no BYPASSRLS) and every tenant table has FORCE ROW LEVEL SECURITY.
  5. Integration tests run as a vietgara_app login and assert that another garage’s rows are invisible and unwritable (NFR-SE-03); CI runs them on every pull request.

5. Encryption

Data TypeMeasure
Data in transitTLS 1.2+ required for all client-server and service-to-service connections (including internal GKE traffic via mTLS if a service mesh is used)
Data at restCloud SQL encrypts by default (Google-managed keys); especially sensitive data (e.g. claim documents from Phase 17) additionally uses a Customer-Managed Encryption Key (CMEK)
Payment informationNever stored directly; fully tokenized via the Payment Gateway Adapter (PCI-DSS SAQ-A)
Application secrets (API keys, certs)Google Secret Manager, never hard-coded, rotated periodically

6. API & Infrastructure Security

  • Rate limiting at the API Gateway per client IP and per user (Kong, Release 8.0), with the strict profile on sign-in, registration, OTP, password reset, public quote links, the public contact request (Release 10.2) and, from Phase 12, public booking; security headers and a CORS policy per production host (NFR-SE-09, 10); OTP endpoints are additionally limited per user in the backend (resend interval, attempt counter).
  • Request size limits at the gateway (4 MB) and per field in the backend (e.g. logos ≤ 1 MB, type-checked by content).
  • Uploaded files are stored under random names and registered in the files table (garage or user owner, kind, size, checksum). Public kinds (logo, part image) are readable by URL; every other kind is private and served through short-lived signed URLs, with an authorization check on the local backend. Local-disk files are written with mode 0600. A garage’s files are purged with the garage (NFR-SE-11).

6.1. Public Endpoints and Local Agents (Phases 13 and 14)

  • Public booking (/b/:garageSlug) is the first unauthenticated write path for customers: e-mail OTP before a booking reaches the garage, 15-minute holds for unverified bookings, per-contact caps, Kong limits, a captcha after a limit trips and no other customers’ data on the page (FR-BKG-08).
  • The print agent opens only outbound connections, is bound to one garage by a revocable key and accepts signed jobs for that garage alone.
  • WAF + Cloud Armor in front of the Load Balancer, defending against the OWASP Top 10 (SQLi, XSS, etc.) and DDoS at the edge.
  • VPC Service Controls limit the access scope between GCP projects/services, reducing the attack surface.
  • Container image scanning (Container Analysis) in the CI/CD pipeline before deploying to GKE.
  • Penetration testing at least once a year and before each new payment/invoicing feature launch (NFR-SE-06).

6.2. Plan Payment Evidence and Platform Bank Details (Release 5.2)

  • The evidence of a plan payment (screenshot or PDF of a bank transfer) is personal and financial data. It is a private file of the registry (Database Design 6.7), owned by the owner user and readable only by that owner and by platform admins through short-lived signed URLs; it is never public and never embedded in e-mails.
  • Uploads are limited to PNG, JPEG, WebP and PDF within a size limit, checked by content, not by file name.
  • The platform’s bank details and QR code are edited only by platform admins, are shown to every signed-in owner through the settings endpoint, and every change is audited. A request keeps the instructions that were shown when it started, so a later change never rewrites what an owner was asked to pay to.
  • The unique transfer reference ties a transfer to one request and is shown to the owner only; approval is always a human decision of a platform admin, with the amount comparison shown, and a payment request is never approved by a webhook before Phase 16.

7. Audit Log

Security-relevant and financial operations are recorded in the append-only audit_log table: actor (user_id), garage_id, action, entity_type, entity_id, before/after (jsonb), IP, timestamp. The application role vietgara_app has INSERT and SELECT only — UPDATE and DELETE are revoked — so the application cannot alter history.

  • Release 1.1 records: garage created/updated; staff invited, joined, updated, removed; invitations revoked; roles and staff groups created/updated/deleted; plans created/updated; subscription plan changes and admin extensions.

  • Billing & Settlement records:

    ActionEntityBefore / after
    settlement.createdsettlement— / the settlement: number, repair order, total, paid amount, status
    settlement.payment_recordedsettlement_payment (the payment’s id)the settlement’s paid amounts and status before and after, and after also the payment: payer, amount, method, note

    A change whose audit entry cannot be written fails, so money never changes unaudited. A request replayed from its Idempotency-Key runs nothing and adds no entry.

  • Release 5.1 adds customer.link_requested, customer.link_confirmed, customer.link_ended, customer.link_answered (the user is never named), settlement.billing_updated, vehicle.corrected and vehicle.owner_changed (reason kept), listed in Event Design Section 4. Customer-user links are the one table with a garage-or-user Row-Level Security policy (Database Design Section 3.4); the one-time code of a request is bound to the request, not the user (OTP purpose customer_link).

  • Release 5.2 adds subscription.plan_payment_submitted, subscription.plan_payment_cancelled, subscription.plan_payment_approved (with the amount comparison and the admin’s note), subscription.plan_payment_rejected (with the reason), subscription.credit_adjusted and platform_payment_settings.updated (the bank details and QR file id before and after). Changing the platform’s bank details is the highest-risk edit in the product, because an altered account redirects owners’ payments, so it is audited with who, when and the previous values and is restricted to platform admins (Section 6.2).

  • Release 5.0 adds settlement.payment_voided (the voucher, reason and totals before and after), and MFA enrolment, disabling and recovery-code regeneration (Release 6.1). Receivables, invoicing and insurance claim operations are added with those modules (Phases 14, 16 and 17).

  • The entry is written in the same transaction as the change, so an operation and its audit record commit or roll back together. Before go-live (Release 1.5) the log is also exported to Cloud Logging / BigQuery with a retention matching accounting regulations, outside the application’s reach.

8. Mapping to Compliance

The full details of legal compliance items (Decree 13/2023, e-invoicing, cybersecurity, etc.) are presented in the Compliance Checklist — this document only ensures the underlying technical measures (encryption, RBAC, audit log, MFA) are in place to meet those requirements.

Front-end separation

The Owner Web and the Platform Admin are separate single-page apps served from separate hosts. Platform-admin code is therefore never delivered to garage users, and a platform admin’s session lives in the Platform Admin origin. The Admin Web shows an access-denied page to any user without the platform-admin flag; the backend’s PlatformAdmin interceptor is the authority and rejects /api/v1/admin/* calls from everyone else. The Platform Admin offers e-mail and password sign-in only: the Google popup result is delivered to a single front-end origin (APP_BASE_URL, the Owner Web). The Platform Admin is served on vietgara-admin.viettechno.com (Deployment Architecture Section 2.2); restrict access to that host (IP allow-list or VPN) where possible.