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);
}