Instruction file imported from pavanthakur/XYDataLabs.OrderProcessingSystem (
.github/instructions/multitenant-payment-schema.instructions.md). Copyright stays with the author.
Multitenant Payment Schema Rules — XYDataLabs.OrderProcessingSystem
These rules are binding for all tenant, payment, DTO, migration, middleware, and related test changes.
Authority
- Root standard:
ARCHITECTURE.md - If this file conflicts with older instructions or comments, follow
ARCHITECTURE.mdand the current codebase.
Tenant model
- Use the three-key tenant pattern only:
Tenant.Id→ internal FK onlyTenant.ExternalId→ external API/webhook/integration keyTenant.Code→X-Tenant-Code, ops tooling, logs
Tenant.Idmust never appear in external DTOs or UI surfaces.Tenantsis a system table, not a tenant-owned table.
Tenant resolution
- Request header is
X-Tenant-Code, neverX-Tenant-Id. - Missing or unknown tenant code → HTTP 400.
- Resolved
SuspendedorDecommissionedtenant → HTTP 403. - No null tenant context may flow downstream.
ITenantProvidermust exposeHasTenantContext,TenantId,TenantCode,TenantExternalId,ConnectionString, andIsSharedPool.- Approved headerless paths are
GET /api/v1/info/runtime-configuration,GET /health,GET /health/live, andGET /health/ready. - UI/browser code must read the active tenant from API runtime configuration, not UI-local configuration.
Tenant tier model (hybrid)
- Two tiers:
SharedPool(default) andDedicated. Values inTenantTierConstants. Tenant.TenantTier— nvarchar(20), NOT NULL, defaultSharedPool.- Dedicated tenant connection strings are stored in configuration (
DedicatedTenantConnectionStrings:{Code}in appsettings / Key Vault), never in the database.- Missing config entry = unresolvable (fail-loud, not silent shared-pool routing).
- For Managed Identity connections: use the full connection string (no credentials embedded).
- For password-based connections: use a Key Vault secret reference, never the raw password.
TenantAandTenantBare alwaysSharedPool.TenantCisDedicatedwith connection string provisioned per environment via config/Key Vault.IsSharedPoolis derived fromTenantTier == SharedPool, NOT from connection string presence.- A Dedicated tenant without a provisioned connection string config entry is treated as unresolvable (fail-loud).
Tenant registry (seed state — authoritative routing, ADR-019)
| TenantCode | Tier | Active provider | Notes |
|---|---|---|---|
| TenantA | SharedPool | Razorpay | OpenPay seeded inactive; Razorpay is the active routing baseline and uses Use3DSecure=false (popup/SAQ A) |
| TenantB | SharedPool | Razorpay | OpenPay seeded inactive |
| TenantC | Dedicated | OpenPay | Separate DB; requires DedicatedTenantConnectionStrings:TenantC in config |
Tenant.PaymentProviderCode(nvarchar(50), nullable) is the only authoritative routing field — set by migration/ops script, never by code.ITenantRegistry.FindByCode(string)is the sync resolver at payment dispatch time.PaymentProvider.IsActiveis kept but routing-inert after Phase 8.6.- Ops change:
UPDATE Tenants SET PaymentProviderCode = '...' WHERE Code = '...'— no PR needed.
Tenant resolution pipeline (circular dependency rule)
EntityFrameworkTenantResolverMUST useTenantRegistryDbContext, neverOrderProcessingSystemDbContext.TenantRegistryDbContextalways uses the shared/admin connection string from configuration.TenantRegistryDbContexthas noITenantProviderdependency and no query filters.OrderProcessingSystemDbContextuses the per-request connection string resolved fromITenantProvider.- Query filters are always active on all DbContexts (defense-in-depth). On dedicated DBs the filter is trivially true.
IAppDbContext boundary
IAppDbContextmust NOT exposeDbSet<Tenant>. Tenant queries go throughITenantRegistryorITenantResolver.- Application-layer handlers must never query the
Tenantstable directly. ITenantRegistry(in Application/Abstractions) provides read-only access to active tenants for bootstrap endpoints.
Tenant-owned entities
- Tenant-owned entities must inherit from
BaseAuditableEntityorBaseAuditableCreateEntity. - Do not redeclare
TenantIdon derived entities. TenantIdisint, non-nullable, FK-backed.- Tenant-owned entities must have:
- FK to
Tenants(Id) - global query filter on
TenantId - explicit tenant-scoped indexes for lookup keys
- FK to
Tenantsand any other non-tenant-owned system tables are excluded from tenant query filters and tenant stamping.
Payment identifiers
- Use only these names:
CustomerOrderIdAttemptOrderIdPaymentTraceId
- Do not introduce new uses of these ambiguous or legacy names:
OrderIdfor payment attempt identityReferenceNoAPINO1APINO2
- Customer-facing DTOs and UI may show
CustomerOrderId. AttemptOrderIdis provider/callback/technical only.PaymentTraceIdis internal-only and must not appear in customer-facing DTOs or UI.
Column limits (violating these causes HTTP 500 on callback)
| Entity | Column | Limit |
|---|---|---|
TransactionStatusHistory |
Notes |
255 |
PaymentAttemptHistory |
Notes |
512 |
PaymentAttempt |
LastErrorMessage |
512 |
ConfirmPaymentStatusCommandHandlermust truncate text to these limits before persisting.
Card data handling (PCI DSS 3.2)
CardTransactionmust never store raw PAN or CVV.CreditCardCvv2was removed — CVV must not be persisted under any circumstances.MaskedCardNumberstores BIN (first 6) + masked middle + last 4, e.g.411111******1234.PayinLog.LastFourCardNbrstores only the last 4 digits for audit trail.- No DTO, log output, or error message may contain a full card number or CVV.
- Architecture tests enforce these constraints — see
CardTransaction_Should_Not_Store_Raw_Card_Data.
Per-tenant payment flags
PaymentProvider.Use3DSecurecontrols whether 3D Secure is enabled per tenant. It is aboolcolumn (defaulttrue) on thePaymentProviderentity.- This is a business rule per tenant, not an infrastructure/global setting. It must NOT be in
OpenPayConfigor appsettings JSON. ProcessPaymentCommandHandlerreadsUse3DSecurefrom_paymentProvider.Use3DSecure— thePaymentProviderentity resolved byTenantPaymentProviderResolverviaAppMasterData.GetProviderByTypeForTenant().- Seed defaults come from
DbInitializer.GetUse3DSecureSeedDefault. Razorpay seedsfalsefor all tenants so the runtime uses hostedprovider_checkout; OpenPay and future providers seedtrueunless explicitly changed. Re-seed never overwrites existing DB values. - Future per-tenant payment flags (e.g. per-tenant MerchantId) should follow the same pattern: column on
PaymentProvider, not appsettings.
ConfigureTenantOwnership pattern
- Every new tenant-owned entity MUST be registered in
ConfigureTenantOwnership<T>()inOnModelCreating(). - This single call configures: FK to
Tenants(Id), global query filter onTenantId, andDeleteBehavior.Restrict. - Do NOT manually configure these three concerns individually — always use
ConfigureTenantOwnership<T>(). - After adding the call, also add the corresponding
DbSet<T>to bothOrderProcessingSystemDbContextandIAppDbContext.
IAppDbContext parity rule
IAppDbContextmust expose everyDbSet<T>fromOrderProcessingSystemDbContextexceptDbSet<Tenant>.Tenantis a system entity — tenant queries go throughITenantRegistryorITenantResolver, never throughIAppDbContext.- Architecture test
IAppDbContext_DbSets_Must_Match_OrderProcessingSystemDbContext_Minus_Tenantenforces this at CI.
IgnoreQueryFilters exemption rule
.IgnoreQueryFilters()bypasses tenant isolation and is restricted to an architecture-test allow-list.- Current approved usages: none — the allow-list is empty (ADR-009).
AppMasterDatawas removed from the allow-list: it now uses scoped lifetime and respects the tenant query filter.- Any new usage requires: (1) a code-review justification documenting why cross-tenant access is safe, and (2) adding the filename to the allow-list in
ArchitectureTests.IgnoreQueryFilters_Usage_Must_Be_In_Allow_List_Only.
EF and migrations
- Current baseline starts from
RebaselineMultitenantPaymentSchema. TenantAandTenantBmust be seeded in the baseline migrationUp().- Every new tenant-owned table migration must include:
- FK to
Tenants - required tenant-scoped indexes
- FK to
- After creating any migration, run a drift check with a second migration and confirm it is empty.
- Remove the temporary drift-check migration after verification.
Non-request operations
DbInitializer, background jobs, test fixtures, and any out-of-band creation flow must passTenantIdexplicitly.- Never rely on ambient middleware tenant context in non-request code paths.
- For dedicated-DB seeding, use
NullTenantProvider(null-objectITenantProviderwithHasTenantContext = false). This causes the EF query filter to short-circuit totrue(all rows visible), which is correct when physical DB isolation replaces query-filter isolation. DbInitializer.SeedDedicatedTenants()readsDedicatedTenantConnectionStringsfromIConfigurationto seed dedicated tenant databases. Connection strings are never stored in theTenantstable.
Required test coverage
Architecture tests (MultiTenantSchemaTests)
- Migration drift detection (
EfMigrationDriftTests) - Tenant-scoped composite index presence on payment entities
- Tenant global query filter presence on tenant-owned entities; absence on Tenant entity
- Customer-facing DTO identifier surface compliance (no internal IDs exposed)
TenantTierConstantsdefinesSharedPoolandDedicatedvaluesTenant.TenantTierdefaults toSharedPoolTenantRegistryDbContexthas no query filter on Tenant
Middleware / integration tests (TenantMiddlewareTests)
- 400 for missing tenant header
- 400 for unknown tenant code
- 403 for Suspended / Decommissioned tenant status
- Runtime configuration endpoint returns DB tenants without auth
SharedPool tenant isolation tests (TenantIsolationTests)
- Per-entity query-filter isolation: tenants in the same DB see only their own data
- No dependency on shared baseline tenant rows (tests create their own tenants)
Dedicated tenant tests (DedicatedTenantTests)
Middleware / status scenarios (single-DB factory):
- Dedicated + unprovisioned (null CS) + Active → 400 (fail-loud, not silent shared-pool fallback)
- Dedicated + unprovisioned (null CS) + Suspended → 400 (unresolvable takes priority over status)
- Dedicated + provisioned + Suspended → 403
- Dedicated + provisioned + Decommissioned → 403
- Dedicated + provisioned + Active → 200 (routed to dedicated DB)
- SharedPool tenant coexists with Dedicated tenants without interference
Physical DB isolation scenarios (routing-aware factory):
- Data written to dedicated tenant is physically present in dedicated DB (direct SQL verification)
- Dedicated tenant data NOT visible via direct query on shared-pool DB
- SharedPool tenant data NOT visible via direct query on dedicated DB
New feature guardrail
When adding a new tenant-owned entity or feature:
- SharedPool path: verify query filter isolation via
TenantIsolationTestspattern - Dedicated path: verify physical DB isolation via
DedicatedTenantTestspattern — write through routing factory, verify via direct SQL on both databases - Architecture guard: ensure FK to Tenants, composite index, and query filter are tested
- Never silently route an unprovisioned Dedicated tenant to SharedPool — fail loud