<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Souf CH</title>
    <description>The latest articles on DEV Community by Souf CH (@chafian).</description>
    <link>https://dev.to/chafian</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4219965%2Fd9d5f7da-8a92-41d2-b2d2-60b8c16d898e.png</url>
      <title>DEV Community: Souf CH</title>
      <link>https://dev.to/chafian</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chafian"/>
    <language>en</language>
    <item>
      <title>Exploring tenant isolation with Spring Boot, OIDC, and PostgreSQL RLS</title>
      <dc:creator>Souf CH</dc:creator>
      <pubDate>Sun, 01 Nov 2026 04:25:38 +0000</pubDate>
      <link>https://dev.to/chafian/exploring-tenant-isolation-with-spring-boot-oidc-and-postgresql-rls-3e22</link>
      <guid>https://dev.to/chafian/exploring-tenant-isolation-with-spring-boot-oidc-and-postgresql-rls-3e22</guid>
      <description>&lt;p&gt;A multi-tenant API needs to answer two separate questions: who is making the request, and what may that identity do inside the selected tenant?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/chafian/spring-boot-saas-starter" rel="noopener noreferrer"&gt;Spring Boot SaaS Starter&lt;/a&gt; is an open-source backend MVP I maintain to make those boundaries easier to inspect. It provides a Projects API with tenant membership, role checks, optimistic concurrency, and audit records.&lt;/p&gt;

&lt;p&gt;The current stack uses Java 25, Spring Boot 4.1.2, PostgreSQL, JDBC, and Flyway. Docker Compose includes Keycloak for local development.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication establishes identity
&lt;/h2&gt;

&lt;p&gt;The API accepts bearer tokens through Spring Security’s JWT resource server support. Its configuration validates the signature and issuer, requires an audience match, and checks for a nonblank subject and an expiration claim.&lt;/p&gt;

&lt;p&gt;The subject identifies the caller. Memberships associate that subject with a tenant and a role in the application database.&lt;/p&gt;

&lt;p&gt;This distinction matters: a valid token alone does not establish membership in a particular tenant. The tenant UUID in a request selects the tenant to access; authorization still happens in the service layer.&lt;/p&gt;

&lt;p&gt;See the implementation in &lt;a href="https://github.com/chafian/spring-boot-saas-starter/blob/main/src/main/java/io/github/chafian/saas/security/SecurityConfig.java" rel="noopener noreferrer"&gt;SecurityConfig.java&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Follow the tenant boundary into the database
&lt;/h2&gt;

&lt;p&gt;The starter uses a shared schema with complementary application and database checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A transactional service checks and locks the caller’s membership row.&lt;/li&gt;
&lt;li&gt;It verifies the role required for the operation.&lt;/li&gt;
&lt;li&gt;It sets transaction-local database context for the tenant.&lt;/li&gt;
&lt;li&gt;Queries include explicit tenant predicates.&lt;/li&gt;
&lt;li&gt;PostgreSQL enforces row-level security on projects and audit events.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://github.com/chafian/spring-boot-saas-starter/blob/main/src/main/java/io/github/chafian/saas/tenancy/TenantAccess.java" rel="noopener noreferrer"&gt;TenantAccess.java&lt;/a&gt; activates the context using a parameterized query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final argument makes the setting local to the transaction. The design therefore avoids leaving tenant state attached to a pooled connection after the transaction finishes.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/chafian/spring-boot-saas-starter/blob/main/src/main/resources/db/migration/V1__tenant_foundation.sql" rel="noopener noreferrer"&gt;database migration&lt;/a&gt; enables and forces row-level security on both protected tables. Policies compare each row’s &lt;code&gt;tenant_id&lt;/code&gt; with the current transaction’s tenant setting, including checks on writes.&lt;/p&gt;

&lt;p&gt;Tenant and membership tables have a different role: they support onboarding and membership discovery across tenants. Their authorization boundary is enforced in services.&lt;/p&gt;

&lt;p&gt;The application also uses restricted runtime database credentials, separate from schema migration credentials. Startup checks reject unsafe database privileges or missing RLS enforcement.&lt;/p&gt;

&lt;p&gt;These layers help defend against accidentally omitted tenant predicates. They do not make a compromised application or arbitrary SQL execution harmless. The &lt;a href="https://github.com/chafian/spring-boot-saas-starter/blob/main/docs/architecture.md" rel="noopener noreferrer"&gt;architecture guide&lt;/a&gt; explains that boundary explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep permissions specific to each tenant
&lt;/h2&gt;

&lt;p&gt;The role model is deliberately small:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;OWNER&lt;/th&gt;
&lt;th&gt;ADMIN&lt;/th&gt;
&lt;th&gt;MEMBER&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read projects and list members&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create, update, or delete projects&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read audit events&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manage non-owner memberships&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An authenticated identity can create a tenant and becomes its owner. Ownership transfer and owner removal are outside this MVP.&lt;/p&gt;

&lt;p&gt;A caller who is not a tenant member receives &lt;code&gt;404&lt;/code&gt; for tenant resources. A member who lacks the required role receives &lt;code&gt;403&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make conflicting writes explicit
&lt;/h2&gt;

&lt;p&gt;Projects carry a version number. Updates match the tenant, project ID, and expected version, then increment the version. Deletions also require the expected version.&lt;/p&gt;

&lt;p&gt;For example, after updating version &lt;code&gt;0&lt;/code&gt; successfully, a deletion that still supplies version &lt;code&gt;0&lt;/code&gt; receives &lt;code&gt;409 Conflict&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The client should reload the project and reconcile changes before retrying. Automatically substituting the latest version would defeat the purpose of detecting a stale write.&lt;/p&gt;

&lt;p&gt;Project mutations and their audit records occur within the same transaction. See &lt;a href="https://github.com/chafian/spring-boot-saas-starter/blob/main/src/main/java/io/github/chafian/saas/project/ProjectService.java" rel="noopener noreferrer"&gt;ProjectService.java&lt;/a&gt; and the &lt;a href="https://github.com/chafian/spring-boot-saas-starter/blob/main/docs/api.md#local-curl-walkthrough" rel="noopener noreferrer"&gt;API walkthrough&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the local example
&lt;/h2&gt;

&lt;p&gt;The README’s container-based path requires Git, a POSIX shell, Docker Engine with Compose v2, OpenSSL, and Python 3.9+. On Windows, use WSL with Docker access. Ports &lt;code&gt;8080&lt;/code&gt;, &lt;code&gt;8081&lt;/code&gt;, and &lt;code&gt;5432&lt;/code&gt; must be available.&lt;/p&gt;

&lt;p&gt;A host JDK is unnecessary for this path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/chafian/spring-boot-saas-starter.git
&lt;span class="nb"&gt;cd &lt;/span&gt;spring-boot-saas-starter
./scripts/init-env.sh
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
python3 scripts/smoke.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The setup script generates local credentials. The smoke script waits for readiness, signs in as the demo identities, and exercises the API.&lt;/p&gt;

&lt;p&gt;It checks that an outsider cannot access the tenant’s projects, that adding the identity as a MEMBER enables reading, and that the same member still cannot create projects. It also exercises updates, stale-write rejection, deletion, audit events, and requests without authentication.&lt;/p&gt;

&lt;p&gt;Successful completion produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS: real OIDC login, tenant isolation, membership, RBAC, CRUD, conflicts, and audit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Windows/WSL troubleshooting:&lt;/strong&gt; On my machine, Docker could not bind host port &lt;code&gt;5432&lt;/code&gt;. I changed the PostgreSQL port mapping in &lt;code&gt;compose.yml&lt;/code&gt; from &lt;code&gt;127.0.0.1:5432:5432&lt;/code&gt; to &lt;code&gt;127.0.0.1:5433:5432&lt;/code&gt;, then reran &lt;code&gt;docker compose up -d&lt;/code&gt;. The application’s internal database connection remained unchanged, and the smoke test passed.&lt;/p&gt;

&lt;p&gt;Each run creates a fictional tenant and retains its audit history. Use &lt;code&gt;docker compose down&lt;/code&gt; to stop the services while preserving the database volume.&lt;/p&gt;

&lt;p&gt;Keycloak runs in development mode and is ephemeral. Follow the README’s reset guidance if it is recreated, because identity subjects may change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test and extend the example
&lt;/h2&gt;

&lt;p&gt;For development, install JDK 25 and &lt;code&gt;unzip&lt;/code&gt;, keep Docker running, and use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./mvnw verify
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; unittest discover &lt;span class="nt"&gt;-s&lt;/span&gt; scripts &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'test_*.py'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The documented Java verification path uses a real PostgreSQL Testcontainer. CI is configured to run verification and the Compose smoke example.&lt;/p&gt;

&lt;p&gt;To add another tenant-owned resource, follow the &lt;a href="https://github.com/chafian/spring-boot-saas-starter/blob/main/docs/extending.md" rel="noopener noreferrer"&gt;extension guide&lt;/a&gt; and preserve the membership, transaction, tenant-context, and database-policy boundaries.&lt;/p&gt;

&lt;p&gt;The current scope excludes billing, a frontend, email invitations, tenant deletion, and ownership transfer. Production deployment also requires environment-specific work such as TLS, production identity configuration, tenant-creation quotas or rate limits, backups, monitoring, and secret management. The local Keycloak setup is a development convenience.&lt;/p&gt;

&lt;p&gt;If you work with Java SaaS backends, I’d welcome feedback on the isolation model and the setup experience.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/chafian/spring-boot-saas-starter" rel="noopener noreferrer"&gt;Explore the repository and try the example&lt;/a&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>postgres</category>
      <category>security</category>
    </item>
  </channel>
</rss>
