diff --git a/extensions/auth/opa/impl/SCHEMA.md b/extensions/auth/opa/impl/SCHEMA.md new file mode 100644 index 0000000000..ffebd5002b --- /dev/null +++ b/extensions/auth/opa/impl/SCHEMA.md @@ -0,0 +1,125 @@ + + +# 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 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 9dd95259d8..740e5e1056 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("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("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 0000000000..a5fd615ac2 --- /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 0000000000..be08c234bd --- /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. + * + *

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. + * + *

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 ab985abfea..828ef2a245 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,14 +19,15 @@ 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 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.client5.http.impl.classic.CloseableHttpResponse; @@ -41,6 +42,13 @@ 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; /** @@ -198,8 +206,8 @@ private boolean queryOpa( /** * Builds the OPA input JSON for the authorization query. * - *

Assembles the actor, action, resource, and context sections into the expected OPA input - * format. + *

Uses type-safe model classes to construct the authorization input, ensuring consistency with + * the JSON schema. * *

Note: OpaPolarisAuthorizer bypasses Polaris's built-in role-based * authorization system. This includes both principal roles and catalog roles that would normally @@ -222,98 +230,93 @@ private String buildOpaInputJson( List targets, List 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. - * - *

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. - * - *

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 targets, List 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 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 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. * - *

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 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 0000000000..6da3c5355b --- /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. + * + *

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 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 0000000000..ad3ded12ff --- /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. + * + *

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 0000000000..fabcfdbd19 --- /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. + * + *

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 0000000000..ecd961671a --- /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. + * + *

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 0000000000..d478e60f8b --- /dev/null +++ b/extensions/auth/opa/impl/src/main/java/org/apache/polaris/extension/auth/opa/model/README.md @@ -0,0 +1,80 @@ + + +# 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 0000000000..e761d7f636 --- /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. + * + *

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 targets(); + + /** Secondary entities involved in the operation (e.g., source table in RENAME). */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + List 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 0000000000..9cd93c9441 --- /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. + * + *

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. + * + *

For example, a table might have parents: [catalog, namespace]. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + List 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 0000000000..01d750f9bd --- /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. + * + *

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. + * + *

Schema Generation

+ * + *

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: + * + *

{@code
+ * ./gradlew :polaris-extensions-auth-opa:generateOpaSchema
+ * }
+ * + *

This generates {@code opa-input-schema.json} which can be referenced in documentation and used + * by OPA policy developers. + * + *

Model Structure

+ * + *
    + *
  • {@link org.apache.polaris.extension.auth.opa.model.OpaRequest} - Top-level request wrapper + *
  • {@link org.apache.polaris.extension.auth.opa.model.OpaAuthorizationInput} - Authorization + * context containing actor, action, resource, and context + *
  • {@link org.apache.polaris.extension.auth.opa.model.Actor} - Principal and roles + *
  • {@link org.apache.polaris.extension.auth.opa.model.Resource} - Target and secondary + * resources + *
  • {@link org.apache.polaris.extension.auth.opa.model.ResourceEntity} - Individual resource + * with type, name, and parents + *
  • {@link org.apache.polaris.extension.auth.opa.model.Context} - Request metadata + *
+ */ +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 1c359ade2a..1cdd9fc2cb 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 @@ void testOpaRequestJsonWithHierarchicalResource() throws Exception { // 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 @@ void testOpaRequestJsonWithHierarchicalResource() throws Exception { .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 @@ void testOpaRequestJsonWithMultiLevelNamespace() throws Exception { .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); }