ADR-017: RLS Multi-Tenancy Migration
# ADR-017 — RLS Strategy:Multi-Tenancy ForwardMigration
Decisions
**Status:** Accepted — forward-decisionCEO supplement to ADR-017-RLS-MULTITENANCY.md
Date:Signed 2026-05-1311 (Alem Bašić). Phase 2A V17 Flyway PERMISSIVE migration authorized for stage execution. Phase 2C RESTRICTIVE flip remains gated on Securion audit + 30-day soak per §4 schedule.
**Date:** 2026-05-11
**Author:** Bruce Momjian (Database Architecture, CodeCraft)
**Architecture Review:** Petter Graff (CodeCraft)
**Decision-maker:** CEO Alem Bašić — PhaseSIGNED 2C2026-05-11 flip("ok requiresadr17 explicitodobreno") CEOvia sign-offsession perf73dafab
§5**Mehanik clearance:** /tmp/mehanik-cleared-100362
**MC Task:** #100586#100362 (Phase 0' ADR-017ADR RLSConsolidation)
strategy**Promoted forward decisions)
Relationship to existingfrom:** ADR-017:bilko-001 draft (`~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-001-multi-tenant-architecture.md`)
**Cross-references:**
- ADR-017-RLS-MULTITENANCY.md023 (signedwhy 2026-05-11)single recordsDB theremains three-phase migration
decision and the Phase 2A PERMISSIVE DDL that is now live on disk (V16, V17).
This document does NOT re-document what is already live. It defines the forward
decisions that were deferred from Phase 0' because V17 was still being executed:
Phase 2C RESTRICTIVE flip gate criteria (executable checklist)bilko_approle grant matrix (per-table, per-operation)Service-account bypass policy (Flyway, admin jobs, monitoring)Connection pool integration (OrgScopePlugin + HikariCP +SET LOCAL)CountryPlugin interaction: does country filtering compose with org filtering?Rollback procedure if RESTRICTIVE flip breaks production traffic
Cross-references:
ADR-017-RLS-MULTITENANCY.md (Phase 0' decision + Phase 2A DDL)V16__country_constraint.sql (on diskcorrect —organizations.country§6NOTsupersessionNULLtriggers+notCHECK)fired; V17__rls_permissive.sql§2(oncontext)disk-— PERMISSIVE policies + bilko_app role creation)OrgScopeSessionVariable.kt (on disk — OrgScopePlugin + orgTransaction pattern)- ADR-
015-FOUR-JURISDICTION-PLUGIN.md015 (TaxJurisdiction enumdrivingdrivescountry_code)`country_code` - column CHECK values)
- ADR-
023 §6bilko-001 (single-DBancestorsupersessiondraft,triggersfully absorbed by this ADR — do notfiredreferenceasancestor)of-2026-05-13)ADR-bilko-003 - §Layer 3 (versioned CoA data model)
- Plan v3 §4a (Option D not triggered), §4c (RLS timing
rationale)—
before
Phase 1H merge)
- `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md`
---
## 1. Context
### 1.1 Current DB State (tool-verified 2026-05-13)
11)
V16| Component | State |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Database | `bilko-demo-db`, Cloud SQL PostgreSQL 15, europe-north1 |
| Flyway migrations | V1..V15 applied |
| Row-Level Security | NOT enabled — zero RLS policies on any table |
| Tenant isolation | Application-layer only: `WHERE org_id = :principalOrgId` clauses |
| `organizations.country` | Column exists; values `'RS'`, `'HR'`, `'BA'`; NOT NULL constraint absent |
| Cross-tenant leak | Confirmed: PUT `/api/v1/invoices/{id}` and V17GET are`/api/v1/invoices/{id}/pdf` appliedwith oncross-tenant disk.JWT Phasereturn 2AHTTP is complete.
| |
| current |
| |
| |
| |
| |
Theit planin v3two
existing routes.
### 1.2 Why Single Database Remains Correct (ADR-023 §26 statedCheck)
"V1..V15ADR-023 applied"§6 —defines the conditions that waswould thetrigger statemigration atto Option D (per-country DBs).
All five conditions are unmet as of 2026-05-11 plan(Plan authoring.v3 Martin§4a Kleppmann'slines panel100–108):
review- (MCPaying #100586customers genesis)in correctly2+ identifiedmarkets: that0 V16— NOT triggered
- Regulatory request for per-country data extract: none received — NOT triggered
- HR-FISK kernel-level coupling: Storecove API path requires no kernel isolation — NOT triggered
- p95 query latency > 500ms from cross-country noise: 0 paying customers — NOT triggered
- 2 customers complain about cross-country data visibility: 0 customers — NOT triggered
Option D costs +$60/month infra and V172–4 haveweeks sinceengineering beenper applied.market with no customer-facing
benefit today. **This ADR acceptsis thatexplicitly realitycompatible andwith governsOption onlyD forwardmigration** decisions.
2. bilko_app Role Grant Matrix
V17 creates bilko_app via a DO block if it does not exist, but does not grant it any
table-level privileges. The Ktor application connects as the bilko database user
(Cloud SQL IAM SA), not as bilko_app. The relationship between the two roles must
be explicit so that— RLS policies
targetingare to TOportable bilko_appactuallyseparate fire.
2.1If Decision:Option ConnectionD Usertriggers, Grantsthe bilko_app
same Thepolicy applicationDDL runtimeapplies
connectsto aseach per-country DB with zero changes.
### 1.3 Why RLS Cannot Wait Until Post-HR GA
Plan v3 §4c (bilkoorlines its135–145): Cloudthe SQLcross-tenant IAM500 SAleaks equivalent).are a live security defect.
With 0 paying customers today it is unexploited — but a bilko_appmarkersecond roleregistered usedorganization
by(required for HR demo) creates an immediately exploitable state.
RLS policies.PERMISSIVE The connection user must be a
member of bilko_app so that policies targeting TO bilko_app apply.
-- V22__bilko_app_grants.sqlmode (Phase 2A2A) cleanupimposes —zero runuser-facing oncechange perand environment)zero --risk ZAKON:of Runservice
afterdisruption. V17The existing `WHERE org_id` middleware still fires, and RLS fires alongside
it. Both must pass for data to be returned. A latent policy gap is confirmedcaught applied. This is idempotent via IF NOT EXISTS.
-- Grant bilko_app membership to the application runtime user.
-- Cloud SQL IAM SA user is 'bilko' (or the IAM SA email shortname).
-- REPLACE 'bilko' with the actual Cloud SQL user if it differs per environment.
GRANT bilko_app TO bilko;
-- Grant DML on all 9 tenant tables to bilko_app.
-- RLS policies will further restrict these grants per session variable.
-- SELECT is required on organizations for org-lookup at login time.
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE organizations TO bilko_app;
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE invoices TO bilko_app;
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE invoice_items TO bilko_app;
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE expenses TO bilko_app;
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE transactions TO bilko_app;
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE bank_transactions TO bilko_app;
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE bank_accounts TO bilko_app;
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE accounts TO bilko_app;
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE contacts TO bilko_app;
-- Sequence grants: required for INSERT on tables with UUID default (gen_random_uuid()
-- requires no sequence; SERIAL tables require USAGE on the sequence).
-- Current schema uses gen_random_uuid() — no sequence grants needed.
-- If any table is changed to BIGSERIAL in future: GRANT USAGE, SELECT ON SEQUENCE <seq> TO bilko_app;
-- Schema USAGE: allows bilko_app to reference objects in the public schema.
GRANT USAGE ON SCHEMA public TO bilko_app;
2.2 Grant Matrix Summary
| |||||
| |||||
| |||||
| |||||
| |||||
| |||||
| |||||
| |||||
|
Immutability enforcement on transactions and bank_transactions: UPDATE and DELETE are
revoked at the role grant level. This is defence-in-depth:by the
application layer alsorather prohibitsthan theseexposing operations,data butto revokingthe wrong tenant.
**CEO sign is required before Phase 2A Flyway migrations run on stage** — not before
this ADR document is accepted. The ADR records the decision; the sign unblocks execution.
---
## 2. Decision
**Option C is adopted: Shared codebase, shared deployment, shared database, with
PostgreSQL Row-Level Security enforcing tenant isolation.**
This is the unanimous recommendation from the 5-agent architecture review (ADR-bilko-001 §framing,
line 28–30). One codebase. One Cloud Run deployment. One PostgreSQL instance with RLS.
### 2.1 Binding Constraints
1. `Organization.taxJurisdiction` (`TaxJurisdiction` enum `{HR, RS, BA_FED, BA_RS}` per ADR-015) is
the primary discriminator for jurisdiction-specific behaviour.
2. `Organization.id` (UUID) is the primary tenant discriminator for data isolation.
3. RLS policies enforce data isolation at the DBdatabase rolelayer. levelApplication meanscode anMUST applicationNOT
bugrely cannotsolely accidentallyon overwrite`WHERE org_id = :id` clauses (ADR-005 flaw — being retired by Phase 2C).
4. The `country_code` column on `organizations` is NOT NULL with CHECK constraint
`IN ('HR', 'RS', 'BA_FED', 'BA_RS')` — enforced by Flyway V16 (Phase 1H Task 1H.1).
5. EU data residency: Current `bilko-demo-db` is in Cloud SQL `europe-north1` (Finland).
This IS within EU/EEA — GDPR Article 44 satisfied. Frankfurt migration (eu-central-1)
is not required to unblock HR GA (Plan v3 §4d lines 179–183).
### 2.2 Three-Phase Migration Path
The migration is split into three phases to ensure zero service disruption and a ledgersafe
entry.rollback Correctionspath areat handledeach viastep.
compensating#### journalPhase entries,2A — PERMISSIVE RLS (parallel with Phase 1H, target: end of Week 2)
**Goal:** RLS policies created and attached, set to PERMISSIVE. Existing application-layer
scoping continues to operate. Both layers must pass — RLS is a second check, not directa
UPDATE.
2.3**Who Whatsigns bilko_appthis Doesoff:** NOTCEO Get
Alem - Bašić
No(thisTRUNCATEADR signature) — required before any Phase 2A Flyway migrations run onanythetablestage database. **DDL — PERMISSIVE policies (preventsFlywayaccidentalV17):**mass```sqldeletion)-- NoV17__rls_permissive.sqlCREATE--TABLE,ZAKON:DROPCEOTABLE,sign required before this migration runs on stage. -- Apply PERMISSIVE RLS on core tables. Application-layer WHERE org_id -- clauses remain active. Both must pass. -- Enable RLS on tables ALTER TABLE(schemaorganizationschangesENABLEareROWFlyway-only)LEVEL NoSECURITY;accessALTERtoTABLEinvoiceslogged_actionstableENABLEdirectlyROW—LEVELauditSECURITY;insertsALTERgoTABLEthroughinvoice_itemsaENABLEtriggerROWfunctionLEVELownedSECURITY; ALTER TABLE expenses ENABLE ROW LEVEL SECURITY; ALTER TABLE transactions ENABLE ROW LEVEL SECURITY; ALTER TABLE bank_transactions ENABLE ROW LEVEL SECURITY; ALTER TABLE bank_accounts ENABLE ROW LEVEL SECURITY; ALTER TABLE accounts ENABLE ROW LEVEL SECURITY; ALTER TABLE contacts ENABLE ROW LEVEL SECURITY; -- PERMISSIVE policy: organization-scoped isolation -- current_setting() reads the app.current_org_id session variable -- set bythebilko_adminthatKtorfiresconnectiononpoolDMLbeforeto audited tablesNo access toflyway_schema_historytableNo SUPERUSER, CREATEDB, CREATEROLE privileges
3. Service-Account Bypass Policy
Several non-application processes musteach query the(connection databasemiddleware).
withoutCREATE beingPOLICY boundorg_isolation byON RLSinvoices
policies.AS ThisPERMISSIVE
sectionFOR definesALL
howTO eachbilko_app process-- application role (NOT superuser)
USING (org_id = current_setting('app.current_org_id')::uuid);
CREATE POLICY org_isolation ON invoice_items
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (
invoice_id IN (
SELECT id FROM invoices
WHERE org_id = current_setting('app.current_org_id')::uuid
)
);
CREATE POLICY org_isolation ON expenses
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (org_id = current_setting('app.current_org_id')::uuid);
CREATE POLICY org_isolation ON transactions
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (org_id = current_setting('app.current_org_id')::uuid);
CREATE POLICY org_isolation ON bank_transactions
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (
bank_account_id IN (
SELECT id FROM bank_accounts
WHERE org_id = current_setting('app.current_org_id')::uuid
)
);
CREATE POLICY org_isolation ON bank_accounts
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (org_id = current_setting('app.current_org_id')::uuid);
CREATE POLICY org_isolation ON accounts
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (org_id = current_setting('app.current_org_id')::uuid);
CREATE POLICY org_isolation ON contacts
AS PERMISSIVE
FOR ALL
TO bilko_app
USING (org_id = current_setting('app.current_org_id')::uuid);
-- BYPASS for migrations and admin tooling (Flyway runs as bilko_admin)
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
ALTER TABLE expenses FORCE ROW LEVEL SECURITY;
ALTER TABLE transactions FORCE ROW LEVEL SECURITY;
ALTER TABLE bank_transactions FORCE ROW LEVEL SECURITY;
ALTER TABLE bank_accounts FORCE ROW LEVEL SECURITY;
ALTER TABLE accounts FORCE ROW LEVEL SECURITY;
ALTER TABLE contacts FORCE ROW LEVEL SECURITY;
-- Flyway runs as bilko_admin (superuser bypasses RLS andby thedefault).
controls-- thatExplicit preventFORCE privilegeis creep.
3.1— Flyway (Schema Migrations)
Connection user: bilko_admin (Cloud SQL built-in superuser, or the admin role cloudsqlsuperuserholdergrants provisionedBYPASSRLS atif DBneeded.
creation).
RLSSet bypassconnection mechanism: PostgreSQL superusers bypass RLS by default. bilko_admin is a
superuser and therefore never subject to RLS policies, regardless of app.current_org_id.
Control: bilko_admin credentials are available ONLY in:
Cloud Buildmiddleware (CI/CDKotlinpipeline)Exposed—/injectedHikariCP):as--aOnSecreteachManagerconnectionsecretcheckout:at--migration stepVaultwarden break-glass vault — for emergency direct access onlyLocal.env.localfor developer environments — never committed
bilko_admin credentials are NOT stored in the Cloud Run service's environment. The
application process cannot access bilko_admin at runtime. This ensures that even a
compromised application binary cannot use Flyway-level privileges.
Verification: SHOW is_superuser; in a bilko_admin session returns on.
SET ROLE bilko_app; SHOW is_superuser; returns off — RLS applies.
3.2 Background Jobs (Recurring Invoice Generator, Exchange Rate Fetcher)
Background jobs that process multiple organizations must set app.current_org_id per
org iteration. The pattern is:
// apps/api/src/main/kotlin/no/alai/bilko/jobs/RecurringInvoiceJob.kt (example)
fun run() {
val allOrgs = transaction {
// Admin query: fetch all org IDs that have due recurring invoices.
// This query runs WITHOUTLOCAL app.current_org_id set= —'<org_uuid_from_jwt>';
bilko_admin-- On connection //return (orto apool:
connection-- that has been explicitly granted BYPASSRLS).
// Alternative: use a dedicated job-runner DB user with BYPASSRLS.
exec("SET LOCAL app.current_org_id = ''") // explicit empty = no org context
Organizations.selectAll().map { it[Organizations.id] }
}
allOrgs.forEach { orgId ->
orgTransaction(orgId.toString()) {
// All queries inside this block are RLS-scoped to orgId.
// generateDueInvoices() only sees this org's recurring invoices.
generateDueInvoices(orgId)
}
}
}
Decision: Background jobs that require cross-org visibility for the enumeration phase
(e.g., "find all orgs with due invoices") use a dedicated role bilko_job with
BYPASSRLS privilege. The per-org processing phase uses orgTransaction() (RLS-scoped).
-- V22 (addendum): create bilko_job role for background job enumeration phase
CREATE ROLE bilko_job BYPASSRLS NOINHERIT NOCREATEDB NOCREATEROLE;
GRANT CONNECT ON DATABASE bilko TO bilko_job;
GRANT USAGE ON SCHEMA public TO bilko_job;
GRANT SELECT ON TABLE organizations TO bilko_job;; -- enumeration only
-- bilko_job does NOT get DML grants — enumeration is SELECT-only.
-- Actual DML happens via orgTransaction() under bilko_app RLS scope.
Rationale: Giving bilko_job BYPASSRLS for SELECT-only enumeration is a narrower
privilege than making it a superuser. bilko_job has no INSERT/UPDATE/DELETE anywhere.
If a job process is compromised, an attacker can enumerate org IDs but cannot modify data.
3.3 Monitoring Queries (Health Checks, Metrics)
Monitoring processes (Cloud SQL insights, Prometheus pg_stat exporter, health check
endpoints) do not carry org context. They query system tables, not tenant tables.
Pattern: Monitoring uses a dedicated bilko_monitor role:
-- bilko_monitor: read-only system monitoring
CREATE ROLE bilko_monitor NOINHERIT NOCREATEDB NOCREATEROLE;
GRANT CONNECT ON DATABASE bilko TO bilko_monitor;
GRANT USAGE ON SCHEMA public TO bilko_monitor;
GRANT SELECT ON TABLE pg_stat_activity TO bilko_monitor;
-- bilko_monitor has NO grants on any tenant tables.
-- It cannot access organizations, invoices, etc. regardless of RLS.
Health check queries (SELECT 1) do not require any tenant table grants. The application
health endpoint should use a dedicated bilko_health connection that does not set
app.current_org_id and only executes SELECT 1 or SELECT NOW().
3.4 Backup Processes (pg_dump, Cloud SQL export)
Cloud SQL automated backups operate at the storage layer, not via SQL connections.
They bypass all RLS, role grants, and session variables — backups capture the full
database state unconditionally. This is correct and expected.
For application-level backup exports (e.g., per-org data export for GDPR Article 20 data
portability), the export service uses orgTransaction() — RLS-scoped, identical to
normal application queries. The export service role is bilko_app (not bilko_admin).
3.5 Role Hierarchy Summary
| |||
| |||
| |||
| |||
| |
4. Connection Pool Integration (OrgScopePlugin + HikariCP)
4.1 Current Implementation (on disk)
OrgScopeSessionVariable.kt (tool-verified 2026-05-13) implements:
OrgScopePlugin— KtorApplicationPluginthat stores org ID inThreadLocalper requestorgTransaction(organizationId, block)— wraps Exposedtransaction {}withSET LOCAL app.current_org_idsetOrgContext(orgId)/clearOrgContext()— utility functions for explicit lifecycle management
Critical design invariant: SET LOCAL scopes the session variable to the current
transaction boundary. When the transaction commits or rolls back, the variable is
automatically unset. The connection returned to HikariCP does NOT carry the variable.
This means:
EveryorgTransaction {}call must set the variable — it cannot be set once at connection checkout and assumed to persist across multiple transactions.The HikariCP connection pool does not need to be modified or connection-tested for variable state —SET LOCALensures clean state on every connection return.Coroutine-based code must ensureorgTransactionis called on the same coroutine dispatcher as the database access (Dispatchers.IO for Exposed). TheThreadLocalinOrgScopePluginis a request-lifecycle convenience read — the authoritative value is the one passed explicitly toorgTransaction.
4.2 Failure Modes and Guards
Failure: app.current_org_id not set (PERMISSIVE phase)
If orgTransaction() is not called (e.g., a new route uses plain transaction {} instead),
current_setting('app.current_org_id', true) returns NULL or '' (empty string).
The V17 PERMISSIVE policies guard against this:
CASE WHEN current_setting(reset_config('app.current_org_id', true)
IS```
NULL**Verification ORafter current_setting('Phase 2A:**
```sql
-- Rogue-role test (Proveo E2E + Securion audit):
SET ROLE bilko_app;
SET LOCAL app.current_org_id', true)current_org_id = '<hr_org_uuid>';
THENSELECT falsecount(*) FROM invoices; -- deny:must returnsreturn 0only HR org rows
forSET bilko_appLOCAL roleapp.current_org_id ELSE= '<org_id_checkrs_org_uuid>';
ENDSELECT count(*) InFROM PERMISSIVEinvoices; phase:-- themust application-layerreturn only RS org rows
-- Cross-tenant access attempt:
SET LOCAL app.current_org_id = '<hr_org_uuid>';
SELECT * FROM invoices WHERE org_id clause= is'<rs_org_uuid>'; still-- active.must If
a developer forgets orgTransaction(), the RLS policy returnsreturn 0 rows (PERMISSIVE deny),blocks)
but```
#### Phase 2B — Audit Log Partitioning (post-HR GA)
**Goal:** Partition the application`logged_actions` audit table by `country_code` to enable
per-jurisdiction GDPR data extraction requests and enforce per-jurisdiction retention.
```sql
-- V18__audit_log_partitioning.sql (Phase 2B — post-HR GA)
-- Declarative partitioning by country_code
CREATE TABLE logged_actions_partitioned (
LIKE logged_actions INCLUDING ALL
) PARTITION BY LIST (country_code);
CREATE TABLE logged_actions_hr PARTITION OF logged_actions_partitioned
FOR VALUES IN ('HR');
CREATE TABLE logged_actions_rs PARTITION OF logged_actions_partitioned
FOR VALUES IN ('RS');
CREATE TABLE logged_actions_ba_fed PARTITION OF logged_actions_partitioned
FOR VALUES IN ('BA_FED');
CREATE TABLE logged_actions_ba_rs PARTITION OF logged_actions_partitioned
FOR VALUES IN ('BA_RS');
-- Retention policy enforcement (aligned with CountryPlugin.getRetentionRules()):
-- HR: 11 years (Zakon o računovodstvu NN 78/2015, čl. 10)
-- RS/BA: 10 years
-- Implemented as pg_cron job deleting rows WHERE clauseaction_tstamp_tx would< alsonow() filter- correctlyinterval —'11 theyears'
user-- seesper emptypartition.
results-- rathercountry_code thancolumn cross-tenantbackfilled data.from Thisorganizations.country isvia:
not-- aUPDATE datalogged_actions leakSET butcountry_code is= ao.country
correctness-- bugFROM organizations o WHERE o.id = logged_actions.org_id;
```
RLS policy for `logged_actions` (user
sees 0 results instead of their own data). Detected by integration tests.
Failure: app.current_org_id not set (RESTRICTIVE phase, post-Phase 2C)
Same missing orgTransaction() callapplied in RESTRICTIVE mode: the policy fails closed — 0 rows
returned. This is the intended security-fail-closed behavior. The application sees an empty
result set and surfaces a 404 or an empty list to the user. No cross-tenant data exposure.
Detected by integration tests that assert non-zero results for authenticated requests.
Failure: malformed UUID in app.current_org_id
current_setting('app.current_org_id', true)::uuid will throw invalid input syntax for type uuid if the value is not a valid UUID. OrgScopePlugin (line 168–170 of
OrgScopeSessionVariable.kt) validates UUID format before storing in ThreadLocal:
runCatching { UUID.fromString(orgId) }.onFailure {
log.error("[OrgScope] BilkoPrincipal.organizationId is not a valid UUID: {}", orgId)
return@onCall // request continues without RLS context; app-layer WHERE fires
}
In Phase 2A (PERMISSIVE)2B):
malformed```sql
UUIDCREATE meansPOLICY noorg_isolation RLSON contextlogged_actions_partitioned
→AS app-layerPERMISSIVE
WHEREFOR clauseALL
isTO the effective guard.
In Phase 2C (RESTRICTIVE): same behavior. The policy evaluates NULL::uuid which errors,
but the CASE WHEN NULL guard catches it first and returns false (deny).
Additional guard required for Phase 2C: Before flipping to RESTRICTIVE, the USING
clause must be hardened for the UUID cast:
-- Phase 2C policy pattern (fail-closed UUID handling):bilko_app
USING (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
THEN false
WHEN length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE organization_idorg_id = current_setting('app.current_org_id', true)::uuid
END
)
The length check prevents the UUID cast from throwing on clearly non-UUID values.
A correct ::uuid cast validates the format fully; the length pre-check just avoids
exceptions for obviously invalid input.
4.3 Thread Safety Invariant
OrgScopePlugin uses ThreadLocal<String?> (line 75 of OrgScopeSessionVariable.kt).
Ktor coroutines on Dispatchers.IO may run on different threads for different suspensions
within the same request. This means the ThreadLocal value may not be visible after a
coroutine suspension that crosses a thread boundary.
Decision: ThreadLocal in OrgScopePlugin is used ONLY for service-layer convenience
reads via currentOrgId(). The authoritative RLS enforcement uses the explicit orgId
parameter passed to orgTransaction(organizationId, block) — this parameter is captured
in the coroutine closure and is not thread-dependent.
Rule: Service-layer code must never call currentOrgId() and pass the result to
orgTransaction(). The org ID must come from BilkoPrincipal.organizationId sourced
from the JWT, propagated explicitly through the call chain.
5. CountryPlugin Interaction with RLS
5.1 Isolation Layers Are Independent
RLS enforces org-level isolation (which organization owns this row).
CountryPlugin enforces jurisdiction-level behavior (which rules apply to this org).
These two concerns are orthogonal and compose without interference:
Request arrives → JWT validated → BilkoPrincipal extracted
→ org_id from principal → orgTransaction(org_id) sets app.current_org_id
→ RLS enforces: only this org's rows are visible
→ org.country from principal → CountryPlugin resolved via Ktor DI
→ CountryPlugin.calculateVat() / generateEInvoiceXml() etc. apply jurisdiction rules
→ All DB queries inside the transaction see only this org's rows (RLS)
→ All jurisdiction logic uses CountryPlugin for this org's country
RLS does NOT filter by country. A Croatian org (country = 'HR') and a Serbian org
(country = 'RS') in the same database are isolated from each other by the
app.current_org_id session variable — not by country. Country determines behavior
(VAT rates, invoice format), not data visibility.
5.2 Cross-Country Filtering Does Not Layer on Top of RLS
There is no "country-aware RLS" layer. The RLS policy for invoices is:
USING (organization_id = current_setting('app.current_org_id', true)::uuid);
```
It checks org_id only. A request from an HR org will only ever see HR org rows because
app.current_org_id contains the HR org's UUID — not because the policy checks country.
This is intentional. Adding AND country_code = 'HR' to the policy would be:
Redundant (org-level isolation already prevents cross-org access)Dangerous (ifcountry_codeis NULL or migrated incorrectly, the policy would deny access to legitimate data)Incorrect for admin/Flyway queries that legitimately need cross-country access
Decision: RLS policies filter by organization_id only. Country filtering is the
responsibility of the application layer (via CountryPlugin) and is not enforced in RLS.
5.3 chart_of_accounts: Cross-Org, Country-Scoped
The chart_of_accounts table (defined in ADR-017-RLS-MULTITENANCY.md §2.3) is a
reference table shared across all orgs in the same jurisdiction. It is NOT org-scoped.
Decision: No RLS policy is applied to chart_of_accounts. It is a read-only reference
table accessible to all bilko_app connections. Write access is restricted to Flyway
migrations and admin tooling (bilko_admin).
-- chart_of_accounts grants (part of V22 or separate V23):
GRANT SELECT ON TABLE chart_of_accounts TO bilko_app;
-- No INSERT/UPDATE/DELETE for bilko_app — CoA entries are seeded by Flyway only.
This means an HR org could in theory query RS CoA entries (if they knew to ask). In
practice, the application always calls CountryPlugin.getChartOfAccountsDefaults() which
queries CoA filtered by jurisdiction = org.country — the application enforces the filter;
RLS does not need to.
6.#### Phase 2C — RESTRICTIVE Flip+ —Retire GateApplication-Layer CriteriaScoping and(post-Securion ExecutableAudit)
Checklist
**Goal:** Phase 2C convertsConvert PERMISSIVE RLS policies to RESTRICTIVERESTRICTIVE. and retires theRemove ADR-005 application-layer
`WHERE org_id` org_idclauses.middleware. ThisRLS is the highest-risksole migrationisolation step.
6.1 **Gate Criteriaconditions (all must be true before Phase 2C begins)
:**
- 1.
- Securion audit
completeof Phase 2A policies completed — no critical findingson2.PhaseAutomated2A policies 30-day soak metric thresholds met— see §6.2Fail-closedrogue-role test suite passing in CI (Proveo — see§6.3CEO explicit sign-off— separate from the ADR-017-RLS-MULTITENANCY sign given on 2026-05-11 (that sign coveredPhase 2Aonly;verificationPhaseabove)2C3.requiresZeroa new sign)Rollback plan confirmed— dry-run on a clone of the stage database before production
6.2 30-Day Soak Metric Thresholds
These metrics are measured against the stage Cloud SQL instance from the day V17 applies
until Phase 2C is authorized. All thresholds must be met for a continuous 30-day window.
| ||
| | |
| ||
6.3 Fail-Closed Test Scenarios (Securion Checklist)
These test scenarios MUST be executed and pass beforefor Phase 2C
is```sql
authorized.
Securion (Parisa Tabriz) owns this checklist and provides a signed evidence file.
Core Isolation Tests
Rogue-role SELECT:SET ROLE bilko_app; SET LOCAL app.current_org_id = '<org_a_uuid>'; SELECT COUNT(*) FROM invoices WHERE organization_id = '<org_b_uuid>';Expected: 0 rows (even though the WHERE clause explicitly asks for org B).Rogue-role UPDATE:SET ROLE bilko_app; SET LOCAL app.current_org_id = '<org_a_uuid>'; UPDATE invoices SET status = 'CANCELLED' WHERE organization_id = '<org_b_uuid>';Expected: 0 rows updated (not an error — UPDATE silently affects 0 rows, which is correct fail-closed behavior).Rogue-role INSERT:SET ROLE bilko_app; SET LOCAL app.current_org_id = '<org_a_uuid>'; INSERT INTO invoices (id, organization_id, ...) VALUES (gen_random_uuid(), '<org_b_uuid>', ...);Expected: WITH CHECK violation / 0 rows inserted (RESTRICTIVE WITH CHECK clause must deny cross-org inserts).Missing context SELECT:SET ROLE bilko_app; SELECT COUNT(*) FROM invoices;(noSET LOCAL app.current_org_id) Expected: 0 rows (null/empty context returns false in USING clause).Empty string context SELECT:SET ROLE bilko_app; SET LOCAL app.current_org_id = ''; SELECT COUNT(*) FROM invoices;Expected: 0 rows.Malformed UUID context:SET ROLE bilko_app; SET LOCAL app.current_org_id = 'not-a-uuid'; SELECT COUNT(*) FROM invoices;Expected: 0 rows (length check in USING clause returns false before UUID cast).
invoice_items Subquery Isolation
Cross-org invoice_items via subquery:Org A attempts to read invoice_items for an invoice owned by Org B.SET ROLE bilko_app; SET LOCAL app.current_org_id = '<org_a_uuid>'; SELECT COUNT(*) FROM invoice_items WHERE invoice_id = '<org_b_invoice_uuid>';Expected: 0 rows (invoice_items USING clause subquery on invoices returns empty set for wrong org).
bank_transactions Subquery Isolation
Cross-org bank_transactions via subquery:Same pattern as invoice_items. Expected: 0 rows.
bilko_admin Bypass Verification
bilko_admin can see all rows:SET ROLE bilko_admin; SELECT COUNT(*) FROM invoices;Expected: returns actual total row count (superuser bypass confirmed — this is CORRECT behavior for admin).bilko_admin FORCE RLS is NOT set on organizations:SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'organizations';Expected:relrowsecurity = true,relforcerowsecurity = false(organizations is RLS-enabled but NOT FORCE — bilko_admin can see all orgs for onboarding/support).
Application Integration Tests (Proveo, not Securion)
PUT /api/v1/invoices/{id}with cross-tenant JWT: HTTP 404 (not 500, not 200)GET /api/v1/invoices/{id}/pdfwith cross-tenant JWT: HTTP 404GET /api/v1/invoices/{id}with own-tenant JWT: HTTP 200 with correct dataCreate invoice as HR org → invoice visible to HR org, invisible to RS org querying same ID
6.4 RESTRICTIVE Policy DDL
When all §6.1 gate criteria are met, the flip is executed as Flyway migration V23
(or the next available version at execution time).
-- V23__rls_restrictive.V19__rls_restrictive.sql (Phase 2C — post-post Securion audit + 30d soak + CEO sign)audit)
-- ZAKON:Convert CEOPERMISSIVE Phase→ 2CRESTRICTIVE signon requiredall before applying.tables
-- This migrationis retiresthe ADR-005point application-of no return: application layer WHERE org_id clauses.is --retired Afterafter this migration, app code may remove redundant WHERE org_id = :principalOrgId clauses
-- from service methods. Do NOT remove them before this migration runs on production.
--
-- DOWN: revert to V17 PERMISSIVE state
-- DROP POLICY org_isolation ON invoices; ... (repeat for all tables)
-- CREATE POLICY org_isolation ON invoices AS PERMISSIVE ... (restore V17 text)
-- ─── invoices ────────────────────────────────────────────────────────────────────────────────this.
DROP POLICY org_isolation ON invoices;
CREATE POLICY org_isolation ON invoices
AS RESTRICTIVE
FOR ALL
TO bilko_app
USING (org_id CASE
WHEN= current_setting('app.current_org_id')::uuid)
WITH CHECK (org_id = current_setting('app.current_org_id')::uuid);
-- Same pattern for expenses, transactions, bank_transactions, bank_accounts,
-- accounts, contacts, invoice_items (repeat for each table).
```
### 2.3 Versioned Chart of Accounts Table
The `chart_of_accounts` table stores jurisdiction-specific CoA entries with time-ranged
validity. This supports:
- Pravilnik revisions without code changes (ADR-bilko-003 §Layer 3, lines 122–143)
- Historical invoice accuracy (rate in force at transaction date, not current rate)
- `CountryPlugin.getChartOfAccountsDefaults()` seeding on org creation (ADR-015 §2.2)
```sql
-- Part of Flyway V17 or separate V17b (Phase 2A / 1H parallel)
CREATE TABLE chart_of_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
true)jurisdiction VARCHAR(8) NOT NULL, -- TaxJurisdiction enum value: 'HR', 'RS', 'BA_FED', 'BA_RS'
code VARCHAR(16) NOT NULL, -- e.g. '1300' (HR Kontni Plan), '204' (RS Pravilnik)
name VARCHAR(256) NOT NULL,
account_type VARCHAR(16) NOT NULL -- ASSET, LIABILITY, EQUITY, INCOME, EXPENSE
CHECK (account_type IN ('ASSET', 'LIABILITY', 'EQUITY', 'INCOME', 'EXPENSE')),
vat_treatment VARCHAR(64), -- e.g. 'STANDARD_RATE', 'EXEMPT', null for non-VAT accounts
valid_from DATE NOT NULL,
valid_to DATE, -- NULL = currently valid
version INT NOT NULL DEFAULT 1, -- increments per Pravilnik revision
notes TEXT, -- statutory reference e.g. "NN 78/2015, čl. 5"
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (jurisdiction, code, valid_from)
);
CREATE INDEX idx_coa_jurisdiction_date
ON chart_of_accounts (jurisdiction, valid_from, valid_to);
-- Query pattern: entries valid on a given transaction date
-- SELECT * FROM chart_of_accounts
-- WHERE jurisdiction = $1
-- AND valid_from <= $2
-- AND (valid_to IS NULL OR valid_to > $2)
-- ORDER BY code;
-- When Croatia raises PDV from 25% to 27% on 2027-01-01:
-- INSERT INTO chart_of_accounts (jurisdiction, code, name, account_type, vat_treatment, valid_from, version)
-- VALUES ('HR', '2400', 'PDV po stopi 27%', 'LIABILITY', 'STANDARD_RATE', '2027-01-01', 2);
-- UPDATE chart_of_accounts SET valid_to = '2026-12-31'
-- WHERE jurisdiction = 'HR' AND code = '2400' AND valid_to IS NULL AND version = 1;
-- No code change required.
```
**Seeding:** `CountryPlugin.getChartOfAccountsDefaults()` returns the list of entries
that Flyway data migrations insert into `chart_of_accounts` for each jurisdiction.
Flyway V18 (Phase 1H — separate from V17 RLS) seeds HR Kontni Plan entries.
### 2.4 Exchange Rate Precision Upgrade
**Current precision (CLAUDE.md database rules):** `NUMERIC(19,4)` for ALL monetary amounts.
**Upgrade required for FX rate columns specifically:**
Exchange rates require higher precision than invoice monetary amounts. Using `NUMERIC(19,4)`
for an exchange rate means EUR/RSD at 117.2350 is representable, but EUR/BAM at
1.95583 is stored as 1.9558 — a systematic rounding error that compounds across large
invoice volumes and cross-currency reconciliation.
**Decision:** FX rate columns upgrade to `NUMERIC(20,10)`. Monetary amount columns
(invoice totals, line amounts, tax amounts) remain `NUMERIC(19,4)`.
```sql
-- V17c__exchange_rate_precision.sql (Phase 2A parallel)
ALTER TABLE exchange_rates
ALTER COLUMN rate TYPE NUMERIC(20,10); -- was NUMERIC(19,4)
-- If an exchange_rate_history or similar snapshot table exists:
-- ALTER TABLE exchange_rate_history
-- ALTER COLUMN rate TYPE NUMERIC(20,10);
-- NEVER change invoice_items.unit_price, invoice_items.line_total,
-- transactions.amount, etc. — those remain NUMERIC(19,4).
-- Only rate/exchange_rate columns receive this upgrade.
```
**Invariant:** All monetary arithmetic (invoice totals, tax calculations, double-entry
postings) remains at `NUMERIC(19,4)`. The precision upgrade is scoped to the FX
rate storage layer only. Rounding when applying FX rates to amounts: round half-even
(banker's rounding) to 4 decimal places after multiplication.
---
## 3. Connection Middleware — Setting `app.current_org_id`
The RLS policies use `current_setting('app.current_org_id')::uuid`. This session
variable must be set on every database connection before any query executes.
**Pattern (Kotlin / Exposed / HikariCP):**
```kotlin
// apps/api/src/main/kotlin/no/alai/bilko/db/OrgContextInterceptor.kt (Phase 2A NEW)
/**
* Sets the PostgreSQL session variable `app.current_org_id` to the authenticated
* org's UUID before any database access.
*
* Called from the Ktor routing pipeline after JWT validation, before the
* database transaction opens.
*
* Must reset after the request completes — use try/finally or Ktor plugin lifecycle.
*/
fun setOrgContext(orgId: UUID) {
transaction {
exec("SET LOCAL app.current_org_id = '${orgId}'")
}
}
fun clearOrgContext() {
transaction {
exec("RESET app.current_org_id")
// or: exec("SET LOCAL app.current_org_id = ''")
}
}
```
**Failure mode:** If `app.current_org_id` is not set, `current_setting('app.current_org_id')`
throws an error in PostgreSQL (by default). To make it return NULL instead (for Flyway
admin connections that do not set the variable):
```sql
-- In V17 migration, set default:
ALTER DATABASE bilko_demo SET app.current_org_id = '';
```
And in the policy, guard against empty string:
```sql
USING (
CASE WHEN current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false -- deny if not set
ELSE organization_idorg_id = current_setting('app.current_org_id', true)::uuid
END
)
WITH CHECK (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE organization_id = current_setting('app.current_org_id', true)::uuid
END
);
-- ─── expenses ────────────────────────────────────────────────────────────────────────────────
DROP POLICY org_isolation ON expenses;
CREATE POLICY org_isolation ON expenses
AS RESTRICTIVE FOR ALL TO bilko_app
USING (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE organization_id = current_setting('app.current_org_id', true)::uuid
END
)
WITH CHECK (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE organization_id = current_setting('app.current_org_id', true)::uuid
END
);
-- ─── transactions ─────────────────────────────────────────────────────────────────────────────
DROP POLICY org_isolation ON transactions;
CREATE POLICY org_isolation ON transactions
AS RESTRICTIVE FOR ALL TO bilko_app
USING (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE organization_id = current_setting('app.current_org_id', true)::uuid
END
)
WITH CHECK (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE organization_id = current_setting('app.current_org_id', true)::uuid
END
);
-- ─── bank_accounts ────────────────────────────────────────────────────────────────────────────
DROP POLICY org_isolation ON bank_accounts;
CREATE POLICY org_isolation ON bank_accounts
AS RESTRICTIVE FOR ALL TO bilko_app
USING (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE organization_id = current_setting('app.current_org_id', true)::uuid
END
)
WITH CHECK (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE organization_id = current_setting('app.current_org_id', true)::uuid
END
);
-- ─── bank_transactions ────────────────────────────────────────────────────────────────────────
DROP POLICY org_isolation ON bank_transactions;
CREATE POLICY org_isolation ON bank_transactions
AS RESTRICTIVE FOR ALL TO bilko_app
USING (
bank_account_id IN (
SELECT id FROM bank_accounts
WHERE organization_id = current_setting('app.current_org_id', true)::uuid
AND current_setting('app.current_org_id', true) IS NOT NULL
AND current_setting('app.current_org_id', true) <> ''
AND length(current_setting('app.current_org_id', true)) = 36
)
)
WITH CHECK (
bank_account_id IN (
SELECT id FROM bank_accounts
WHERE organization_id = current_setting('app.current_org_id', true)::uuid
AND current_setting('app.current_org_id', true) IS NOT NULL
AND current_setting('app.current_org_id', true) <> ''
AND length(current_setting('app.current_org_id', true)) = 36
)
);
-- ─── accounts ─────────────────────────────────────────────────────────────────────────────────
DROP POLICY org_isolation ON accounts;
CREATE POLICY org_isolation ON accounts
AS RESTRICTIVE FOR ALL TO bilko_app
USING (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE organization_id = current_setting('app.current_org_id', true)::uuid
END
)
WITH CHECK (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE organization_id = current_setting('app.current_org_id', true)::uuid
END
);
-- ─── contacts ─────────────────────────────────────────────────────────────────────────────────
DROP POLICY org_isolation ON contacts;
CREATE POLICY org_isolation ON contacts
AS RESTRICTIVE FOR ALL TO bilko_app
USING (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE organization_id = current_setting('app.current_org_id', true)::uuid
END
)
WITH CHECK (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE organization_id = current_setting('app.current_org_id', true)::uuid
END
);
-- ─── invoice_items ────────────────────────────────────────────────────────────────────────────
DROP POLICY org_isolation ON invoice_items;
CREATE POLICY org_isolation ON invoice_items
AS RESTRICTIVE FOR ALL TO bilko_app
USING (
invoice_id IN (
SELECT id FROM invoices
WHERE organization_id = current_setting('app.current_org_id', true)::uuid
AND current_setting('app.current_org_id', true) IS NOT NULL
AND current_setting('app.current_org_id', true) <> ''
AND length(current_setting('app.current_org_id', true)) = 36
)
)
WITH CHECK (
invoice_id IN (
SELECT id FROM invoices
WHERE organization_id = current_setting('app.current_org_id', true)::uuid
AND current_setting('app.current_org_id', true) IS NOT NULL
AND current_setting('app.current_org_id', true) <> ''
AND length(current_setting('app.current_org_id', true)) = 36
)
);
-- ─── organizations ────────────────────────────────────────────────────────────────────────────
DROP POLICY org_isolation ON organizations;
CREATE POLICY org_isolation ON organizations
AS RESTRICTIVE FOR ALL TO bilko_app
USING (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE id = current_setting('app.current_org_id', true)::uuid
END
)
WITH CHECK (
CASE
WHEN current_setting('app.current_org_id', true) IS NULL
OR current_setting('app.current_org_id', true) = ''
OR length(current_setting('app.current_org_id', true)) <> 36
THEN false
ELSE id = current_setting('app.current_org_id', true)::uuid
END
);
6.5 Application-Layer Cleanup After Phase 2C
After V23 applies on production, ADR-005 is formally superseded. CodeCraft may begin
removing redundant WHERE org_id = :principalOrgId clauses from service methods. This
is NOT required immediately — removing them is a cleanup task, not a correctness task.```
The dual-layer`true` was intentional during the migration period.
Do NOT remove application-layer WHERE clauses from:
Test code (integration tests should still verify org scoping explicitly)Admin / superuser code paths (these bypass RLS and need the WHERE clause for correctness)Any query that explicitly needs cross-org access (job enumeration phase)
7. Rollback Procedure (Phase 2C)
If RESTRICTIVE policies cause production traffic degradation after Phase 2C applies:
7.1 Immediate Mitigation (< 5 minutes)
Revert RLSparameter to PERMISSIVE`current_setting()` withoutmakes removingit policiesreturn (fastest path — no Flyway involved):
-- Run as bilko_admin via Cloud SQL console or break-glass psql session:
-- Revert all RESTRICTIVE → PERMISSIVE without dropping policies
DO $$
DECLARE
t text;
tables text[] := ARRAY['invoices','expenses','transactions','bank_accounts',
'bank_transactions','accounts','contacts',
'invoice_items','organizations'];
BEGIN
FOREACH t IN ARRAY tables LOOP
EXECUTE format('DROP POLICY org_isolation ON %I', t);
END LOOP;
END $$;
-- Restore PERMISSIVE policies from V17 DDL.
-- The V17 PERMISSIVE policy text is the source of truth.
-- Copy-paste V17 CREATE POLICY blocks here.
-- NOTE: Do NOT drop and recreate ENABLE ROW LEVEL SECURITY — leave RLS enabled.
-- NOTE: Do NOT drop FORCE ROW LEVEL SECURITY — leave force enabled.
After this is applied: RLS is back in PERMISSIVE mode. Application-layer WHERE clauses
have NOT been removed yet (per §6.5 timing). The system is back to the Phase 2A dual-layer
state. Services are unaffected.
7.2 Root Cause Analysis Gate
Before re-attempting Phase 2C:
Identify the query or code path that failed (application logs, Cloud SQL slow query log)Determine if the failure was: (a) missingorgTransaction()call, (b) incorrect USING clause, (c) background job withoutbilko_jobrole, (d) monitoring query touching tenant tableFix the root cause — do NOT re-attempt Phase 2C until the failure is understood and the §6.3 fail-closed test for that scenario passesRun the full §6.3 checklist again from scratchReset the 30-day soak clock
7.3 Full Rollback (Flyway-based, if immediate mitigation insufficient)
If the PERMISSIVE revert does not stabilize the system (e.g., the issue is in FORCE RLS
behaviorNULL rather than PERMISSIVE/RESTRICTIVEthrow
distinction):
-- Full rollback — disable RLS entirely (emergency only, CEO approval required)
ALTER TABLE invoices DISABLE ROW LEVEL SECURITY;
ALTER TABLE invoice_items DISABLE ROW LEVEL SECURITY;
ALTER TABLE expenses DISABLE ROW LEVEL SECURITY;
ALTER TABLE transactions DISABLE ROW LEVEL SECURITY;
ALTER TABLE bank_transactions DISABLE ROW LEVEL SECURITY;
ALTER TABLE bank_accounts DISABLE ROW LEVEL SECURITY;
ALTER TABLE accounts DISABLE ROW LEVEL SECURITY;
ALTER TABLE contacts DISABLE ROW LEVEL SECURITY;
ALTER TABLE organizations DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS org_isolation ON invoices;
DROP POLICY IF EXISTS org_isolation ON invoice_items;
DROP POLICY IF EXISTS org_isolation ON expenses;
DROP POLICY IF EXISTS org_isolation ON transactions;
DROP POLICY IF EXISTS org_isolation ON bank_transactions;
DROP POLICY IF EXISTS org_isolation ON bank_accounts;
DROP POLICY IF EXISTS org_isolation ON accounts;
DROP POLICY IF EXISTS org_isolation ON contacts;
DROP POLICY IF EXISTS org_isolation ON organizations;
ZAKON: Full rollback (disabling RLS entirely) requires CEO approval. It returns
the system to a state where application-layer WHERE clauses are the sole isolation
mechanism — the Phase 2A state is permanently lost. A new Flyway migration (re-applying
V17) is required to restore it.
After full rollback, treat the situation as a new Phase 2A and restart the soak clock.
8. Alternatives Considered
Option A: Single RESTRICTIVE flip with no PERMISSIVE phase
Apply RESTRICTIVE policies immediately without Phase 2A (PERMISSIVE). Remove ADR-005
application-layer clauses at the same time.
Rejected: Any missing orgTransaction() call would result in a user seeing 0 rows
(silent data loss), which is harder to debug than a cross-tenant leak. The PERMISSIVE
phase ensures that missing context causes empty results that are caught in integration
tests while the application layer maintains correct behavior.
Option B: FORCE ROW LEVEL SECURITY on all tables including organizations
Apply FORCE on organizations as well, so bilko_admin cannot bypass RLS on that table.
Rejected: Flyway migrations and admin tooling legitimately need cross-org access to
organizations for onboarding, support, and migration operations. FORCE on organizations
would require all admin queries to set app.current_org_id — creating a coupling between
admin tooling and the RLS session variable mechanism. V17 explicitly omits FORCE on
organizations for this reason.
Option C: Use connection-level SET instead of SET LOCAL
Set app.current_org_id at connection checkout (once per connection from the pool)
rather than per-transaction via SET LOCAL.
Rejected: HikariCP connection pools reuse connections across requests. A connection-level
SET would persist across requests, meaning a subsequent request on the same connection
would inherit the previous request's org context ifwhen the variable is not reset.set.
## SET---
LOCALscopes4. automaticallyMigration Schedule
| Phase | Flyway Version | Target | Blocking |
| ---------- | --------------------------------------------------- | --------------------------- | ------------------------------------------- |
| Phase 1H.1 | V16: `organizations.country` NOT NULL + CHECK | HR enum expansion (ADR-015) | ADR-015 accepted |
| Phase 2A | V17: PERMISSIVE RLS + CoA table + FX rate precision | Stage only | CEO sign (this ADR) |
| Phase 2A | V17 seed: HR Kontni Plan data | Stage only | V17 + PluginHR.getChartOfAccountsDefaults() |
| Phase 2B | V18: audit log partitioning | Post-HR GA | Securion review |
| Phase 2C | V19: RESTRICTIVE + retire ADR-005 app scoping | Post-Securion audit | Securion audit pass + CEO sign |
All migrations use Flyway's expand/contract pattern. No migration modifies data in a way
that cannot be reversed by a subsequent compensating migration. Backward compatibility
is required across all rolling deployments.
---
## 5. Consequences
### 5.1 Positive
1. **Defence in depth.** Even if a developer introduces a missing `WHERE org_id` in a new
route, RLS at the database layer prevents cross-tenant data exposure.
2. **GDPR jurisdiction extraction.** With `country_code` on `logged_actions` (Phase 2B),
a request from Croatian DPA for "all data held on Croatian entities" is a single
partition query, not a full-table scan with a filter.
3. **Audit surface.** Securion can review one set of RLS policies rather than auditing
every application route for correct scoping.
4. **Option D readiness.** If ADR-023 §6 triggers (e.g., first paying HR customer), the
same RLS DDL applies to the transactionper-country boundary,databases whichwithout alignschange. naturallyMigration path is
not blocked by this ADR.
### 5.2 Negative
1. **Connection middleware requirement.** Every DB connection must set `app.current_org_id`
before any query. Forgetting this in a new service or background job will cause all
queries to return 0 rows (PERMISSIVE) or error (RESTRICTIVE). Mitigated by integration
tests that verify the context middleware fires.
2. **Flyway admin bypass.** Flyway and admin tooling must run as a role that bypasses RLS
(`bilko_admin` with Exposed'sBYPASSRLS). role transactionThis {}blockmust andbe eliminateskept tightly restricted — it is
a privilege escalation path.
3. **Phase 2A adds overhead.** Each query now evaluates an additional predicate. At current
scale (0 paying customers) the raceoverhead conditionis entirely.
Monitor
9.p95 Consequences
query 9.1latency
Positive
Phase 2C can proceed independently of HR GA timeline — all gate criteria are measurable during theafter Phase 2Asoakmigrationperiod.on Thestage.bilko_app###grant5.3matrixRisksremoves1. **GDPR data residency.** Croatian entity data in Cloud SQL europe-north1 (Finland) is legally compliant (EU/EEA). If a future HR DPA contract specifies Frankfurt, a regional migration is required. This ADR does not block that migration. 2. **RLS policy gap.** An incorrect USING clause (e.g., JOIN condition that broadens theambiguityscope)aroundcouldwhichexpose cross-tenant data. **Mitigation:** Securion audit before Phase 2C (RESTRICTIVE), automated rogue-rolehastestwhichinprivileges.CI.The3.immutability**Migrationenforcement on transactions/bank_transactions adds a defence-in-depth layer against ledger tampering.The service-account separation (bilko_admin/bilko_job/bilko_app/bilko_monitor) follows least-privilege.synchronization.** AcompromisedFlywayapplicationmigrationprocessfailurecannotmid-runrunleaves all markets degraded. All V17+ migrations must be backward-compatible and use expand/contract pattern. If V17 fails, rollback is: `DROP POLICY` + `ALTER TABLE ... DISABLE ROW LEVEL SECURITY`. --- ## 6. References | Reference | Path | Lines | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------- | | ADR-bilko-001 (ancestor draft, absorbed by this ADR) | `~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-001-multi-tenant-architecture.md` | 1–162 | | ADR-bilko-003 §Layer 3 (versioned CoA model) | `~/system/specs/bilko-multi-market-architecture-plan/ADR-bilko-003-market-abstraction-layers.md` | 122–143 | | ADR-023 §6 (single-DB migration triggers — not fired) | `docs/architecture/ADR-023-TRANSITIONAL-MULTI-MARKET-ROUTING.md` | 166–176 | | Plan v3 §4a (Option D not triggered — evidence) | `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md` | 100–108 | | Plan v3 §4c (RLS timing — PERMISSIVE before Phase 1H) | `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md` | 135–145 | | Plan v3 §4d (EU data residency does not block HR GA) | `~/system/specs/bilko-multi-market-architecture-plan-v3-2026-05-11.md` | 179–183 | | ADR-015 §2.1 (TaxJurisdiction enum — `country_code` values) | `docs/architecture/ADR-015-FOUR-JURISDICTION-PLUGIN.md` | §2.1 | | Test drift memo (cross-tenant 500 leaks, Round 12.1/12.5) | `~/.claude/projects/-Users-makinja/memory/project_bilko_test_strategy_drift_2026-05-10.md` | — | --- ## 7. Approval **Architecture status:** Accepted (Phase 0' ADR consolidation) **CEO sign status:** SIGNED 2026-05-11 — Phase 2A V17 Flywaymigrations.PERMISSIVE Themigrationfail-closedauthorizedchecklistforgives Securion a deterministic audit target.
9.2 Negative
Thebilko_jobrole with BYPASSRLS requires careful access control. Ifbilko_jobcredentials leak, an attacker can enumerate all org IDs.Thestage. Phase 2C RESTRICTIVE fliprequiresremainsre-runninggated on Securion audit + 30-day soak per §4 schedule. This ADR records the§6.3architecturalchecklistdecision.ifThePhaseCEO2Csignature below isrolled back — this means a minimum of 30 additional days of soak before re-attempting.Thetransactionsandbank_transactionsUPDATE/DELETE revoke (§2.2) will cause unexpected permission errors if any future feature requires amending a posted transaction. Correction must be via compensating journal entries.
10. Approval
Architecture status: Accepted
Phase 2C CEO sign status: REQUIRED SEPARATELY — Phase 2C flipthe gate requiresfor
explicitexecution CEO sign at the time the §6.1 criteria are met. The ADR-017-RLS-MULTITENANCY.md sign
(2026-05-11) coversof Phase 2A only.
| not a gate for writing this document or for Phase 1H code work (CountryPlugin, PluginHR, DI wiring). | Role | | Sign | | Date |
|---|---|---|
| ------------------------------------------------------------- | ---------- | | Architecture Lead (Petter Graff) | Signed | 2026-05-11 | | Database Architecture (Bruce Momjian) | | Signed | | 2026-05- | 11
| CEO (Alem Bašić) | **SIGNED — | transcript
11.8. Document History
| Change | ||
|---|---|---|
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-04-22 | ALAI / ADR-bilko-001 | Initial draft (multi-tenant architecture options analysis) |
| 2026-05- | 11 | Bruce Momjian |