Skip to main content

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_app role 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 diskcorrectorganizations.country§6 NOTsupersession NULLtriggers +not CHECK)
  • 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 enum drivingdrives country_code)
  • `country_code`
  • column CHECK values) - ADR-023 §6bilko-001 (single-DBancestor supersessiondraft, triggersfully absorbed by this ADRdo not firedreference asancestor) of- 2026-05-13)
  • ADR-bilko-003
  • §Layer 3 (versioned CoA data model) - Plan v3 §4a (Option D not triggered), §4c (RLS timing rationale)
PERMISSIVE
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 silentlydrops Round12probes
ItemState
organizations.country NOT NULL, CHECK constraintLIVE — V16 applied
PERMISSIVE RLS on 9 tables500 (organizations,test invoices,drift invoice_items,memo expenses,2026-05-10, transactions,Round bank_transactions,12.1/12.5) bank_accounts,| accounts,The contacts) LIVE — V17 applied
bilko_app role exists (created idempotently in V17 DO block)LIVE
FORCE ROW LEVEL SECURITY on 8 tables (all except organizations)LIVE
OrgScopePlugin + orgTransaction wrapperLIVE — OrgScopeSessionVariable.kt
Application-application-layer WHERE org_id clausesscoping (ADR-005) STILLis ACTIVEthe sole isolation mechanism. A single missing `WHERE org_id` clause in any new routedual-layeror pera Phaserefactoring 2Athat design
Phase 2C RESTRICTIVE flipNOT YETitgatedis ona thiscross-tenant documentdata +exposure. SecurionThis auditis +not 30dtheoretical: soak
confirmed

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 TOportable bilko_appto actuallyseparate fire.

databases.

2.1If Decision:Option ConnectionD Usertriggers, Grantsthe bilko_app

same

Thepolicy applicationDDL runtimeapplies connectsto aseach bilkoper-country DB with zero changes. ### 1.3 Why RLS Cannot Wait Until Post-HR GA Plan v3 §4c (orlines its135–145): Cloudthe SQLcross-tenant IAM500 SAleaks equivalent).are bilko_appa live security defect. With 0 paying customers today it is unexploited — but a markersecond 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

TableSELECTINSERTUPDATEDELETENotes
organizationsYESYESYESYESOrg creation at onboarding; UPDATE for settings; RLS restricts to own row
invoicesYESYESYESYESCore workflow; RLS restricts to org's rows
invoice_itemsYESYESYESYESSub-rows joined via invoices; RLS via subquery on invoices
expensesYESYESYESYESCore workflow
transactionsYESYESNONOTransactions are immutable once created; UPDATE/DELETE blocked at grant level for defence in depth
bank_transactionsYESYESNONOImported from bank feed; immutable after import
bank_accountsYESYESYESYESAccount management (add/remove accounts)
accountsYESYESYESYESChart of accounts; per-org customization
contactsYESYESYESYESCustomer/supplier management

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.

replacement.

2.3**Who Whatsigns bilko_appthis Doesoff:** NOTCEO Get

Alem
    Bašić
  • No(this TRUNCATEADR signature) — required before any Phase 2A Flyway migrations run on anythe tablestage database. **DDL — PERMISSIVE policies (preventsFlyway accidentalV17):** mass```sql deletion)
  • --
  • NoV17__rls_permissive.sql CREATE-- TABLE,ZAKON: DROPCEO TABLE,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 (schemaorganizations changesENABLE areROW Flyway-only)
  • LEVEL
  • NoSECURITY; accessALTER toTABLE logged_actionsinvoices tableENABLE directlyROW LEVEL auditSECURITY; insertsALTER goTABLE throughinvoice_items aENABLE triggerROW functionLEVEL ownedSECURITY; 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 by bilko_adminthe thatKtor firesconnection onpool DMLbefore to audited tables
  • No access to flyway_schema_history table
  • No 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.

belt-and-suspenders

3.1 Flyway (Schema Migrations)

Connection user: bilko_admin (Cloud SQL built-in superuser, or the cloudsqlsuperuseradmin role holdergrants 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:

  1. Cloud Buildmiddleware (CI/CDKotlin pipeline)Exposed / injectedHikariCP): as-- aOn Secreteach Managerconnection secretcheckout: at-- migration step
  2. Vaultwarden break-glass vault — for emergency direct access only
  3. Local .env.local for 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

RoleBYPASSRLSGrantsPurpose
bilko_adminYES (superuser)AllFlyway migrations only
bilko_jobYES (explicit)SELECT on organizationsBackground job enumeration
bilko_appNODML on 9 tenant tablesApplication runtime
bilko_monitorNONone on tenant tablesHealth/metrics monitoring
bilko_healthNONoneHealth check SELECT 1

4. Connection Pool Integration (OrgScopePlugin + HikariCP)

4.1 Current Implementation (on disk)

OrgScopeSessionVariable.kt (tool-verified 2026-05-13) implements:

  • OrgScopePlugin — Ktor ApplicationPlugin that stores org ID in ThreadLocal per request
  • orgTransaction(organizationId, block) — wraps Exposed transaction {} with SET LOCAL app.current_org_id
  • setOrgContext(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:

  1. Every orgTransaction {} call must set the variable — it cannot be set once at connection checkout and assumed to persist across multiple transactions.
  2. The HikariCP connection pool does not need to be modified or connection-tested for variable state — SET LOCAL ensures clean state on every connection return.
  3. Coroutine-based code must ensure orgTransaction is called on the same coroutine dispatcher as the database access (Dispatchers.IO for Exposed). The ThreadLocal in OrgScopePlugin is a request-lifecycle convenience read — the authoritative value is the one passed explicitly to orgTransaction.

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:

  1. Redundant (org-level isolation already prevents cross-org access)
  2. Dangerous (if country_code is NULL or migrated incorrectly, the policy would deny access to legitimate data)
  3. 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_idorg_id` clauses.middleware. ThisRLS is the highest-risksole migrationisolation step.

mechanism.

6.1 **Gate Criteriaconditions (all must be true before Phase 2C begins)

:**
    1.
  1. Securion audit completeof Phase 2A policies completed — no critical findings on2. PhaseAutomated 2A policies
  2. 30-day soak metric thresholds met — see §6.2
  3. Fail-closedrogue-role test suite passing in CI (Proveo — see §6.3
  4. CEO explicit sign-off — separate from the ADR-017-RLS-MULTITENANCY sign given on 2026-05-11 (that sign covered Phase 2A only;verification Phaseabove) 2C3. requiresZero a new sign)
  5. 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.

on
MetricThresholdHow to verify
Cross-cross-tenant RLS bypass incidents 0 Securion rogue-role probe results in CI; psql -c "SET ROLE bilko_app; SET LOCAL app.current_org_id = '<wrong_uuid>'; SELECT COUNT(*) FROM invoices;" must return 0
app.current_org_id unset errors0 per dayApplication logs: no [OrgScope] BilkoPrincipal.organizationId is not a valid UUID ERROR lines; no current_setting PG errors in Cloud SQL slow query log
FORCE RLS bypass attempts0Check pg_audit or Cloud SQL logsstage for SET30 ROLEconsecutive /days RESET4. ROLECEO commandsexplicit outsidesign-off of expected test sequences
RLS policy evaluation errors (p99)< 0.5ms added latencyEXPLAIN ANALYZE on top-10 query patterns before/after V17; p99 query latency per Cloud SQL insights
Application integration test suite100% passingCI green on every push to main; failing RLS tests = soak clock reset

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; (no SET 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}/pdf with cross-tenant JWT: HTTP 404
  •  GET /api/v1/invoices/{id} with own-tenant JWT: HTTP 200 with correct data
  •  Create 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) missing orgTransaction() call, (b) incorrect USING clause, (c) background job without bilko_job role, (d) monitoring query touching tenant table
  •  Fix the root cause — do NOT re-attempt Phase 2C until the failure is understood and the §6.3 fail-closed test for that scenario passes
  •  Run the full §6.3 checklist again from scratch
  •  Reset 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--- LOCAL## scopes4. 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). transactionThis {}role 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.

immeasurable.
Monitor

9.p95 Consequences

query

9.1latency Positive

  1. Phase 2C can proceed independently of HR GA timeline — all gate criteria are measurable during theafter Phase 2A soakmigration period.
  2. on
  3. Thestage. bilko_app### grant5.3 matrixRisks removes1. **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 the ambiguityscope) aroundcould whichexpose cross-tenant data. **Mitigation:** Securion audit before Phase 2C (RESTRICTIVE), automated rogue-role hastest whichin privileges.CI. The3. immutability**Migration enforcement on transactions/bank_transactions adds a defence-in-depth layer against ledger tampering.
  4. The service-account separation (bilko_admin/bilko_job/bilko_app/bilko_monitor) follows least-privilege.synchronization.** A compromisedFlyway applicationmigration processfailure cannotmid-run runleaves 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 Flyway migrations.
  5. PERMISSIVE
  6. Themigration fail-closedauthorized checklistfor gives Securion a deterministic audit target.

9.2 Negative

  1. The bilko_job role with BYPASSRLS requires careful access control. If bilko_job credentials leak, an attacker can enumerate all org IDs.
  2. Thestage. Phase 2C RESTRICTIVE flip requiresremains re-runninggated on Securion audit + 30-day soak per §4 schedule. This ADR records the §6.3architectural checklistdecision. ifThe PhaseCEO 2Csignature below is rolled back — this means a minimum of 30 additional days of soak before re-attempting.
  3. The transactions and bank_transactions UPDATE/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.

database migrations.Itis||-------------------------------------|11|| transcript2026-05-11|---##
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-13
Architecture Lead (Petter Graff)Pending
CEO (Alem Bašić) | **SIGNED Phasesession 2Cf73dafab, gate Required"ok atadr17 Phaseodobreno"** 2C| execution

11.8. Document History

|Date|Author|||----------|11017;DDLexamples
Date Author Change
---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 2026-04-22 | ALAI / ADR-bilko-001 | Initial draft (multi-tenant architecture options analysis) | | 2026-05-13 | Bruce Momjian Initial/ Petter MCGraff #100586| PhasePromoted 0'from forwardADR-bilko-001 decisionsdraft; supplementID changed to ADR-017-RLS-MULTITENANCY.md
added; versioned CoA DDL added; NUMERIC(20,10) FX precision noted; Phase 2B audit log partitioning added; connection middleware pattern added; CEO sign gate formalised. MC #100362. | | 2026-05-11 | John (AI Director) | CEO Alem Bašić signed ADR-017 via session f73dafab ("ok adr17 odobreno"). Phase 2A V17 Flyway PERMISSIVE migration authorized for stage. Status header + §7 approval table updated. Unblocks Bruce Momjian dispatch for Phase 2A. |