You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by di...@apache.org on 2026/07/14 18:28:10 UTC

(polaris) branch main updated: fix(authz): include realm identifier in OPA authorization input for tenant isolation (#4992)

This is an automated email from the ASF dual-hosted git repository.

dimas-b pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git


The following commit(s) were added to refs/heads/main by this push:
     new d0a8dff401 fix(authz): include realm identifier in OPA authorization input for tenant isolation (#4992)
d0a8dff401 is described below

commit d0a8dff401e30cab1df3ca6d0e133816e80e9c10
Author: vignesh_A <16...@users.noreply.github.com>
AuthorDate: Tue Jul 14 23:58:06 2026 +0530

    fix(authz): include realm identifier in OPA authorization input for tenant isolation (#4992)
    
    * fix(authz): include realm identifier in OPA authorization input for tenant isolation
    
    The OPA authorizer was not receiving any realm/tenant information.
    This meant that for the same principal/role/resource names, the
    authorization input sent to OPA was identical across realms (except
    for the random request_id).
    
    - Add optional 'realm' field to the Context model
    - Thread realm from RealmContext in OpaPolarisAuthorizerFactory
      (same pattern as RangerPolarisAuthorizerFactory)
    - Update authorizer to include it in buildContext()
    - Regenerate schema + update test/docs/CHANGELOG
    
    This fixes cross-realm authorization confusion when using OPA
    with multi-realm + shared policy.
    
    * fix(authz): make OPA realm required and inject via constructor
---
 CHANGELOG.md                                       |   2 +-
 extensions/auth/opa/opa-input-schema.json          |   4 +
 .../extension/auth/opa/OpaPolarisAuthorizer.java   |  10 +-
 .../auth/opa/OpaPolarisAuthorizerFactory.java      |  15 +-
 .../polaris/extension/auth/opa/model/Context.java  |   8 ++
 .../polaris/extension/auth/opa/model/README.md     |   1 +
 .../auth/opa/OpaPolarisAuthorizerFactoryTest.java  | 157 ++++++++++++++++++++-
 .../auth/opa/OpaPolarisAuthorizerTest.java         | 154 +++++++++++++++++---
 8 files changed, 326 insertions(+), 25 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19c2c3163c..221d952843 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -61,6 +61,7 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
 - Async task execution (table cleanup, manifest and batch file cleanup) now retries when a handler returns false on transient errors (e.g. IO or delete failures). Previously `false` was swallowed with only a warning log and the task was never retried via the existing retry mechanism.
 - `TableCleanupTaskHandler` now clamps `TABLE_METADATA_CLEANUP_BATCH_SIZE` to at least 1. Previously a non-positive realm override caused an infinite loop (0) or `IllegalArgumentException` (<0) when splitting metadata files for cleanup.
 - `ManifestFileCleanupTaskHandler` now handles Iceberg v2 delete manifests in addition to data manifests. Previously, `DROP TABLE PURGE` on a v2 table that had been updated via merge-on-read DML left position-delete files and their manifests as orphans in object storage; the cleanup task failed silently because `ManifestFiles.read()` rejects delete manifests.
+- OPA authorizer now includes the realm identifier in the authorization context sent to OPA (`input.context.realm`). This ensures OPA policies can enforce tenant isolation across realms, preventing potential collisions if identical principal or resource names exist in different realms.
 
 ## [1.6.0]
 
@@ -99,7 +100,6 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
 - The token broker now builds the JWT `Algorithm` and `JWTVerifier` once per realm in the `TokenBrokerFactory` and reuses them across requests, instead of rebuilding them on every `verify()`/`sign()` call on the request-scoped broker. For deployments using file-based symmetric secrets, the secret is now read once per realm (at first use) rather than on every JWT operation; rotating the on-disk secret requires a restart.
 
 ### Fixes
-
 - Fixed a boundary condition in GCS downscoped credential generation (`GcpCredentialsStorageIntegration`). Locations without a trailing slash could previously grant access to sibling object prefixes via the generated CEL conditions for `resource.name` and list prefixes. Granted paths are now normalized to a directory prefix (with a trailing slash) before the CEL conditions are built, so sibling prefixes can no longer satisfy the `startsWith` checks.
 - Fixed `NullPointerException` during `dropEntity` when an entity referenced by a grant had been concurrently removed (or purged). `lookupEntities` can return null entries for dropped entities; these are now skipped safely.
 - `RateLimiterFilter` now returns an Iceberg-compatible `ErrorResponse` JSON body on HTTP 429, with `Content-Type: application/json`. Previously the body was empty, causing Iceberg REST clients to surface an opaque error.
diff --git a/extensions/auth/opa/opa-input-schema.json b/extensions/auth/opa/opa-input-schema.json
index a5fd615ac2..0a5b22d18f 100644
--- a/extensions/auth/opa/opa-input-schema.json
+++ b/extensions/auth/opa/opa-input-schema.json
@@ -69,6 +69,10 @@
         "request_id" : {
           "type" : "string",
           "required" : true
+        },
+        "realm" : {
+          "type" : "string",
+          "required" : true
         }
       }
     }
diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java
index 7799bb22a5..a2664f5e9a 100644
--- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java
+++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java
@@ -85,9 +85,10 @@ class OpaPolarisAuthorizer implements PolarisAuthorizer {
   private final BearerTokenProvider tokenProvider;
   private final CloseableHttpClient httpClient;
   private final ObjectMapper objectMapper;
+  private final String realm;
 
   /**
-   * Public constructor that accepts a complete policy URI.
+   * Public constructor that accepts a complete policy URI and the current realm identifier.
    *
    * @param policyUri The required URI for the OPA endpoint. For example, {@code
    *     https://opa.example.com/v1/polaris/allow}.
@@ -96,17 +97,20 @@ class OpaPolarisAuthorizer implements PolarisAuthorizer {
    * @param objectMapper Jackson ObjectMapper for JSON serialization (required). Shared across
    *     authorizer instances to avoid initialization overhead.
    * @param tokenProvider Token provider for authentication (optional)
+   * @param realm The realm identifier (from RealmContext) for isolation in OPA policies.
    */
   public OpaPolarisAuthorizer(
       @NonNull URI policyUri,
       @NonNull CloseableHttpClient httpClient,
       @NonNull ObjectMapper objectMapper,
-      @Nullable BearerTokenProvider tokenProvider) {
+      @Nullable BearerTokenProvider tokenProvider,
+      @NonNull String realm) {
 
     this.policyUri = policyUri;
     this.tokenProvider = tokenProvider;
     this.httpClient = httpClient;
     this.objectMapper = objectMapper;
+    this.realm = realm;
   }
 
   /**
@@ -334,7 +338,7 @@ class OpaPolarisAuthorizer implements PolarisAuthorizer {
   }
 
   private ImmutableContext buildContext() {
-    return ImmutableContext.builder().requestId(UUID.randomUUID().toString()).build();
+    return ImmutableContext.builder().requestId(UUID.randomUUID().toString()).realm(realm).build();
   }
 
   private ImmutableResource buildResource(
diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java
index e7e3810cb4..a0c3bef247 100644
--- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java
+++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactory.java
@@ -34,6 +34,7 @@ import org.apache.hc.client5.http.impl.classic.HttpClients;
 import org.apache.polaris.core.auth.PolarisAuthorizer;
 import org.apache.polaris.core.auth.PolarisAuthorizerFactory;
 import org.apache.polaris.core.config.RealmConfig;
+import org.apache.polaris.core.context.RealmContext;
 import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider;
 import org.apache.polaris.extension.auth.opa.token.FileBearerTokenProvider;
 import org.apache.polaris.extension.auth.opa.token.StaticBearerTokenProvider;
@@ -52,15 +53,20 @@ class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory {
   private final Clock clock;
   private final ObjectMapper objectMapper;
   private final AsyncExec asyncExec;
+  private final RealmContext realmContext;
   private CloseableHttpClient httpClient;
   private BearerTokenProvider bearerTokenProvider;
 
   @Inject
   public OpaPolarisAuthorizerFactory(
-      OpaAuthorizationConfig opaConfig, Clock clock, AsyncExec asyncExec) {
+      OpaAuthorizationConfig opaConfig,
+      Clock clock,
+      AsyncExec asyncExec,
+      RealmContext realmContext) {
     this.opaConfig = opaConfig;
     this.clock = clock;
     this.asyncExec = asyncExec;
+    this.realmContext = realmContext;
     this.objectMapper = JsonMapper.builder().build();
   }
 
@@ -96,7 +102,12 @@ class OpaPolarisAuthorizerFactory implements PolarisAuthorizerFactory {
                     new IllegalStateException(
                         "OPA policy URI must be configured via polaris.authorization.opa.policy-uri"));
 
-    return new OpaPolarisAuthorizer(policyUri, httpClient, objectMapper, bearerTokenProvider);
+    return new OpaPolarisAuthorizer(
+        policyUri,
+        httpClient,
+        objectMapper,
+        bearerTokenProvider,
+        realmContext.getRealmIdentifier());
   }
 
   @PreDestroy
diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/model/Context.java b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/model/Context.java
index ad3ded12ff..c87c36b630 100644
--- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/model/Context.java
+++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/model/Context.java
@@ -36,4 +36,12 @@ import org.apache.polaris.immutables.PolarisImmutable;
 public interface Context {
   /** A unique identifier for correlating this request with OPA server logs. */
   String requestId();
+
+  /**
+   * The realm identifier for this authorization request.
+   *
+   * <p>This enables OPA policies to enforce isolation when the same principal names or resource
+   * names exist across different realms (multi-realm deployments using a shared OPA policy).
+   */
+  String realm();
 }
diff --git a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/model/README.md b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/model/README.md
index 8c3bd5ee9c..5df7637253 100644
--- a/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/model/README.md
+++ b/extensions/auth/opa/src/main/java/org/apache/polaris/extension/auth/opa/model/README.md
@@ -66,6 +66,7 @@ Individual resource with hierarchical context:
 ### Context
 Request metadata:
 - `request_id`: Unique correlation ID for logging
+- `realm`: Realm identifier (allows OPA policies to scope decisions by realm)
 
 ## Schema Evolution
 
diff --git a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java
index 59e4420b7d..16175fa156 100644
--- a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java
+++ b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerFactoryTest.java
@@ -19,15 +19,31 @@
 package org.apache.polaris.extension.auth.opa;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
 import static org.mockito.Mockito.mock;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
 import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
 import java.net.URI;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.time.Clock;
 import java.time.Duration;
+import java.util.Map;
+import java.util.Set;
+import org.apache.polaris.core.auth.PolarisAuthorizableOperation;
+import org.apache.polaris.core.auth.PolarisPrincipal;
 import org.apache.polaris.core.config.RealmConfig;
+import org.apache.polaris.core.context.RealmContext;
+import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper;
 import org.apache.polaris.extension.auth.opa.token.FileBearerTokenProvider;
 import org.apache.polaris.nosql.async.java.JavaPoolAsyncExec;
 import org.junit.jupiter.api.Test;
@@ -62,8 +78,9 @@ public class OpaPolarisAuthorizerFactoryTest {
             .build();
 
     try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) {
+      RealmContext realmContext = () -> "test-realm";
       OpaPolarisAuthorizerFactory factory =
-          new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec);
+          new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec, realmContext);
 
       // Create authorizer
       RealmConfig realmConfig = mock(RealmConfig.class);
@@ -106,8 +123,9 @@ public class OpaPolarisAuthorizerFactoryTest {
             .build();
 
     try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) {
+      RealmContext realmContext = () -> "test-realm";
       OpaPolarisAuthorizerFactory factory =
-          new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec);
+          new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec, realmContext);
 
       // Create authorizer
       RealmConfig realmConfig = mock(RealmConfig.class);
@@ -151,8 +169,9 @@ public class OpaPolarisAuthorizerFactoryTest {
             .build();
 
     try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) {
+      RealmContext realmContext = () -> "test-realm";
       OpaPolarisAuthorizerFactory factory =
-          new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec);
+          new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec, realmContext);
 
       // Create authorizer
       RealmConfig realmConfig = mock(RealmConfig.class);
@@ -161,4 +180,136 @@ public class OpaPolarisAuthorizerFactoryTest {
       assertThat(authorizer).isNotNull();
     }
   }
+
+  @Test
+  public void testFactoryPassesRealmToAuthorizerContext() throws Exception {
+    final String[] capturedRequestBody = new String[1];
+
+    HttpServer server = createServerWithRequestCapture(capturedRequestBody);
+    try {
+      OpaAuthorizationConfig opaConfig =
+          ImmutableOpaAuthorizationConfig.builder()
+              .policyUri(
+                  URI.create(
+                      "http://localhost:"
+                          + server.getAddress().getPort()
+                          + "/v1/data/polaris/allow"))
+              .auth(
+                  ImmutableAuthenticationConfig.builder()
+                      .type(OpaAuthorizationConfig.AuthenticationType.NONE)
+                      .build())
+              .http(
+                  ImmutableHttpConfig.builder()
+                      .timeout(Duration.ofSeconds(2))
+                      .verifySsl(true)
+                      .build())
+              .build();
+
+      try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) {
+        RealmContext realmContext = () -> "factory-realm";
+        OpaPolarisAuthorizerFactory factory =
+            new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec, realmContext);
+
+        factory.initialize();
+        OpaPolarisAuthorizer authorizer =
+            (OpaPolarisAuthorizer) factory.create(mock(RealmConfig.class));
+
+        PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin"));
+        assertThatNoException()
+            .isThrownBy(
+                () ->
+                    authorizer.authorizeOrThrow(
+                        principal,
+                        Set.of(),
+                        PolarisAuthorizableOperation.GET_CATALOG,
+                        (PolarisResolvedPathWrapper) null,
+                        (PolarisResolvedPathWrapper) null));
+
+        ObjectMapper mapper = JsonMapper.builder().build();
+        JsonNode root = mapper.readTree(capturedRequestBody[0]);
+        assertThat(root.path("input").path("context").get("realm").asText())
+            .isEqualTo("factory-realm");
+        factory.cleanup();
+      }
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  public void testFactoryUsesDistinctRealmValues() throws Exception {
+    final String[] capturedRequestBody = new String[1];
+
+    HttpServer server = createServerWithRequestCapture(capturedRequestBody);
+    try {
+      OpaAuthorizationConfig opaConfig =
+          ImmutableOpaAuthorizationConfig.builder()
+              .policyUri(
+                  URI.create(
+                      "http://localhost:"
+                          + server.getAddress().getPort()
+                          + "/v1/data/polaris/allow"))
+              .auth(
+                  ImmutableAuthenticationConfig.builder()
+                      .type(OpaAuthorizationConfig.AuthenticationType.NONE)
+                      .build())
+              .http(
+                  ImmutableHttpConfig.builder()
+                      .timeout(Duration.ofSeconds(2))
+                      .verifySsl(true)
+                      .build())
+              .build();
+
+      try (JavaPoolAsyncExec asyncExec = new JavaPoolAsyncExec()) {
+        RealmContext realmContext = () -> "realm-b";
+        OpaPolarisAuthorizerFactory factory =
+            new OpaPolarisAuthorizerFactory(opaConfig, Clock.systemUTC(), asyncExec, realmContext);
+
+        factory.initialize();
+        OpaPolarisAuthorizer authorizer =
+            (OpaPolarisAuthorizer) factory.create(mock(RealmConfig.class));
+
+        PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin"));
+        assertThatNoException()
+            .isThrownBy(
+                () ->
+                    authorizer.authorizeOrThrow(
+                        principal,
+                        Set.of(),
+                        PolarisAuthorizableOperation.GET_CATALOG,
+                        (PolarisResolvedPathWrapper) null,
+                        (PolarisResolvedPathWrapper) null));
+
+        ObjectMapper mapper = JsonMapper.builder().build();
+        JsonNode root = mapper.readTree(capturedRequestBody[0]);
+        assertThat(root.path("input").path("context").get("realm").asText()).isEqualTo("realm-b");
+        factory.cleanup();
+      }
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  private HttpServer createServerWithRequestCapture(String[] capturedRequestBody)
+      throws IOException {
+    HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+    server.createContext(
+        "/v1/data/polaris/allow",
+        new HttpHandler() {
+          @Override
+          public void handle(HttpExchange exchange) throws IOException {
+            byte[] requestBytes = exchange.getRequestBody().readAllBytes();
+            capturedRequestBody[0] = new String(requestBytes, StandardCharsets.UTF_8);
+
+            String response = "{\"result\":{\"allow\":true}}";
+            exchange.getResponseHeaders().add("Content-Type", "application/json");
+            exchange.sendResponseHeaders(200, response.length());
+            try (OutputStream os = exchange.getResponseBody()) {
+              os.write(response.getBytes(StandardCharsets.UTF_8));
+            }
+          }
+        });
+    server.start();
+    return server;
+  }
 }
diff --git a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java
index bfae693671..8e236d8393 100644
--- a/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java
+++ b/extensions/auth/opa/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java
@@ -87,7 +87,11 @@ public class OpaPolarisAuthorizerTest {
               "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
       OpaPolarisAuthorizer authorizer =
           new OpaPolarisAuthorizer(
-              policyUri, HttpClients.createDefault(), JsonMapper.builder().build(), null);
+              policyUri,
+              HttpClients.createDefault(),
+              JsonMapper.builder().build(),
+              null,
+              "test-realm");
 
       PolarisPrincipal principal =
           PolarisPrincipal.of("eve", Map.of("department", "finance"), Set.of("auditor"));
@@ -115,6 +119,10 @@ public class OpaPolarisAuthorizerTest {
       assertThat(input.has("action")).as("Input should have 'action' field").isTrue();
       assertThat(input.has("resource")).as("Input should have 'resource' field").isTrue();
       assertThat(input.has("context")).as("Input should have 'context' field").isTrue();
+
+      // Verify realm is included for tenant isolation
+      assertThat(input.get("context").has("realm")).as("context should contain realm").isTrue();
+      assertThat(input.get("context").get("realm").asText()).isEqualTo("test-realm");
     } finally {
       server.stop(0);
     }
@@ -132,7 +140,11 @@ public class OpaPolarisAuthorizerTest {
               "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
       OpaPolarisAuthorizer authorizer =
           new OpaPolarisAuthorizer(
-              policyUri, HttpClients.createDefault(), JsonMapper.builder().build(), null);
+              policyUri,
+              HttpClients.createDefault(),
+              JsonMapper.builder().build(),
+              null,
+              "prod-realm");
 
       // Set up a realistic principal
       PolarisPrincipal principal =
@@ -208,6 +220,10 @@ public class OpaPolarisAuthorizerTest {
       assertThat(input.has("resource")).as("Input should have 'resource' field").isTrue();
       assertThat(input.has("context")).as("Input should have 'context' field").isTrue();
 
+      // Verify realm for tenant isolation
+      assertThat(input.get("context").has("realm")).isTrue();
+      assertThat(input.get("context").get("realm").asText()).isEqualTo("prod-realm");
+
       // Verify actor details
       var actor = input.get("actor");
       assertThat(actor.has("principal")).as("Actor should have 'principal' field").isTrue();
@@ -263,7 +279,11 @@ public class OpaPolarisAuthorizerTest {
               "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
       OpaPolarisAuthorizer authorizer =
           new OpaPolarisAuthorizer(
-              policyUri, HttpClients.createDefault(), JsonMapper.builder().build(), null);
+              policyUri,
+              HttpClients.createDefault(),
+              JsonMapper.builder().build(),
+              null,
+              "analytics-realm");
 
       // Set up a realistic principal
       PolarisPrincipal principal =
@@ -351,6 +371,10 @@ public class OpaPolarisAuthorizerTest {
       assertThat(input.has("resource")).as("Input should have 'resource' field").isTrue();
       assertThat(input.has("context")).as("Input should have 'context' field").isTrue();
 
+      // Verify realm for tenant isolation
+      assertThat(input.get("context").has("realm")).isTrue();
+      assertThat(input.get("context").get("realm").asText()).isEqualTo("analytics-realm");
+
       // Verify actor details
       var actor = input.get("actor");
       assertThat(actor.get("principal").asText()).isEqualTo("bob");
@@ -427,7 +451,11 @@ public class OpaPolarisAuthorizerTest {
               "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
       OpaPolarisAuthorizer authorizer =
           new OpaPolarisAuthorizer(
-              policyUri, HttpClients.createDefault(), JsonMapper.builder().build(), null);
+              policyUri,
+              HttpClients.createDefault(),
+              JsonMapper.builder().build(),
+              null,
+              "test-realm");
 
       PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin"));
 
@@ -473,7 +501,11 @@ public class OpaPolarisAuthorizerTest {
     URI policyUri = URI.create("http://opa.example.com:8181/v1/data/polaris/allow");
     OpaPolarisAuthorizer authorizer =
         new OpaPolarisAuthorizer(
-            policyUri, HttpClients.createDefault(), JsonMapper.builder().build(), tokenProvider);
+            policyUri,
+            HttpClients.createDefault(),
+            JsonMapper.builder().build(),
+            tokenProvider,
+            "test-realm");
 
     assertThat(authorizer).isNotNull();
   }
@@ -492,7 +524,8 @@ public class OpaPolarisAuthorizerTest {
             policyUri,
             mock(CloseableHttpClient.class),
             JsonMapper.builder().build(),
-            tokenProvider) {
+            tokenProvider,
+            "test-realm") {
           @Override
           <T> T httpClientExecute(
               ClassicHttpRequest request, HttpClientResponseHandler<? extends T> responseHandler)
@@ -535,7 +568,8 @@ public class OpaPolarisAuthorizerTest {
             policyUri,
             mock(CloseableHttpClient.class),
             JsonMapper.builder().build(),
-            tokenProvider) {
+            tokenProvider,
+            "test-realm") {
           @Override
           <T> T httpClientExecute(
               ClassicHttpRequest request, HttpClientResponseHandler<? extends T> responseHandler)
@@ -573,7 +607,8 @@ public class OpaPolarisAuthorizerTest {
             URI.create("http://opa.example.com:8181/v1/data/polaris/allow"),
             mock(CloseableHttpClient.class),
             JsonMapper.builder().build(),
-            null);
+            null,
+            "test-realm");
     PolarisResolutionManifest resolutionManifest = mock(PolarisResolutionManifest.class);
     AuthorizationState authzState = new AuthorizationState(resolutionManifest);
     PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("role-1"));
@@ -600,7 +635,8 @@ public class OpaPolarisAuthorizerTest {
             URI.create("http://opa.example.com:8181/v1/data/polaris/allow"),
             mock(CloseableHttpClient.class),
             JsonMapper.builder().build(),
-            null) {
+            null,
+            "test-realm") {
           @Override
           <T> T httpClientExecute(
               ClassicHttpRequest request, HttpClientResponseHandler<? extends T> responseHandler)
@@ -648,7 +684,8 @@ public class OpaPolarisAuthorizerTest {
             URI.create("http://opa.example.com:8181/v1/data/polaris/allow"),
             mock(CloseableHttpClient.class),
             JsonMapper.builder().build(),
-            null) {
+            null,
+            "test-realm") {
           @Override
           <T> T httpClientExecute(
               ClassicHttpRequest request, HttpClientResponseHandler<? extends T> responseHandler)
@@ -695,7 +732,8 @@ public class OpaPolarisAuthorizerTest {
             URI.create("http://opa.example.com:8181/v1/data/polaris/allow"),
             mock(CloseableHttpClient.class),
             JsonMapper.builder().build(),
-            null) {
+            null,
+            "catalog-realm") {
           @Override
           <T> T httpClientExecute(
               ClassicHttpRequest request, HttpClientResponseHandler<? extends T> responseHandler)
@@ -711,6 +749,7 @@ public class OpaPolarisAuthorizerTest {
     JsonNode root = mapper.readTree(capturedRequestBody[0]);
 
     assertThat(decision.isAllowed()).isTrue();
+    assertThat(root.path("input").path("context").get("realm").asText()).isEqualTo("catalog-realm");
     JsonNode expectedTarget =
         mapper.readTree(
             """
@@ -739,7 +778,11 @@ public class OpaPolarisAuthorizerTest {
               "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
       OpaPolarisAuthorizer authorizer =
           new OpaPolarisAuthorizer(
-              policyUri, HttpClients.createDefault(), JsonMapper.builder().build(), null);
+              policyUri,
+              HttpClients.createDefault(),
+              JsonMapper.builder().build(),
+              null,
+              "test-realm");
 
       PolarisEntity rootEntity =
           new PolarisEntity.Builder()
@@ -808,7 +851,11 @@ public class OpaPolarisAuthorizerTest {
               "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
       OpaPolarisAuthorizer authorizer =
           new OpaPolarisAuthorizer(
-              policyUri, HttpClients.createDefault(), JsonMapper.builder().build(), null);
+              policyUri,
+              HttpClients.createDefault(),
+              JsonMapper.builder().build(),
+              null,
+              "test-realm");
 
       PolarisEntity rootEntity =
           new PolarisEntity.Builder()
@@ -879,7 +926,8 @@ public class OpaPolarisAuthorizerTest {
             URI.create("http://opa.example.com:8181/v1/data/polaris/allow"),
             mock(CloseableHttpClient.class),
             JsonMapper.builder().build(),
-            null) {
+            null,
+            "test-realm") {
           @Override
           <T> T httpClientExecute(
               ClassicHttpRequest request, HttpClientResponseHandler<? extends T> responseHandler)
@@ -944,7 +992,8 @@ public class OpaPolarisAuthorizerTest {
             URI.create("http://opa.example.com:8181/v1/data/polaris/allow"),
             mock(CloseableHttpClient.class),
             JsonMapper.builder().build(),
-            null) {
+            null,
+            "test-realm") {
           @Override
           <T> T httpClientExecute(
               ClassicHttpRequest request, HttpClientResponseHandler<? extends T> responseHandler)
@@ -1030,7 +1079,8 @@ public class OpaPolarisAuthorizerTest {
             URI.create("http://opa.example.com:8181/v1/data/polaris/allow"),
             mock(CloseableHttpClient.class),
             JsonMapper.builder().build(),
-            null) {
+            null,
+            "test-realm") {
           @Override
           <T> T httpClientExecute(
               ClassicHttpRequest request, HttpClientResponseHandler<? extends T> responseHandler)
@@ -1055,6 +1105,78 @@ public class OpaPolarisAuthorizerTest {
     assertThat(requestCount[0]).isEqualTo(2);
   }
 
+  @Test
+  void serializesInputWithRealm() throws Exception {
+    final String[] capturedRequestBody = new String[1];
+
+    HttpServer server = createServerWithRequestCapture(capturedRequestBody);
+    try {
+      URI policyUri =
+          URI.create(
+              "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
+      OpaPolarisAuthorizer authorizer =
+          new OpaPolarisAuthorizer(
+              policyUri,
+              HttpClients.createDefault(),
+              JsonMapper.builder().build(),
+              null,
+              "explicit-realm");
+
+      PolarisPrincipal principal = PolarisPrincipal.of("eve", Map.of(), Set.of("auditor"));
+
+      assertThatNoException()
+          .isThrownBy(
+              () ->
+                  authorizer.authorizeOrThrow(
+                      principal,
+                      Set.of(),
+                      PolarisAuthorizableOperation.GET_CATALOG,
+                      (PolarisResolvedPathWrapper) null,
+                      (PolarisResolvedPathWrapper) null));
+
+      ObjectMapper mapper = JsonMapper.builder().build();
+      JsonNode root = mapper.readTree(capturedRequestBody[0]);
+      var context = root.path("input").path("context");
+      assertThat(context.has("request_id")).isTrue();
+      assertThat(context.path("realm").asText()).isEqualTo("explicit-realm");
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  void serializesRealmInAuthorizePath() throws Exception {
+    final String[] capturedRequestBody = new String[1];
+
+    HttpServer server = createServerWithRequestCapture(capturedRequestBody);
+    try {
+      URI policyUri =
+          URI.create(
+              "http://localhost:" + server.getAddress().getPort() + "/v1/data/polaris/allow");
+      OpaPolarisAuthorizer authorizer =
+          new OpaPolarisAuthorizer(
+              policyUri,
+              HttpClients.createDefault(),
+              JsonMapper.builder().build(),
+              null,
+              "tenant-xyz");
+
+      PolarisResolutionManifest resolutionManifest = mock(PolarisResolutionManifest.class);
+      AuthorizationState authzState = new AuthorizationState(resolutionManifest);
+
+      PolarisPrincipal principal = PolarisPrincipal.of("alice", Map.of(), Set.of("admin"));
+      AuthorizationRequest request = requestWithCatalogTarget(principal);
+
+      assertThatNoException().isThrownBy(() -> authorizer.authorize(authzState, request));
+
+      ObjectMapper mapper = JsonMapper.builder().build();
+      JsonNode root = mapper.readTree(capturedRequestBody[0]);
+      assertThat(root.path("input").path("context").get("realm").asText()).isEqualTo("tenant-xyz");
+    } finally {
+      server.stop(0);
+    }
+  }
+
   private AuthorizationRequest requestWithCatalogTarget(PolarisPrincipal principal) {
     return new AuthorizationRequest(
         principal,