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

snazy 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 4083ac1ee Use POJOs for OPA JSON schema construction and publish 
schema (#3031)
4083ac1ee is described below

commit 4083ac1ee4dc6c2888d364362dcf1deda9be1b64
Author: Sung Yun <[email protected]>
AuthorDate: Mon Nov 17 12:42:29 2025 -0500

    Use POJOs for OPA JSON schema construction and publish schema (#3031)
    
    Co-authored-by: Robert Stupp <[email protected]>
---
 extensions/auth/opa/impl/SCHEMA.md                 | 125 +++++++++++++++
 extensions/auth/opa/impl/build.gradle.kts          | 103 +++++++++++++
 extensions/auth/opa/impl/opa-input-schema.json     |  76 +++++++++
 .../auth/opa/model/OpaSchemaGenerator.java         |  78 ++++++++++
 .../extension/auth/opa/OpaPolarisAuthorizer.java   | 169 +++++++++++----------
 .../polaris/extension/auth/opa/model/Actor.java    |  43 ++++++
 .../polaris/extension/auth/opa/model/Context.java  |  39 +++++
 .../auth/opa/model/OpaAuthorizationInput.java      |  50 ++++++
 .../extension/auth/opa/model/OpaRequest.java       |  39 +++++
 .../polaris/extension/auth/opa/model/README.md     |  80 ++++++++++
 .../polaris/extension/auth/opa/model/Resource.java |  46 ++++++
 .../extension/auth/opa/model/ResourceEntity.java   |  52 +++++++
 .../extension/auth/opa/model/package-info.java     |  54 +++++++
 .../auth/opa/OpaPolarisAuthorizerTest.java         |  17 +--
 14 files changed, 879 insertions(+), 92 deletions(-)

diff --git a/extensions/auth/opa/impl/SCHEMA.md 
b/extensions/auth/opa/impl/SCHEMA.md
new file mode 100644
index 000000000..ffebd5002
--- /dev/null
+++ b/extensions/auth/opa/impl/SCHEMA.md
@@ -0,0 +1,125 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+ 
+   http://www.apache.org/licenses/LICENSE-2.0
+ 
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+# OPA Input Schema Management
+
+This document describes how the OPA authorization input schema is managed in 
Apache Polaris.
+
+## Overview
+
+The OPA input schema follows a **schema-as-code** approach where:
+
+1. **Java model classes** (in `model/` package) are the single source of truth
+2. **JSON Schema** is automatically generated from these classes
+3. **CI validation** ensures the schema stays in sync with the code
+
+## Developer Workflow
+
+### Modifying the Schema
+
+When you need to add/modify fields in the OPA input:
+
+1. **Update the model classes** in 
`src/main/java/org/apache/polaris/extension/auth/opa/model/`
+   ```java
+   @PolarisImmutable
+   public interface Actor {
+     String principal();
+     List<String> roles();
+     // Add new field here
+   }
+   ```
+
+2. **Regenerate the JSON Schema**
+   ```bash
+   ./gradlew :polaris-extensions-auth-opa:generateOpaSchema
+   ```
+
+3. **Commit both changes**
+   - The updated Java files
+   - The updated `opa-input-schema.json`
+
+4. **CI will validate** that the schema matches the code
+
+### CI Validation
+
+The `validateOpaSchema` task automatically runs during `./gradlew check`:
+
+```bash
+./gradlew :polaris-extensions-auth-opa:check
+```
+
+This task:
+1. Generates schema from current code to a temp file
+2. Compares it with the committed `opa-input-schema.json`
+3. **Fails the build** if they don't match
+
+#### What happens if validation fails?
+
+You'll see an error like:
+
+```
+❌ OPA Schema validation failed!
+
+The committed opa-input-schema.json does not match the generated schema.
+This means the schema is out of sync with the model classes.
+
+To fix this, run:
+  ./gradlew :polaris-extensions-auth-opa:generateOpaSchema
+
+Then commit the updated opa-input-schema.json file.
+```
+
+Simply run the suggested command and commit the regenerated schema.
+
+## Gradle Tasks
+
+### `generateOpaSchema`
+Generates the JSON Schema from model classes.
+
+```bash
+./gradlew :polaris-extensions-auth-opa:generateOpaSchema
+```
+
+**Output**: `extensions/auth/opa/impl/opa-input-schema.json`
+
+### `validateOpaSchema`
+Validates that committed schema matches the code.
+
+```bash
+./gradlew :polaris-extensions-auth-opa:validateOpaSchema
+```
+
+**Runs automatically** as part of `:check` task.
+
+## For OPA Policy Developers
+
+The generated `opa-input-schema.json` documents the structure of authorization 
requests sent from Polaris to OPA.
+
+## Model Classes Reference
+
+| Class | Purpose | Key Fields |
+|-------|---------|------------|
+| `OpaRequest` | Top-level wrapper | `input` |
+| `OpaAuthorizationInput` | Complete auth context | `actor`, `action`, 
`resource`, `context` |
+| `Actor` | Principal information | `principal`, `roles` |
+| `Resource` | Resources being accessed | `targets`, `secondaries` |
+| `ResourceEntity` | Individual resource | `type`, `name`, `parents` |
+| `Context` | Request metadata | `request_id` |
+
+See the [model package 
README](src/main/java/org/apache/polaris/extension/auth/opa/model/README.md) 
for detailed usage examples.
diff --git a/extensions/auth/opa/impl/build.gradle.kts 
b/extensions/auth/opa/impl/build.gradle.kts
index 9dd95259d..740e5e105 100644
--- a/extensions/auth/opa/impl/build.gradle.kts
+++ b/extensions/auth/opa/impl/build.gradle.kts
@@ -17,11 +17,15 @@
  * under the License.
  */
 
+import java.io.OutputStream
+
 plugins {
   id("polaris-server")
   id("org.kordamp.gradle.jandex")
 }
 
+val jsonSchemaGenerator = sourceSets.create("jsonSchemaGenerator")
+
 dependencies {
   implementation(project(":polaris-core"))
   implementation(libs.apache.httpclient5)
@@ -33,6 +37,13 @@ dependencies {
   implementation(libs.auth0.jwt)
   implementation(project(":polaris-async-api"))
 
+  add(jsonSchemaGenerator.implementationConfigurationName, 
project(":polaris-extensions-auth-opa"))
+  add(jsonSchemaGenerator.implementationConfigurationName, 
platform(libs.jackson.bom))
+  add(
+    jsonSchemaGenerator.implementationConfigurationName,
+    "com.fasterxml.jackson.module:jackson-module-jsonSchema",
+  )
+
   // Iceberg dependency for ForbiddenException
   implementation(platform(libs.iceberg.bom))
   implementation("org.apache.iceberg:iceberg-api")
@@ -58,3 +69,95 @@ dependencies {
   testImplementation(project(":polaris-async-java"))
   testImplementation(project(":polaris-idgen-mocks"))
 }
+
+// Task to generate JSON Schema from model classes
+tasks.register<JavaExec>("generateOpaSchema") {
+  group = "documentation"
+  description = "Generates JSON Schema for OPA authorization input"
+
+  dependsOn(tasks.compileJava, tasks.named("jandex"))
+
+  // Only execute generation if anything changed
+  outputs.cacheIf { true }
+  outputs.file("${projectDir}/opa-input-schema.json")
+  inputs.files(jsonSchemaGenerator.runtimeClasspath)
+
+  classpath = jsonSchemaGenerator.runtimeClasspath
+  
mainClass.set("org.apache.polaris.extension.auth.opa.model.OpaSchemaGenerator")
+  args("${projectDir}/opa-input-schema.json")
+}
+
+// Task to validate that the committed schema matches the generated schema
+tasks.register<JavaExec>("validateOpaSchema") {
+  group = "verification"
+  description = "Validates that the committed OPA schema matches the generated 
schema"
+
+  dependsOn(tasks.compileJava, tasks.named("jandex"))
+
+  val tempSchemaFile = 
layout.buildDirectory.file("opa-schema/opa-input-schema-generated.json")
+  val committedSchemaFile = file("${projectDir}/opa-input-schema.json")
+  val logFile = layout.buildDirectory.file("opa-schema/generator.log")
+
+  // Only execute validation if anything changed
+  outputs.cacheIf { true }
+  outputs.file(tempSchemaFile)
+  inputs.file(committedSchemaFile)
+  inputs.files(jsonSchemaGenerator.runtimeClasspath)
+
+  classpath = jsonSchemaGenerator.runtimeClasspath
+  
mainClass.set("org.apache.polaris.extension.auth.opa.model.OpaSchemaGenerator")
+  args(tempSchemaFile.get().asFile.absolutePath)
+  isIgnoreExitValue = true
+
+  var outStream: OutputStream? = null
+  doFirst {
+    // Ensure temp directory exists
+    tempSchemaFile.get().asFile.parentFile.mkdirs()
+    outStream = logFile.get().asFile.outputStream()
+    standardOutput = outStream
+    errorOutput = outStream
+  }
+
+  doLast {
+    outStream?.close()
+
+    if (executionResult.get().exitValue != 0) {
+      throw GradleException(
+        """
+        |OPA Schema validation failed!
+        |
+        |${logFile.get().asFile.readText()}
+      """
+          .trimMargin()
+      )
+    }
+
+    val generatedContent = tempSchemaFile.get().asFile.readText().trim()
+    val committedContent = committedSchemaFile.readText().trim()
+
+    if (generatedContent != committedContent) {
+      throw GradleException(
+        """
+        |OPA Schema validation failed!
+        |
+        |The committed opa-input-schema.json does not match the generated 
schema.
+        |This means the schema is out of sync with the model classes.
+        |
+        |To fix this, run:
+        |  ./gradlew :polaris-extensions-auth-opa:generateOpaSchema
+        |
+        |Then commit the updated opa-input-schema.json file.
+        |
+        |Committed file: ${committedSchemaFile.absolutePath}
+        |Generated file: ${tempSchemaFile.get().asFile.absolutePath}
+      """
+          .trimMargin()
+      )
+    }
+
+    logger.info("OPA schema validation passed - schema is up to date")
+  }
+}
+
+// Add schema validation to the check task
+tasks.named("check") { dependsOn("validateOpaSchema") }
diff --git a/extensions/auth/opa/impl/opa-input-schema.json 
b/extensions/auth/opa/impl/opa-input-schema.json
new file mode 100644
index 000000000..a5fd615ac
--- /dev/null
+++ b/extensions/auth/opa/impl/opa-input-schema.json
@@ -0,0 +1,76 @@
+{
+  "type" : "object",
+  "id" : 
"urn:jsonschema:org:apache:polaris:extension:auth:opa:model:OpaAuthorizationInput",
+  "properties" : {
+    "actor" : {
+      "type" : "object",
+      "id" : 
"urn:jsonschema:org:apache:polaris:extension:auth:opa:model:Actor",
+      "required" : true,
+      "properties" : {
+        "principal" : {
+          "type" : "string",
+          "required" : true
+        },
+        "roles" : {
+          "type" : "array",
+          "items" : {
+            "type" : "string"
+          }
+        }
+      }
+    },
+    "action" : {
+      "type" : "string",
+      "required" : true
+    },
+    "resource" : {
+      "type" : "object",
+      "id" : 
"urn:jsonschema:org:apache:polaris:extension:auth:opa:model:Resource",
+      "required" : true,
+      "properties" : {
+        "targets" : {
+          "type" : "array",
+          "items" : {
+            "type" : "object",
+            "id" : 
"urn:jsonschema:org:apache:polaris:extension:auth:opa:model:ResourceEntity",
+            "properties" : {
+              "type" : {
+                "type" : "string",
+                "required" : true
+              },
+              "name" : {
+                "type" : "string",
+                "required" : true
+              },
+              "parents" : {
+                "type" : "array",
+                "items" : {
+                  "type" : "object",
+                  "$ref" : 
"urn:jsonschema:org:apache:polaris:extension:auth:opa:model:ResourceEntity"
+                }
+              }
+            }
+          }
+        },
+        "secondaries" : {
+          "type" : "array",
+          "items" : {
+            "type" : "object",
+            "$ref" : 
"urn:jsonschema:org:apache:polaris:extension:auth:opa:model:ResourceEntity"
+          }
+        }
+      }
+    },
+    "context" : {
+      "type" : "object",
+      "id" : 
"urn:jsonschema:org:apache:polaris:extension:auth:opa:model:Context",
+      "required" : true,
+      "properties" : {
+        "request_id" : {
+          "type" : "string",
+          "required" : true
+        }
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git 
a/extensions/auth/opa/impl/src/jsonSchemaGenerator/java/org/apache/polaris/extension/auth/opa/model/OpaSchemaGenerator.java
 
b/extensions/auth/opa/impl/src/jsonSchemaGenerator/java/org/apache/polaris/extension/auth/opa/model/OpaSchemaGenerator.java
new file mode 100644
index 000000000..be08c234b
--- /dev/null
+++ 
b/extensions/auth/opa/impl/src/jsonSchemaGenerator/java/org/apache/polaris/extension/auth/opa/model/OpaSchemaGenerator.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.model;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.module.jsonSchema.JsonSchema;
+import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * Utility to generate JSON Schema from the OPA input model classes.
+ *
+ * <p>This can be run as a standalone utility to generate the JSON Schema 
document that can be
+ * referenced in documentation and used by OPA policy developers.
+ *
+ * <p>Usage: java 
org.apache.polaris.extension.auth.opa.model.OpaSchemaGenerator 
[output-file-path]
+ */
+public class OpaSchemaGenerator {
+
+  /**
+   * Generates a JSON Schema for the OPA authorization input structure.
+   *
+   * @return the JSON Schema as a pretty-printed string
+   * @throws IOException if schema generation fails
+   */
+  public static String generateSchema() throws IOException {
+    ObjectMapper mapper = new ObjectMapper();
+    mapper.enable(SerializationFeature.INDENT_OUTPUT);
+
+    JsonSchemaGenerator schemaGen = new JsonSchemaGenerator(mapper);
+    JsonSchema schema = schemaGen.generateSchema(OpaAuthorizationInput.class);
+
+    return mapper.writeValueAsString(schema);
+  }
+
+  /**
+   * Main method to generate and save the JSON Schema to a file.
+   *
+   * @param args optional output file path (defaults to opa-input-schema.json)
+   */
+  public static void main(String[] args) throws IOException {
+    String schemaJson = generateSchema();
+
+    // Determine output path
+    Path outputPath;
+    if (args.length > 0) {
+      outputPath = Paths.get(args[0]);
+    } else {
+      outputPath = Paths.get("opa-input-schema.json");
+    }
+
+    // Write schema to file
+    Files.writeString(outputPath, schemaJson);
+    System.out.println("JSON Schema generated successfully at: " + 
outputPath.toAbsolutePath());
+    System.out.println();
+    System.out.println(schemaJson);
+  }
+}
diff --git 
a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java
 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java
index 346d49f0e..6c216121e 100644
--- 
a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java
+++ 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizer.java
@@ -19,15 +19,16 @@
 package org.apache.polaris.extension.auth.opa;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ArrayNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.google.common.annotations.VisibleForTesting;
 import jakarta.annotation.Nonnull;
 import jakarta.annotation.Nullable;
 import java.io.IOException;
 import java.net.URI;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
+import java.util.UUID;
 import org.apache.hc.client5.http.classic.methods.HttpPost;
 import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
 import org.apache.hc.core5.http.ClassicHttpRequest;
@@ -45,6 +46,13 @@ import org.apache.polaris.core.auth.PolarisAuthorizer;
 import org.apache.polaris.core.auth.PolarisPrincipal;
 import org.apache.polaris.core.entity.PolarisBaseEntity;
 import org.apache.polaris.core.persistence.PolarisResolvedPathWrapper;
+import org.apache.polaris.extension.auth.opa.model.ImmutableActor;
+import org.apache.polaris.extension.auth.opa.model.ImmutableContext;
+import 
org.apache.polaris.extension.auth.opa.model.ImmutableOpaAuthorizationInput;
+import org.apache.polaris.extension.auth.opa.model.ImmutableOpaRequest;
+import org.apache.polaris.extension.auth.opa.model.ImmutableResource;
+import org.apache.polaris.extension.auth.opa.model.ImmutableResourceEntity;
+import org.apache.polaris.extension.auth.opa.model.ResourceEntity;
 import org.apache.polaris.extension.auth.opa.token.BearerTokenProvider;
 
 /**
@@ -211,8 +219,8 @@ class OpaPolarisAuthorizer implements PolarisAuthorizer {
   /**
    * Builds the OPA input JSON for the authorization query.
    *
-   * <p>Assembles the actor, action, resource, and context sections into the 
expected OPA input
-   * format.
+   * <p>Uses type-safe model classes to construct the authorization input, 
ensuring consistency with
+   * the JSON schema.
    *
    * <p><strong>Note:</strong> OpaPolarisAuthorizer bypasses Polaris's 
built-in role-based
    * authorization system. This includes both principal roles and catalog 
roles that would normally
@@ -235,98 +243,93 @@ class OpaPolarisAuthorizer implements PolarisAuthorizer {
       List<PolarisResolvedPathWrapper> targets,
       List<PolarisResolvedPathWrapper> secondaries)
       throws IOException {
-    ObjectNode input = objectMapper.createObjectNode();
-    input.set("actor", buildActorNode(principal));
-    input.put("action", op.name());
-    input.set("resource", buildResourceNode(targets, secondaries));
-    input.set("context", buildContextNode());
-    ObjectNode root = objectMapper.createObjectNode();
-    root.set("input", input);
-    return objectMapper.writeValueAsString(root);
-  }
 
-  /**
-   * Builds the actor section of the OPA input JSON.
-   *
-   * <p>Includes principal name, and roles as a generic field.
-   *
-   * @param principal the principal requesting authorization
-   * @return the actor node for OPA input
-   */
-  private ObjectNode buildActorNode(PolarisPrincipal principal) {
-    ObjectNode actor = objectMapper.createObjectNode();
-    actor.put("principal", principal.getName());
-    ArrayNode roles = objectMapper.createArrayNode();
-    for (String role : principal.getRoles()) roles.add(role);
-    actor.set("roles", roles);
-    return actor;
-  }
+    // Build actor from principal
+    var actor =
+        ImmutableActor.builder()
+            .principal(principal.getName())
+            .addAllRoles(principal.getRoles())
+            .build();
 
-  /**
-   * Builds the resource section of the OPA input JSON.
-   *
-   * <p>Includes the main target entity under 'primary' and secondary entities 
under 'secondaries'.
-   *
-   * @param targets the list of main target entities
-   * @param secondaries the list of secondary entities
-   * @return the resource node for OPA input
-   */
-  private ObjectNode buildResourceNode(
-      List<PolarisResolvedPathWrapper> targets, 
List<PolarisResolvedPathWrapper> secondaries) {
-    ObjectNode resource = objectMapper.createObjectNode();
-    // Main targets as 'targets' array
-    ArrayNode targetsArray = objectMapper.createArrayNode();
-    if (targets != null && !targets.isEmpty()) {
-      for (PolarisResolvedPathWrapper targetWrapper : targets) {
-        targetsArray.add(buildSingleResourceNode(targetWrapper));
-      }
-    }
-    resource.set("targets", targetsArray);
-    // Secondaries as array
-    ArrayNode secondariesArray = objectMapper.createArrayNode();
-    if (secondaries != null && !secondaries.isEmpty()) {
-      for (PolarisResolvedPathWrapper secondaryWrapper : secondaries) {
-        secondariesArray.add(buildSingleResourceNode(secondaryWrapper));
+    // Build resource entities for targets
+    List<ResourceEntity> targetEntities = new ArrayList<>();
+    if (targets != null) {
+      for (PolarisResolvedPathWrapper target : targets) {
+        ResourceEntity entity = buildResourceEntity(target);
+        if (entity != null) {
+          targetEntities.add(entity);
+        }
       }
     }
-    resource.set("secondaries", secondariesArray);
-    return resource;
-  }
 
-  /** Helper to build a resource node for a single PolarisResolvedPathWrapper. 
*/
-  private ObjectNode buildSingleResourceNode(PolarisResolvedPathWrapper 
wrapper) {
-    ObjectNode node = objectMapper.createObjectNode();
-    if (wrapper == null) return node;
-    var resolvedEntity = wrapper.getResolvedLeafEntity();
-    if (resolvedEntity != null) {
-      var entity = resolvedEntity.getEntity();
-      node.put("type", entity.getType().name());
-      node.put("name", entity.getName());
-      var parentPath = wrapper.getResolvedParentPath();
-      if (parentPath != null && !parentPath.isEmpty()) {
-        ArrayNode parentsArray = objectMapper.createArrayNode();
-        for (var parent : parentPath) {
-          ObjectNode parentNode = objectMapper.createObjectNode();
-          parentNode.put("type", parent.getEntity().getType().name());
-          parentNode.put("name", parent.getEntity().getName());
-          parentsArray.add(parentNode);
+    // Build resource entities for secondaries
+    List<ResourceEntity> secondaryEntities = new ArrayList<>();
+    if (secondaries != null) {
+      for (PolarisResolvedPathWrapper secondary : secondaries) {
+        ResourceEntity entity = buildResourceEntity(secondary);
+        if (entity != null) {
+          secondaryEntities.add(entity);
         }
-        node.set("parents", parentsArray);
       }
     }
-    return node;
+
+    // Build resource
+    var resource =
+        
ImmutableResource.builder().targets(targetEntities).secondaries(secondaryEntities).build();
+
+    // Build context
+    var context = 
ImmutableContext.builder().requestId(UUID.randomUUID().toString()).build();
+
+    // Build complete authorization input
+    var input =
+        ImmutableOpaAuthorizationInput.builder()
+            .actor(actor)
+            .action(op.name())
+            .resource(resource)
+            .context(context)
+            .build();
+
+    // Wrap in OPA request
+    var request = ImmutableOpaRequest.builder().input(input).build();
+
+    return objectMapper.writeValueAsString(request);
   }
 
   /**
-   * Builds the context section of the OPA input JSON.
+   * Builds a resource entity from a resolved path wrapper.
    *
-   * <p>Includes a request ID for correlating OPA server requests with logs.
-   *
-   * @return the context node for OPA input
+   * @param wrapper the resolved path wrapper
+   * @return the resource entity, or null if wrapper is null or has no 
resolved entity
    */
-  private ObjectNode buildContextNode() {
-    ObjectNode context = objectMapper.createObjectNode();
-    context.put("request_id", java.util.UUID.randomUUID().toString());
-    return context;
+  @Nullable
+  private ResourceEntity buildResourceEntity(@Nullable 
PolarisResolvedPathWrapper wrapper) {
+    if (wrapper == null) {
+      return null;
+    }
+
+    var resolvedEntity = wrapper.getResolvedLeafEntity();
+    if (resolvedEntity == null) {
+      return null;
+    }
+
+    var entity = resolvedEntity.getEntity();
+    var builder =
+        
ImmutableResourceEntity.builder().type(entity.getType().name()).name(entity.getName());
+
+    // Build parent hierarchy
+    var parentPath = wrapper.getResolvedParentPath();
+    if (parentPath != null && !parentPath.isEmpty()) {
+      List<ResourceEntity> parents = new ArrayList<>();
+      for (var parent : parentPath) {
+        parents.add(
+            ImmutableResourceEntity.builder()
+                .type(parent.getEntity().getType().name())
+                .name(parent.getEntity().getName())
+                .build());
+      }
+      builder.parents(parents);
+    }
+
+    return builder.build();
   }
 }
diff --git 
a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/Actor.java
 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/Actor.java
new file mode 100644
index 000000000..6da3c5355
--- /dev/null
+++ 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/Actor.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.model;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import java.util.List;
+import org.apache.polaris.immutables.PolarisImmutable;
+
+/**
+ * Represents the actor (principal) making an authorization request.
+ *
+ * <p>Contains the principal identifier and associated roles.
+ */
+@PolarisImmutable
+@JsonSerialize(as = ImmutableActor.class)
+@JsonDeserialize(as = ImmutableActor.class)
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public interface Actor {
+  /** The principal name or identifier. */
+  String principal();
+
+  /** The list of roles associated with the principal. */
+  List<String> roles();
+}
diff --git 
a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/Context.java
 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/Context.java
new file mode 100644
index 000000000..ad3ded12f
--- /dev/null
+++ 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/Context.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.model;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import org.apache.polaris.immutables.PolarisImmutable;
+
+/**
+ * Additional context information for the authorization request.
+ *
+ * <p>Used for tracking and correlation purposes.
+ */
+@PolarisImmutable
+@JsonSerialize(as = ImmutableContext.class)
+@JsonDeserialize(as = ImmutableContext.class)
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public interface Context {
+  /** A unique identifier for correlating this request with OPA server logs. */
+  String requestId();
+}
diff --git 
a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/OpaAuthorizationInput.java
 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/OpaAuthorizationInput.java
new file mode 100644
index 000000000..fabcfdbd1
--- /dev/null
+++ 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/OpaAuthorizationInput.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.model;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import org.apache.polaris.immutables.PolarisImmutable;
+
+/**
+ * OPA authorization input structure.
+ *
+ * <p>This represents the authorization context sent to OPA for policy 
evaluation, containing
+ * information about who is making the request (actor), what they want to do 
(action), what
+ * resources are involved (resource), and additional request context.
+ */
+@PolarisImmutable
+@JsonSerialize(as = ImmutableOpaAuthorizationInput.class)
+@JsonDeserialize(as = ImmutableOpaAuthorizationInput.class)
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public interface OpaAuthorizationInput {
+  /** The actor making the authorization request. */
+  Actor actor();
+
+  /** The action being requested (e.g., "CREATE_NAMESPACE", "READ_TABLE"). */
+  String action();
+
+  /** The resource(s) being accessed. */
+  Resource resource();
+
+  /** Additional context about the request. */
+  Context context();
+}
diff --git 
a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/OpaRequest.java
 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/OpaRequest.java
new file mode 100644
index 000000000..ecd961671
--- /dev/null
+++ 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/OpaRequest.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.model;
+
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import org.apache.polaris.immutables.PolarisImmutable;
+
+/**
+ * OPA request wrapper containing the authorization input.
+ *
+ * <p>This is the top-level structure sent to OPA, containing the input object.
+ */
+@PolarisImmutable
+@JsonSerialize(as = ImmutableOpaRequest.class)
+@JsonDeserialize(as = ImmutableOpaRequest.class)
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public interface OpaRequest {
+  /** The authorization input to be evaluated by OPA. */
+  OpaAuthorizationInput input();
+}
diff --git 
a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/README.md
 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/README.md
new file mode 100644
index 000000000..d478e60f8
--- /dev/null
+++ 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/README.md
@@ -0,0 +1,80 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+ 
+   http://www.apache.org/licenses/LICENSE-2.0
+ 
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+-->
+
+# OPA Authorization Input Model
+
+This package contains the authoritative model for OPA authorization requests 
in Polaris.
+
+## Single Source of Truth
+
+The Java classes in this package serve as the **single source of truth** for 
the OPA input structure. The JSON Schema can be generated from these classes, 
ensuring consistency between code and documentation.
+
+## Generating the JSON Schema
+
+Run the Gradle task to regenerate the schema:
+
+```bash
+./gradlew :polaris-extensions-auth-opa:generateOpaSchema
+```
+
+The schema will be generated at: 
`extensions/auth/opa/impl/opa-input-schema.json`
+
+## Model Classes
+
+### OpaRequest
+Top-level wrapper sent to OPA containing the input.
+
+### OpaAuthorizationInput
+Complete authorization context with:
+- `actor`: Who is making the request
+- `action`: What they want to do
+- `resource`: What they want to access
+- `context`: Request metadata
+
+### Actor
+Principal information:
+- `principal`: User/service identifier
+- `roles`: List of assigned roles
+
+### Resource
+Resources involved in the operation:
+- `targets`: Primary resources being accessed
+- `secondaries`: Secondary resources (e.g., source in RENAME)
+
+### ResourceEntity
+Individual resource with hierarchical context:
+- `type`: Entity type (CATALOG, NAMESPACE, TABLE, etc.)
+- `name`: Entity name
+- `parents`: Hierarchical path of parent entities
+
+### Context
+Request metadata:
+- `request_id`: Unique correlation ID for logging
+
+## Schema Evolution
+
+When adding new fields:
+
+1. Add field to appropriate model interface
+2. Add Javadoc explaining the field
+3. Regenerate schema: `./gradlew 
:polaris-extensions-auth-opa:generateOpaSchema`
+4. Update OPA policies to handle new field
+5. Update documentation
+
+The schema generation ensures backward compatibility by making all new fields 
optional by default.
diff --git 
a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/Resource.java
 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/Resource.java
new file mode 100644
index 000000000..e761d7f63
--- /dev/null
+++ 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/Resource.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import java.util.List;
+import org.apache.polaris.immutables.PolarisImmutable;
+
+/**
+ * Represents the resource(s) being accessed in an authorization request.
+ *
+ * <p>Contains primary target entities and optional secondary entities.
+ */
+@PolarisImmutable
+@JsonSerialize(as = ImmutableResource.class)
+@JsonDeserialize(as = ImmutableResource.class)
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public interface Resource {
+  /** The primary target entities being accessed. */
+  @JsonInclude(JsonInclude.Include.NON_EMPTY)
+  List<ResourceEntity> targets();
+
+  /** Secondary entities involved in the operation (e.g., source table in 
RENAME). */
+  @JsonInclude(JsonInclude.Include.NON_EMPTY)
+  List<ResourceEntity> secondaries();
+}
diff --git 
a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/ResourceEntity.java
 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/ResourceEntity.java
new file mode 100644
index 000000000..9cd93c944
--- /dev/null
+++ 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/ResourceEntity.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.extension.auth.opa.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonNaming;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import java.util.List;
+import org.apache.polaris.immutables.PolarisImmutable;
+
+/**
+ * Represents a single resource entity in the authorization context.
+ *
+ * <p>Contains the entity type, name, and hierarchical parent path.
+ */
+@PolarisImmutable
+@JsonSerialize(as = ImmutableResourceEntity.class)
+@JsonDeserialize(as = ImmutableResourceEntity.class)
+@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
+public interface ResourceEntity {
+  /** The type of the resource (e.g., "CATALOG", "NAMESPACE", "TABLE"). */
+  String type();
+
+  /** The name of the resource. */
+  String name();
+
+  /**
+   * The hierarchical path of parent entities.
+   *
+   * <p>For example, a table might have parents: [catalog, namespace].
+   */
+  @JsonInclude(JsonInclude.Include.NON_NULL)
+  List<ResourceEntity> parents();
+}
diff --git 
a/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/package-info.java
 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/package-info.java
new file mode 100644
index 000000000..01d750f9b
--- /dev/null
+++ 
b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/package-info.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * OPA authorization input model classes.
+ *
+ * <p>This package contains immutable model classes that define the structure 
of authorization
+ * requests sent to Open Policy Agent (OPA). These classes serve as the single 
source of truth for
+ * the OPA input schema.
+ *
+ * <h2>Schema Generation</h2>
+ *
+ * <p>The JSON Schema for these models can be generated using the {@link
+ * org.apache.polaris.extension.auth.opa.model.OpaSchemaGenerator} utility or 
by running the Gradle
+ * task:
+ *
+ * <pre>{@code
+ * ./gradlew :polaris-extensions-auth-opa:generateOpaSchema
+ * }</pre>
+ *
+ * <p>This generates {@code opa-input-schema.json} which can be referenced in 
documentation and used
+ * by OPA policy developers.
+ *
+ * <h2>Model Structure</h2>
+ *
+ * <ul>
+ *   <li>{@link org.apache.polaris.extension.auth.opa.model.OpaRequest} - 
Top-level request wrapper
+ *   <li>{@link 
org.apache.polaris.extension.auth.opa.model.OpaAuthorizationInput} - 
Authorization
+ *       context containing actor, action, resource, and context
+ *   <li>{@link org.apache.polaris.extension.auth.opa.model.Actor} - Principal 
and roles
+ *   <li>{@link org.apache.polaris.extension.auth.opa.model.Resource} - Target 
and secondary
+ *       resources
+ *   <li>{@link org.apache.polaris.extension.auth.opa.model.ResourceEntity} - 
Individual resource
+ *       with type, name, and parents
+ *   <li>{@link org.apache.polaris.extension.auth.opa.model.Context} - Request 
metadata
+ * </ul>
+ */
+package org.apache.polaris.extension.auth.opa.model;
diff --git 
a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java
 
b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java
index 8042f7c41..7401004a8 100644
--- 
a/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java
+++ 
b/extensions/auth/opa/impl/src/test/java/org/apache/polaris/extension/auth/opa/OpaPolarisAuthorizerTest.java
@@ -211,9 +211,6 @@ public class OpaPolarisAuthorizerTest {
       // Verify resource structure - this is the key part for hierarchical 
resources
       var resource = input.get("resource");
       assertThat(resource.has("targets")).as("Resource should have 'targets' 
field").isTrue();
-      assertThat(resource.has("secondaries"))
-          .as("Resource should have 'secondaries' field")
-          .isTrue();
 
       var targets = resource.get("targets");
       assertThat(targets.isArray()).as("Targets should be an array").isTrue();
@@ -255,9 +252,10 @@ public class OpaPolarisAuthorizerTest {
           .as("Namespace name should be sales_data")
           .isEqualTo("sales_data");
 
-      var secondaries = resource.get("secondaries");
-      assertThat(secondaries.isArray()).as("Secondaries should be an 
array").isTrue();
-      assertThat(secondaries.size()).as("Should have no secondaries in this 
test").isEqualTo(0);
+      // Secondaries field should be omitted when empty (NON_EMPTY 
serialization)
+      assertThat(resource.has("secondaries"))
+          .as("Secondaries should be omitted when empty")
+          .isFalse();
     } finally {
       server.stop(0);
     }
@@ -421,9 +419,10 @@ public class OpaPolarisAuthorizerTest {
           .as("Team name should be machine_learning")
           .isEqualTo("machine_learning");
 
-      var secondaries = resource.get("secondaries");
-      assertThat(secondaries.isArray()).as("Secondaries should be an 
array").isTrue();
-      assertThat(secondaries.size()).as("Should have no secondaries in this 
test").isEqualTo(0);
+      // Secondaries field should be omitted when empty (NON_EMPTY 
serialization)
+      assertThat(resource.has("secondaries"))
+          .as("Secondaries should be omitted when empty")
+          .isFalse();
     } finally {
       server.stop(0);
     }


Reply via email to