diff --git a/core/trino-server/src/main/provisio/trino.xml b/core/trino-server/src/main/provisio/trino.xml
index 2987eb5d00076..8a5fd01c0d0d1 100644
--- a/core/trino-server/src/main/provisio/trino.xml
+++ b/core/trino-server/src/main/provisio/trino.xml
@@ -325,4 +325,10 @@
+
+
+
+
+
+
diff --git a/plugin/trino-opa/README.md b/plugin/trino-opa/README.md
new file mode 100644
index 0000000000000..0f363f4d3ddf9
--- /dev/null
+++ b/plugin/trino-opa/README.md
@@ -0,0 +1,247 @@
+# trino-opa
+
+This plugin enables Trino to use Open Policy Agent (OPA) as an authorization engine.
+
+For more information on OPA, please refer to the Open Policy Agent [documentation](https://www.openpolicyagent.org/).
+
+> While every attempt will be made to keep backwards compatibility, this plugin is a recent addition
+> and as such the API may change.
+
+## Configuration
+
+You will need to configure Trino to use the OPA plugin as its access control engine, then configure the
+plugin to contact your OPA endpoint.
+
+`config.properties` - **enabling the plugin**:
+
+Make sure to enable the plugin by configuring Trino to pull in the relevant config file for the OPA
+authorizer, e.g.:
+
+```properties
+access-control.config-files=/etc/trino/access-control-file-based.properties,/etc/trino/access-control-opa.properties
+```
+
+`access-control-opa.properties` - **configuring the plugin**:
+
+Set the access control name to `opa` and specify the policy URI, for example:
+
+```properties
+access-control.name=opa
+opa.policy.uri=https://your-opa-endpoint/v1/data/allow
+```
+
+If you also want to enable the _batch_ mode (see [Batch mode](#batch-mode)), you must additionally set up an
+`opa.policy.batched-uri` configuration entry.
+
+> Batch mode is _not_ a replacement for the "main" URI. The batch mode is _only_
+> used for certain authorization queries where batching is applicable. Even when using
+> `opa.policy.batched-uri`, you _must_ still provide an `opa.policy.uri`
+
+For instance:
+
+```properties
+access-control.name=opa
+opa.policy.uri=https://your-opa-endpoint/v1/data/allow
+opa.policy.batched-uri=https://your-opa-endpoint/v1/data/batch
+```
+
+### All configuration entries
+
+| Configuration name | Required | Default | Description |
+|----------------------------------------------|:--------:|:-------:|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `opa.policy.uri` | Yes | N/A | Endpoint to query OPA |
+| `opa.policy.batched-uri` | No | Unset | Endpoint for batch OPA requests |
+| `opa.log-requests` | No | `false` | Determines whether requests (URI, headers and entire body) are logged prior to sending them to OPA |
+| `opa.log-responses` | No | `false` | Determines whether OPA responses (URI, status code, headers and entire body) are logged |
+| `opa.allow-permission-management-operations` | No | `false` | Determines whether permission / role management operations will be allowed. These operations will be allowed or denied based on this setting, no request is sent to OPA |
+| `opa.http-client.*` | No | Unset | Additional HTTP client configurations that get passed down. E.g. `opa.http-client.http-proxy` for configuring the HTTP proxy |
+
+> When request / response logging is enabled, they will be logged at DEBUG level under the `io.trino.plugin.opa.OpaHttpClient` logger, you will need to update
+> your log configuration accordingly.
+>
+> Be aware that enabling these options will produce very large amounts of logs
+
+##### About permission management operations
+
+The following operations are controlled by the `opa.allow-permission-management-operations` setting. If this setting is `true`, these
+operations will be allowed; they will otherwise be denied. No request is sent to OPA either way:
+
+- `GrantSchemaPrivilege`
+- `DenySchemaPrivilege`
+- `RevokeSchemaPrivilege`
+- `GrantTablePrivilege`
+- `DenyTablePrivilege`
+- `RevokeTablePrivilege`
+- `CreateRole`
+- `DropRole`
+- `GrantRoles`
+- `RevokeRoles`
+
+This is due to the complexity and potential unexpected consequences of having SQL-style grants / roles together with OPA, as per [discussion](https://github.com/trinodb/trino/pull/19532#discussion_r1380776593)
+on the initial PR.
+
+Additionally, users are always allowed to show information about roles (`SHOW ROLES`), regardless of this setting. The following operations are _always_ allowed:
+- `ShowRoles`
+- `ShowCurrentRoles`
+- `ShowRoleGrants`
+
+## OPA queries
+
+The plugin will contact OPA for each authorization request as defined on the SPI.
+
+OPA must return a response containing a boolean `allow` field, which will determine whether the operation
+is permitted or not.
+
+The plugin will pass as much context as possible within the OPA request. A simple way of checking
+what data is passed in from Trino is to run OPA locally in verbose mode.
+
+### Query structure
+
+A query will contain a `context` and an `action` as its top level fields.
+
+#### Query context:
+
+While the `action` object contains information about _what_ action is being performed, the `context` object
+contains all other contextual information about it. The `context` object contains the following fields:
+- `identity`: The identity of the user performing the operation, containing the following 2 fields:
+ - `user` (string): username
+ - `groups` (array of strings): list of groups this user belongs to
+- `softwareStack`: Information about the software stack running in the Trino server, more fields may be added later, currently:
+ - `trinoVersion` (string): Trino version
+
+#### Query action:
+
+This determines _what_ action is being performed and upon what resources, the top level fields are as follows:
+
+- `operation` (string): operation being performed
+- `resource` (object, nullable): information about the object being operated upon
+- `targetResource` (object, nullable): information about the _new object_ being created, if applicable
+- `grantee` (object, nullable): grantee of a grant operation.
+
+Fields that are not applicable for a specific operation (e.g. `targetResource` if not modifying a table/schema/catalog, or `grantee` if not granting
+permissions) will be set to null. Any null field will be omitted altogether from the `action` object.
+
+#### Examples
+
+Accessing a table will result in a query like the one below:
+
+```json
+{
+ "context": {
+ "identity": {
+ "user": "foo",
+ "groups": ["some-group"]
+ },
+ "softwareStack": {
+ "trinoVersion": "434"
+ }
+ },
+ "action": {
+ "operation": "SelectFromColumns",
+ "resource": {
+ "table": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema",
+ "tableName": "my_table",
+ "columns": [
+ "column1",
+ "column2",
+ "column3"
+ ]
+ }
+ }
+ }
+}
+```
+
+`targetResource` is used in cases where a new resource, distinct from the one in `resource` is being created. For instance,
+when renaming a table.
+
+```json
+{
+ "context": {
+ "identity": {
+ "user": "foo",
+ "groups": ["some-group"]
+ },
+ "softwareStack": {
+ "trinoVersion": "434"
+ }
+ },
+ "action": {
+ "operation": "RenameTable",
+ "resource": {
+ "table": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema",
+ "tableName": "my_table"
+ }
+ },
+ "targetResource": {
+ "table": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema",
+ "tableName": "new_table_name"
+ }
+ }
+ }
+}
+```
+
+
+## Batch mode
+
+A very powerful feature provided by OPA is its ability to respond to authorization queries with
+more complex answers than a `true`/`false` boolean value.
+
+Many features in Trino require _filtering_ to be performed to determine, given a list of resources,
+(e.g. tables, queries, views, etc...) which of those a user should be entitled to see/interact with.
+
+If `opa.policy.batched-uri` is _not_ configured, the plugin will send one request to OPA _per item_ being
+filtered, then use the responses from OPA to construct a filtered list containing only those items for which
+a `true` response was returned.
+
+Configuring `opa.policy.batched-uri` will allow the plugin to send a request to that _batch_ endpoint instead,
+with a **list** of the resources being filtered under `action.filterResources` (as opposed to `action.resource`).
+
+> The other fields in the request are identical to the non-batch endpoint.
+
+An OPA policy supporting batch operations should return a (potentially empty) list containing the _indices_
+of the items for which authorization is granted (if any). Returning a `null` value instead of a list
+is equivalent to returning an empty list.
+
+> We may want to reconsider the choice of using _indices_ in the response as opposed to returning a list
+> containing copies of elements from the `filterResources` field in the request for which access should
+> be granted. Indices were chosen over copying elements as it made validation in the plugin easier,
+> and from the few examples we tried, it also made certain policies a bit simpler. Any feedback is appreciated!
+
+An interesting side effect of this is that we can add batching support for policies that didn't originally
+have it quite easily. Consider the following rego:
+
+```rego
+package foo
+
+# ... rest of the policy ...
+# this assumes the non-batch response field is called "allow"
+batch contains i {
+ some i
+ raw_resource := input.action.filterResources[i]
+ allow with input.action.resource as raw_resource
+}
+
+# Corner case: filtering columns is done with a single table item, and many columns inside
+# We cannot use our normal logic in other parts of the policy as they are based on sets
+# and we need to retain order
+batch contains i {
+ some i
+ input.action.operation == "FilterColumns"
+ count(input.action.filterResources) == 1
+ raw_resource := input.action.filterResources[0]
+ count(raw_resource["table"]["columns"]) > 0
+ new_resources := [
+ object.union(raw_resource, {"table": {"column": column_name}})
+ | column_name := raw_resource["table"]["columns"][_]
+ ]
+ allow with input.action.resource as new_resources[i]
+}
+```
diff --git a/plugin/trino-opa/pom.xml b/plugin/trino-opa/pom.xml
new file mode 100644
index 0000000000000..6673090d7c1c4
--- /dev/null
+++ b/plugin/trino-opa/pom.xml
@@ -0,0 +1,166 @@
+
+
+ 4.0.0
+
+ io.trino
+ trino-root
+ 438-SNAPSHOT
+ ../../pom.xml
+
+
+ trino-opa
+
+ trino-plugin
+ Trino - Open Policy Agent
+
+
+ ${project.parent.basedir}
+
+
+
+
+ com.google.guava
+ guava
+
+
+
+ com.google.inject
+ guice
+
+
+
+ io.airlift
+ bootstrap
+
+
+
+ io.airlift
+ concurrent
+
+
+
+ io.airlift
+ configuration
+
+
+
+ io.airlift
+ http-client
+
+
+
+ io.airlift
+ json
+
+
+
+ io.airlift
+ log
+
+
+
+ jakarta.validation
+ jakarta.validation-api
+
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ provided
+
+
+
+ io.airlift
+ slice
+ provided
+
+
+
+ io.opentelemetry
+ opentelemetry-api
+ provided
+
+
+ io.opentelemetry
+ opentelemetry-context
+ provided
+
+
+
+ io.trino
+ trino-spi
+ provided
+
+
+
+ org.openjdk.jol
+ jol-core
+ provided
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ runtime
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jdk8
+ runtime
+
+
+
+ io.trino
+ trino-blackhole
+ test
+
+
+
+ io.trino
+ trino-main
+ test
+
+
+
+ io.trino
+ trino-testing
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+
+ org.junit.jupiter
+ junit-jupiter-params
+ test
+
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+ org.testcontainers
+ testcontainers
+ test
+
+
+
+
+
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/ForOpa.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/ForOpa.java
new file mode 100644
index 0000000000000..d580b645a8f56
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/ForOpa.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.google.inject.BindingAnnotation;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+@BindingAnnotation
+@Target({FIELD, PARAMETER, METHOD})
+@Retention(RUNTIME)
+public @interface ForOpa {
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControl.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControl.java
new file mode 100644
index 0000000000000..1978503a2bc9d
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControl.java
@@ -0,0 +1,769 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Multimaps;
+import com.google.inject.Inject;
+import io.trino.plugin.opa.schema.OpaPluginContext;
+import io.trino.plugin.opa.schema.OpaQueryContext;
+import io.trino.plugin.opa.schema.OpaQueryInput;
+import io.trino.plugin.opa.schema.OpaQueryInputAction;
+import io.trino.plugin.opa.schema.OpaQueryInputResource;
+import io.trino.plugin.opa.schema.TrinoCatalogSessionProperty;
+import io.trino.plugin.opa.schema.TrinoFunction;
+import io.trino.plugin.opa.schema.TrinoGrantPrincipal;
+import io.trino.plugin.opa.schema.TrinoIdentity;
+import io.trino.plugin.opa.schema.TrinoSchema;
+import io.trino.plugin.opa.schema.TrinoTable;
+import io.trino.plugin.opa.schema.TrinoUser;
+import io.trino.spi.connector.CatalogSchemaName;
+import io.trino.spi.connector.CatalogSchemaRoutineName;
+import io.trino.spi.connector.CatalogSchemaTableName;
+import io.trino.spi.connector.SchemaTableName;
+import io.trino.spi.function.SchemaFunctionName;
+import io.trino.spi.security.AccessDeniedException;
+import io.trino.spi.security.Identity;
+import io.trino.spi.security.Privilege;
+import io.trino.spi.security.SystemAccessControl;
+import io.trino.spi.security.SystemSecurityContext;
+import io.trino.spi.security.TrinoPrincipal;
+
+import java.security.Principal;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static io.trino.plugin.opa.OpaHighLevelClient.buildQueryInputForSimpleResource;
+import static io.trino.spi.security.AccessDeniedException.denyCreateCatalog;
+import static io.trino.spi.security.AccessDeniedException.denyCreateFunction;
+import static io.trino.spi.security.AccessDeniedException.denyCreateSchema;
+import static io.trino.spi.security.AccessDeniedException.denyCreateViewWithSelect;
+import static io.trino.spi.security.AccessDeniedException.denyDropCatalog;
+import static io.trino.spi.security.AccessDeniedException.denyDropFunction;
+import static io.trino.spi.security.AccessDeniedException.denyDropSchema;
+import static io.trino.spi.security.AccessDeniedException.denyExecuteProcedure;
+import static io.trino.spi.security.AccessDeniedException.denyExecuteTableProcedure;
+import static io.trino.spi.security.AccessDeniedException.denyImpersonateUser;
+import static io.trino.spi.security.AccessDeniedException.denyRenameMaterializedView;
+import static io.trino.spi.security.AccessDeniedException.denyRenameSchema;
+import static io.trino.spi.security.AccessDeniedException.denyRenameTable;
+import static io.trino.spi.security.AccessDeniedException.denyRenameView;
+import static io.trino.spi.security.AccessDeniedException.denySetCatalogSessionProperty;
+import static io.trino.spi.security.AccessDeniedException.denySetSchemaAuthorization;
+import static io.trino.spi.security.AccessDeniedException.denySetSystemSessionProperty;
+import static io.trino.spi.security.AccessDeniedException.denySetTableAuthorization;
+import static io.trino.spi.security.AccessDeniedException.denySetViewAuthorization;
+import static io.trino.spi.security.AccessDeniedException.denyShowCreateSchema;
+import static io.trino.spi.security.AccessDeniedException.denyShowFunctions;
+import static io.trino.spi.security.AccessDeniedException.denyShowTables;
+import static java.util.Objects.requireNonNull;
+
+public sealed class OpaAccessControl
+ implements SystemAccessControl
+ permits OpaBatchAccessControl
+{
+ private final OpaHighLevelClient opaHighLevelClient;
+ private final boolean allowPermissionManagementOperations;
+ private final OpaPluginContext pluginContext;
+
+ @Inject
+ public OpaAccessControl(OpaHighLevelClient opaHighLevelClient, OpaConfig config, OpaPluginContext pluginContext)
+ {
+ this.opaHighLevelClient = requireNonNull(opaHighLevelClient, "opaHighLevelClient is null");
+ this.allowPermissionManagementOperations = config.getAllowPermissionManagementOperations();
+ this.pluginContext = requireNonNull(pluginContext, "pluginContext is null");
+ }
+
+ @Override
+ public void checkCanImpersonateUser(Identity identity, String userName)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(identity),
+ "ImpersonateUser",
+ () -> denyImpersonateUser(identity.getUser(), userName),
+ OpaQueryInputResource.builder().user(new TrinoUser(userName)).build());
+ }
+
+ @Override
+ public void checkCanSetUser(Optional principal, String userName)
+ {}
+
+ @Override
+ public void checkCanExecuteQuery(Identity identity)
+ {
+ opaHighLevelClient.queryAndEnforce(buildQueryContext(identity), "ExecuteQuery", AccessDeniedException::denyExecuteQuery);
+ }
+
+ @Override
+ public void checkCanViewQueryOwnedBy(Identity identity, Identity queryOwner)
+ {
+ opaHighLevelClient.queryAndEnforce(buildQueryContext(identity), "ViewQueryOwnedBy", AccessDeniedException::denyViewQuery, OpaQueryInputResource.builder().user(new TrinoUser(queryOwner)).build());
+ }
+
+ @Override
+ public Collection filterViewQueryOwnedBy(Identity identity, Collection queryOwners)
+ {
+ return opaHighLevelClient.parallelFilterFromOpa(
+ queryOwners,
+ queryOwner -> buildQueryInputForSimpleResource(
+ buildQueryContext(identity),
+ "FilterViewQueryOwnedBy",
+ OpaQueryInputResource.builder().user(new TrinoUser(queryOwner)).build()));
+ }
+
+ @Override
+ public void checkCanKillQueryOwnedBy(Identity identity, Identity queryOwner)
+ {
+ opaHighLevelClient.queryAndEnforce(buildQueryContext(identity), "KillQueryOwnedBy", AccessDeniedException::denyKillQuery, OpaQueryInputResource.builder().user(new TrinoUser(queryOwner)).build());
+ }
+
+ @Override
+ public void checkCanReadSystemInformation(Identity identity)
+ {
+ opaHighLevelClient.queryAndEnforce(buildQueryContext(identity), "ReadSystemInformation", AccessDeniedException::denyReadSystemInformationAccess);
+ }
+
+ @Override
+ public void checkCanWriteSystemInformation(Identity identity)
+ {
+ opaHighLevelClient.queryAndEnforce(buildQueryContext(identity), "WriteSystemInformation", AccessDeniedException::denyWriteSystemInformationAccess);
+ }
+
+ @Override
+ public void checkCanSetSystemSessionProperty(Identity identity, String propertyName)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(identity),
+ "SetSystemSessionProperty",
+ () -> denySetSystemSessionProperty(propertyName),
+ OpaQueryInputResource.builder().systemSessionProperty(propertyName).build());
+ }
+
+ @Override
+ public boolean canAccessCatalog(SystemSecurityContext context, String catalogName)
+ {
+ return opaHighLevelClient.queryOpaWithSimpleResource(
+ buildQueryContext(context),
+ "AccessCatalog",
+ OpaQueryInputResource.builder().catalog(catalogName).build());
+ }
+
+ @Override
+ public void checkCanCreateCatalog(SystemSecurityContext context, String catalog)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(context),
+ "CreateCatalog",
+ () -> denyCreateCatalog(catalog),
+ OpaQueryInputResource.builder().catalog(catalog).build());
+ }
+
+ @Override
+ public void checkCanDropCatalog(SystemSecurityContext context, String catalog)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(context),
+ "DropCatalog",
+ () -> denyDropCatalog(catalog),
+ OpaQueryInputResource.builder().catalog(catalog).build());
+ }
+
+ @Override
+ public Set filterCatalogs(SystemSecurityContext context, Set catalogs)
+ {
+ return opaHighLevelClient.parallelFilterFromOpa(
+ catalogs,
+ catalog -> buildQueryInputForSimpleResource(
+ buildQueryContext(context),
+ "FilterCatalogs",
+ OpaQueryInputResource.builder().catalog(catalog).build()));
+ }
+
+ @Override
+ public void checkCanCreateSchema(SystemSecurityContext context, CatalogSchemaName schema, Map properties)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(context),
+ "CreateSchema",
+ () -> denyCreateSchema(schema.toString()),
+ OpaQueryInputResource.builder().schema(new TrinoSchema(schema).withProperties(convertProperties(properties))).build());
+ }
+
+ @Override
+ public void checkCanDropSchema(SystemSecurityContext context, CatalogSchemaName schema)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(context),
+ "DropSchema",
+ () -> denyDropSchema(schema.toString()),
+ OpaQueryInputResource.builder().schema(new TrinoSchema(schema)).build());
+ }
+
+ @Override
+ public void checkCanRenameSchema(SystemSecurityContext context, CatalogSchemaName schema, String newSchemaName)
+ {
+ OpaQueryInputResource resource = OpaQueryInputResource.builder().schema(new TrinoSchema(schema)).build();
+ OpaQueryInputResource targetResource = OpaQueryInputResource.builder().schema(new TrinoSchema(schema.getCatalogName(), newSchemaName)).build();
+
+ OpaQueryContext queryContext = buildQueryContext(context);
+
+ if (!opaHighLevelClient.queryOpaWithSourceAndTargetResource(queryContext, "RenameSchema", resource, targetResource)) {
+ denyRenameSchema(schema.toString(), newSchemaName);
+ }
+ }
+
+ @Override
+ public void checkCanSetSchemaAuthorization(SystemSecurityContext context, CatalogSchemaName schema, TrinoPrincipal principal)
+ {
+ OpaQueryInputResource resource = OpaQueryInputResource.builder().schema(new TrinoSchema(schema)).build();
+ OpaQueryInputAction action = OpaQueryInputAction.builder()
+ .operation("SetSchemaAuthorization")
+ .resource(resource)
+ .grantee(TrinoGrantPrincipal.fromTrinoPrincipal(principal))
+ .build();
+ OpaQueryInput input = new OpaQueryInput(buildQueryContext(context), action);
+
+ if (!opaHighLevelClient.queryOpa(input)) {
+ denySetSchemaAuthorization(schema.toString(), principal);
+ }
+ }
+
+ @Override
+ public void checkCanShowSchemas(SystemSecurityContext context, String catalogName)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(context),
+ "ShowSchemas",
+ AccessDeniedException::denyShowSchemas,
+ OpaQueryInputResource.builder().catalog(catalogName).build());
+ }
+
+ @Override
+ public Set filterSchemas(SystemSecurityContext context, String catalogName, Set schemaNames)
+ {
+ return opaHighLevelClient.parallelFilterFromOpa(
+ schemaNames,
+ schema -> buildQueryInputForSimpleResource(
+ buildQueryContext(context),
+ "FilterSchemas",
+ OpaQueryInputResource.builder().schema(new TrinoSchema(catalogName, schema)).build()));
+ }
+
+ @Override
+ public void checkCanShowCreateSchema(SystemSecurityContext context, CatalogSchemaName schemaName)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(context),
+ "ShowCreateSchema",
+ () -> denyShowCreateSchema(schemaName.toString()),
+ OpaQueryInputResource.builder().schema(new TrinoSchema(schemaName)).build());
+ }
+
+ @Override
+ public void checkCanShowCreateTable(SystemSecurityContext context, CatalogSchemaTableName table)
+ {
+ checkTableOperation(context, "ShowCreateTable", table, AccessDeniedException::denyShowCreateTable);
+ }
+
+ @Override
+ public void checkCanCreateTable(SystemSecurityContext context, CatalogSchemaTableName table, Map properties)
+ {
+ checkTableAndPropertiesOperation(context, "CreateTable", table, convertProperties(properties), AccessDeniedException::denyCreateTable);
+ }
+
+ @Override
+ public void checkCanDropTable(SystemSecurityContext context, CatalogSchemaTableName table)
+ {
+ checkTableOperation(context, "DropTable", table, AccessDeniedException::denyDropTable);
+ }
+
+ @Override
+ public void checkCanRenameTable(SystemSecurityContext context, CatalogSchemaTableName table, CatalogSchemaTableName newTable)
+ {
+ OpaQueryInputResource oldResource = OpaQueryInputResource.builder().table(new TrinoTable(table)).build();
+ OpaQueryInputResource newResource = OpaQueryInputResource.builder().table(new TrinoTable(newTable)).build();
+ OpaQueryContext queryContext = buildQueryContext(context);
+
+ if (!opaHighLevelClient.queryOpaWithSourceAndTargetResource(queryContext, "RenameTable", oldResource, newResource)) {
+ denyRenameTable(table.toString(), newTable.toString());
+ }
+ }
+
+ @Override
+ public void checkCanSetTableProperties(SystemSecurityContext context, CatalogSchemaTableName table, Map> properties)
+ {
+ checkTableAndPropertiesOperation(context, "SetTableProperties", table, properties, AccessDeniedException::denySetTableProperties);
+ }
+
+ @Override
+ public void checkCanSetTableComment(SystemSecurityContext context, CatalogSchemaTableName table)
+ {
+ checkTableOperation(context, "SetTableComment", table, AccessDeniedException::denyCommentTable);
+ }
+
+ @Override
+ public void checkCanSetViewComment(SystemSecurityContext context, CatalogSchemaTableName view)
+ {
+ checkTableOperation(context, "SetViewComment", view, AccessDeniedException::denyCommentView);
+ }
+
+ @Override
+ public void checkCanSetColumnComment(SystemSecurityContext context, CatalogSchemaTableName table)
+ {
+ checkTableOperation(context, "SetColumnComment", table, AccessDeniedException::denyCommentColumn);
+ }
+
+ @Override
+ public void checkCanShowTables(SystemSecurityContext context, CatalogSchemaName schema)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(context),
+ "ShowTables",
+ () -> denyShowTables(schema.toString()),
+ OpaQueryInputResource.builder().schema(new TrinoSchema(schema)).build());
+ }
+
+ @Override
+ public Set filterTables(SystemSecurityContext context, String catalogName, Set tableNames)
+ {
+ return opaHighLevelClient.parallelFilterFromOpa(
+ tableNames,
+ table -> buildQueryInputForSimpleResource(
+ buildQueryContext(context),
+ "FilterTables",
+ OpaQueryInputResource.builder()
+ .table(new TrinoTable(catalogName, table.getSchemaName(), table.getTableName()))
+ .build()));
+ }
+
+ @Override
+ public void checkCanShowColumns(SystemSecurityContext context, CatalogSchemaTableName table)
+ {
+ checkTableOperation(context, "ShowColumns", table, AccessDeniedException::denyShowColumns);
+ }
+
+ @Override
+ public Map> filterColumns(SystemSecurityContext context, String catalogName, Map> tableColumns)
+ {
+ ImmutableSet.Builder allColumnsBuilder = ImmutableSet.builder();
+ for (Map.Entry> entry : tableColumns.entrySet()) {
+ SchemaTableName schemaTableName = entry.getKey();
+ TrinoTable trinoTable = new TrinoTable(catalogName, schemaTableName.getSchemaName(), schemaTableName.getTableName());
+ for (String columnName : entry.getValue()) {
+ allColumnsBuilder.add(trinoTable.withColumns(ImmutableSet.of(columnName)));
+ }
+ }
+ Set filteredColumns = opaHighLevelClient.parallelFilterFromOpa(
+ allColumnsBuilder.build(),
+ tableColumn -> buildQueryInputForSimpleResource(
+ buildQueryContext(context),
+ "FilterColumns",
+ OpaQueryInputResource.builder().table(tableColumn).build()));
+
+ ImmutableSetMultimap.Builder results = ImmutableSetMultimap.builder();
+ for (TrinoTable tableColumn : filteredColumns) {
+ results.put(new SchemaTableName(tableColumn.schemaName(), tableColumn.tableName()), getOnlyElement(tableColumn.columns()));
+ }
+ return Multimaps.asMap(results.build());
+ }
+
+ @Override
+ public void checkCanAddColumn(SystemSecurityContext context, CatalogSchemaTableName table)
+ {
+ checkTableOperation(context, "AddColumn", table, AccessDeniedException::denyAddColumn);
+ }
+
+ @Override
+ public void checkCanAlterColumn(SystemSecurityContext context, CatalogSchemaTableName table)
+ {
+ checkTableOperation(context, "AlterColumn", table, AccessDeniedException::denyAlterColumn);
+ }
+
+ @Override
+ public void checkCanDropColumn(SystemSecurityContext context, CatalogSchemaTableName table)
+ {
+ checkTableOperation(context, "DropColumn", table, AccessDeniedException::denyDropColumn);
+ }
+
+ @Override
+ public void checkCanSetTableAuthorization(SystemSecurityContext context, CatalogSchemaTableName table, TrinoPrincipal principal)
+ {
+ OpaQueryInputResource resource = OpaQueryInputResource.builder().table(new TrinoTable(table)).build();
+ OpaQueryInputAction action = OpaQueryInputAction.builder()
+ .operation("SetTableAuthorization")
+ .resource(resource)
+ .grantee(TrinoGrantPrincipal.fromTrinoPrincipal(principal))
+ .build();
+ OpaQueryInput input = new OpaQueryInput(buildQueryContext(context), action);
+
+ if (!opaHighLevelClient.queryOpa(input)) {
+ denySetTableAuthorization(table.toString(), principal);
+ }
+ }
+
+ @Override
+ public void checkCanRenameColumn(SystemSecurityContext context, CatalogSchemaTableName table)
+ {
+ checkTableOperation(context, "RenameColumn", table, AccessDeniedException::denyRenameColumn);
+ }
+
+ @Override
+ public void checkCanSelectFromColumns(SystemSecurityContext context, CatalogSchemaTableName table, Set columns)
+ {
+ checkTableAndColumnsOperation(context, "SelectFromColumns", table, columns, AccessDeniedException::denySelectColumns);
+ }
+
+ @Override
+ public void checkCanInsertIntoTable(SystemSecurityContext context, CatalogSchemaTableName table)
+ {
+ checkTableOperation(context, "InsertIntoTable", table, AccessDeniedException::denyInsertTable);
+ }
+
+ @Override
+ public void checkCanDeleteFromTable(SystemSecurityContext context, CatalogSchemaTableName table)
+ {
+ checkTableOperation(context, "DeleteFromTable", table, AccessDeniedException::denyDeleteTable);
+ }
+
+ @Override
+ public void checkCanTruncateTable(SystemSecurityContext context, CatalogSchemaTableName table)
+ {
+ checkTableOperation(context, "TruncateTable", table, AccessDeniedException::denyTruncateTable);
+ }
+
+ @Override
+ public void checkCanUpdateTableColumns(SystemSecurityContext securityContext, CatalogSchemaTableName table, Set updatedColumnNames)
+ {
+ checkTableAndColumnsOperation(securityContext, "UpdateTableColumns", table, updatedColumnNames, AccessDeniedException::denyUpdateTableColumns);
+ }
+
+ @Override
+ public void checkCanCreateView(SystemSecurityContext context, CatalogSchemaTableName view)
+ {
+ checkTableOperation(context, "CreateView", view, AccessDeniedException::denyCreateView);
+ }
+
+ @Override
+ public void checkCanRenameView(SystemSecurityContext context, CatalogSchemaTableName view, CatalogSchemaTableName newView)
+ {
+ OpaQueryInputResource oldResource = OpaQueryInputResource.builder().table(new TrinoTable(view)).build();
+ OpaQueryInputResource newResource = OpaQueryInputResource.builder().table(new TrinoTable(newView)).build();
+ OpaQueryContext queryContext = buildQueryContext(context);
+
+ if (!opaHighLevelClient.queryOpaWithSourceAndTargetResource(queryContext, "RenameView", oldResource, newResource)) {
+ denyRenameView(view.toString(), newView.toString());
+ }
+ }
+
+ @Override
+ public void checkCanSetViewAuthorization(SystemSecurityContext context, CatalogSchemaTableName view, TrinoPrincipal principal)
+ {
+ OpaQueryInputResource resource = OpaQueryInputResource.builder().table(new TrinoTable(view)).build();
+ OpaQueryInputAction action = OpaQueryInputAction.builder()
+ .operation("SetViewAuthorization")
+ .resource(resource)
+ .grantee(TrinoGrantPrincipal.fromTrinoPrincipal(principal))
+ .build();
+ OpaQueryInput input = new OpaQueryInput(buildQueryContext(context), action);
+
+ if (!opaHighLevelClient.queryOpa(input)) {
+ denySetViewAuthorization(view.toString(), principal);
+ }
+ }
+
+ @Override
+ public void checkCanDropView(SystemSecurityContext context, CatalogSchemaTableName view)
+ {
+ checkTableOperation(context, "DropView", view, AccessDeniedException::denyDropView);
+ }
+
+ @Override
+ public void checkCanCreateViewWithSelectFromColumns(SystemSecurityContext context, CatalogSchemaTableName table, Set columns)
+ {
+ checkTableAndColumnsOperation(context, "CreateViewWithSelectFromColumns", table, columns, (tableAsString, columnSet) -> denyCreateViewWithSelect(tableAsString, context.getIdentity()));
+ }
+
+ @Override
+ public void checkCanCreateMaterializedView(SystemSecurityContext context, CatalogSchemaTableName materializedView, Map properties)
+ {
+ checkTableAndPropertiesOperation(context, "CreateMaterializedView", materializedView, convertProperties(properties), AccessDeniedException::denyCreateMaterializedView);
+ }
+
+ @Override
+ public void checkCanRefreshMaterializedView(SystemSecurityContext context, CatalogSchemaTableName materializedView)
+ {
+ checkTableOperation(context, "RefreshMaterializedView", materializedView, AccessDeniedException::denyRefreshMaterializedView);
+ }
+
+ @Override
+ public void checkCanSetMaterializedViewProperties(SystemSecurityContext context, CatalogSchemaTableName materializedView, Map> properties)
+ {
+ checkTableAndPropertiesOperation(context, "SetMaterializedViewProperties", materializedView, properties, AccessDeniedException::denySetMaterializedViewProperties);
+ }
+
+ @Override
+ public void checkCanDropMaterializedView(SystemSecurityContext context, CatalogSchemaTableName materializedView)
+ {
+ checkTableOperation(context, "DropMaterializedView", materializedView, AccessDeniedException::denyDropMaterializedView);
+ }
+
+ @Override
+ public void checkCanRenameMaterializedView(SystemSecurityContext context, CatalogSchemaTableName view, CatalogSchemaTableName newView)
+ {
+ OpaQueryInputResource oldResource = OpaQueryInputResource.builder().table(new TrinoTable(view)).build();
+ OpaQueryInputResource newResource = OpaQueryInputResource.builder().table(new TrinoTable(newView)).build();
+ OpaQueryContext queryContext = buildQueryContext(context);
+
+ if (!opaHighLevelClient.queryOpaWithSourceAndTargetResource(queryContext, "RenameMaterializedView", oldResource, newResource)) {
+ denyRenameMaterializedView(view.toString(), newView.toString());
+ }
+ }
+
+ @Override
+ public void checkCanSetCatalogSessionProperty(SystemSecurityContext context, String catalogName, String propertyName)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(context),
+ "SetCatalogSessionProperty",
+ () -> denySetCatalogSessionProperty(propertyName),
+ OpaQueryInputResource.builder().catalogSessionProperty(new TrinoCatalogSessionProperty(catalogName, propertyName)).build());
+ }
+
+ @Override
+ public void checkCanGrantSchemaPrivilege(SystemSecurityContext context, Privilege privilege, CatalogSchemaName schema, TrinoPrincipal grantee, boolean grantOption)
+ {
+ enforcePermissionManagementOperation(AccessDeniedException::denyGrantSchemaPrivilege, privilege.toString(), schema.toString());
+ }
+
+ @Override
+ public void checkCanDenySchemaPrivilege(SystemSecurityContext context, Privilege privilege, CatalogSchemaName schema, TrinoPrincipal grantee)
+ {
+ enforcePermissionManagementOperation(AccessDeniedException::denyDenySchemaPrivilege, privilege.toString(), schema.toString());
+ }
+
+ @Override
+ public void checkCanRevokeSchemaPrivilege(SystemSecurityContext context, Privilege privilege, CatalogSchemaName schema, TrinoPrincipal revokee, boolean grantOption)
+ {
+ enforcePermissionManagementOperation(AccessDeniedException::denyRevokeSchemaPrivilege, privilege.toString(), schema.toString());
+ }
+
+ @Override
+ public void checkCanGrantTablePrivilege(SystemSecurityContext context, Privilege privilege, CatalogSchemaTableName table, TrinoPrincipal grantee, boolean grantOption)
+ {
+ enforcePermissionManagementOperation(AccessDeniedException::denyGrantTablePrivilege, privilege.toString(), table.toString());
+ }
+
+ @Override
+ public void checkCanDenyTablePrivilege(SystemSecurityContext context, Privilege privilege, CatalogSchemaTableName table, TrinoPrincipal grantee)
+ {
+ enforcePermissionManagementOperation(AccessDeniedException::denyDenyTablePrivilege, privilege.toString(), table.toString());
+ }
+
+ @Override
+ public void checkCanRevokeTablePrivilege(SystemSecurityContext context, Privilege privilege, CatalogSchemaTableName table, TrinoPrincipal revokee, boolean grantOption)
+ {
+ enforcePermissionManagementOperation(AccessDeniedException::denyRevokeTablePrivilege, privilege.toString(), table.toString());
+ }
+
+ @Override
+ public void checkCanCreateRole(SystemSecurityContext context, String role, Optional grantor)
+ {
+ enforcePermissionManagementOperation(AccessDeniedException::denyCreateRole, role);
+ }
+
+ @Override
+ public void checkCanDropRole(SystemSecurityContext context, String role)
+ {
+ enforcePermissionManagementOperation(AccessDeniedException::denyDropRole, role);
+ }
+
+ @Override
+ public void checkCanGrantRoles(SystemSecurityContext context, Set roles, Set grantees, boolean adminOption, Optional grantor)
+ {
+ enforcePermissionManagementOperation(AccessDeniedException::denyGrantRoles, roles, grantees);
+ }
+
+ @Override
+ public void checkCanRevokeRoles(SystemSecurityContext context, Set roles, Set grantees, boolean adminOption, Optional grantor)
+ {
+ enforcePermissionManagementOperation(AccessDeniedException::denyRevokeRoles, roles, grantees);
+ }
+
+ @Override
+ public void checkCanShowRoles(SystemSecurityContext context)
+ {
+ // We always want to allow users to query their current roles, since OPA does not deal with role information
+ }
+
+ @Override
+ public void checkCanShowCurrentRoles(SystemSecurityContext context)
+ {
+ // We always want to allow users to query their current roles, since OPA does not deal with role information
+ }
+
+ @Override
+ public void checkCanShowRoleGrants(SystemSecurityContext context)
+ {
+ // We always want to allow users to query their current roles, since OPA does not deal with role information
+ }
+
+ @Override
+ public void checkCanShowFunctions(SystemSecurityContext context, CatalogSchemaName schema)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(context),
+ "ShowFunctions",
+ () -> denyShowFunctions(schema.toString()),
+ OpaQueryInputResource.builder().schema(new TrinoSchema(schema)).build());
+ }
+
+ @Override
+ public Set filterFunctions(SystemSecurityContext context, String catalogName, Set functionNames)
+ {
+ return opaHighLevelClient.parallelFilterFromOpa(
+ functionNames,
+ function -> buildQueryInputForSimpleResource(
+ buildQueryContext(context),
+ "FilterFunctions",
+ OpaQueryInputResource.builder()
+ .function(
+ new TrinoFunction(
+ new TrinoSchema(catalogName, function.getSchemaName()),
+ function.getFunctionName()))
+ .build()));
+ }
+
+ @Override
+ public void checkCanExecuteProcedure(SystemSecurityContext systemSecurityContext, CatalogSchemaRoutineName procedure)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(systemSecurityContext),
+ "ExecuteProcedure",
+ () -> denyExecuteProcedure(procedure.toString()),
+ OpaQueryInputResource.builder().function(TrinoFunction.fromTrinoFunction(procedure)).build());
+ }
+
+ @Override
+ public boolean canExecuteFunction(SystemSecurityContext systemSecurityContext, CatalogSchemaRoutineName functionName)
+ {
+ return opaHighLevelClient.queryOpaWithSimpleResource(
+ buildQueryContext(systemSecurityContext),
+ "ExecuteFunction",
+ OpaQueryInputResource.builder().function(TrinoFunction.fromTrinoFunction(functionName)).build());
+ }
+
+ @Override
+ public boolean canCreateViewWithExecuteFunction(SystemSecurityContext systemSecurityContext, CatalogSchemaRoutineName functionName)
+ {
+ return opaHighLevelClient.queryOpaWithSimpleResource(
+ buildQueryContext(systemSecurityContext),
+ "CreateViewWithExecuteFunction",
+ OpaQueryInputResource.builder().function(TrinoFunction.fromTrinoFunction(functionName)).build());
+ }
+
+ @Override
+ public void checkCanExecuteTableProcedure(SystemSecurityContext systemSecurityContext, CatalogSchemaTableName table, String procedure)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(systemSecurityContext),
+ "ExecuteTableProcedure",
+ () -> denyExecuteTableProcedure(table.toString(), procedure),
+ OpaQueryInputResource.builder().table(new TrinoTable(table)).function(procedure).build());
+ }
+
+ @Override
+ public void checkCanCreateFunction(SystemSecurityContext systemSecurityContext, CatalogSchemaRoutineName functionName)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(systemSecurityContext),
+ "CreateFunction",
+ () -> denyCreateFunction(functionName.toString()),
+ OpaQueryInputResource.builder().function(TrinoFunction.fromTrinoFunction(functionName)).build());
+ }
+
+ @Override
+ public void checkCanDropFunction(SystemSecurityContext systemSecurityContext, CatalogSchemaRoutineName functionName)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(systemSecurityContext),
+ "DropFunction",
+ () -> denyDropFunction(functionName.toString()),
+ OpaQueryInputResource.builder().function(TrinoFunction.fromTrinoFunction(functionName)).build());
+ }
+
+ private void checkTableOperation(SystemSecurityContext context, String actionName, CatalogSchemaTableName table, Consumer deny)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(context),
+ actionName,
+ () -> deny.accept(table.toString()),
+ OpaQueryInputResource.builder().table(new TrinoTable(table)).build());
+ }
+
+ private void checkTableAndPropertiesOperation(SystemSecurityContext context, String actionName, CatalogSchemaTableName table, Map> properties, Consumer deny)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(context),
+ actionName,
+ () -> deny.accept(table.toString()),
+ OpaQueryInputResource.builder().table(new TrinoTable(table).withProperties(properties)).build());
+ }
+
+ private void checkTableAndColumnsOperation(SystemSecurityContext context, String actionName, CatalogSchemaTableName table, Set columns, BiConsumer> deny)
+ {
+ opaHighLevelClient.queryAndEnforce(
+ buildQueryContext(context),
+ actionName,
+ () -> deny.accept(table.toString(), columns),
+ OpaQueryInputResource.builder().table(new TrinoTable(table).withColumns(columns)).build());
+ }
+
+ private void enforcePermissionManagementOperation(Consumer deny, T arg)
+ {
+ if (!allowPermissionManagementOperations) {
+ deny.accept(arg);
+ }
+ }
+
+ private void enforcePermissionManagementOperation(BiConsumer deny, T arg1, U arg2)
+ {
+ if (!allowPermissionManagementOperations) {
+ deny.accept(arg1, arg2);
+ }
+ }
+
+ private static Map> convertProperties(Map properties)
+ {
+ return properties.entrySet().stream()
+ .map(propertiesEntry -> Map.entry(propertiesEntry.getKey(), Optional.ofNullable(propertiesEntry.getValue())))
+ .collect(toImmutableMap(Map.Entry::getKey, Map.Entry::getValue));
+ }
+
+ OpaQueryContext buildQueryContext(Identity trinoIdentity)
+ {
+ return new OpaQueryContext(TrinoIdentity.fromTrinoIdentity(trinoIdentity), pluginContext);
+ }
+
+ OpaQueryContext buildQueryContext(SystemSecurityContext securityContext)
+ {
+ return new OpaQueryContext(TrinoIdentity.fromTrinoIdentity(securityContext.getIdentity()), pluginContext);
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControlFactory.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControlFactory.java
new file mode 100644
index 0000000000000..e23d2838f9656
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControlFactory.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.Provider;
+import com.google.inject.Scopes;
+import io.airlift.bootstrap.Bootstrap;
+import io.airlift.concurrent.BoundedExecutor;
+import io.airlift.http.client.HttpClient;
+import io.airlift.json.JsonModule;
+import io.trino.plugin.opa.schema.OpaPluginContext;
+import io.trino.plugin.opa.schema.OpaQuery;
+import io.trino.plugin.opa.schema.OpaQueryResult;
+import io.trino.spi.security.SystemAccessControl;
+import io.trino.spi.security.SystemAccessControlFactory;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.Executor;
+
+import static io.airlift.concurrent.Threads.daemonThreadsNamed;
+import static io.airlift.http.client.HttpClientBinder.httpClientBinder;
+import static io.airlift.json.JsonCodecBinder.jsonCodecBinder;
+import static java.util.Objects.requireNonNull;
+import static java.util.concurrent.Executors.newCachedThreadPool;
+
+public class OpaAccessControlFactory
+ implements SystemAccessControlFactory
+{
+ @Override
+ public String getName()
+ {
+ return "opa";
+ }
+
+ @Override
+ public SystemAccessControl create(Map config)
+ {
+ return create(config, Optional.empty(), Optional.empty());
+ }
+
+ @Override
+ public SystemAccessControl create(Map config, SystemAccessControlContext context)
+ {
+ return create(config, Optional.empty(), Optional.ofNullable(context));
+ }
+
+ @VisibleForTesting
+ protected static SystemAccessControl create(Map config, Optional httpClient, Optional context)
+ {
+ requireNonNull(config, "config is null");
+ requireNonNull(httpClient, "httpClient is null");
+ requireNonNull(context, "context is null");
+
+ Bootstrap app = new Bootstrap(
+ new JsonModule(),
+ binder -> {
+ jsonCodecBinder(binder).bindJsonCodec(OpaQuery.class);
+ jsonCodecBinder(binder).bindJsonCodec(OpaQueryResult.class);
+ httpClient.ifPresentOrElse(
+ client -> binder.bind(Key.get(HttpClient.class, ForOpa.class)).toInstance(client),
+ () -> httpClientBinder(binder).bindHttpClient("opa", ForOpa.class));
+ context.ifPresentOrElse(
+ actualContext -> binder.bind(OpaPluginContext.class).toInstance(new OpaPluginContext(actualContext.getVersion())),
+ () -> binder.bind(OpaPluginContext.class).toInstance(new OpaPluginContext("UNKNOWN")));
+ binder.bind(OpaHighLevelClient.class);
+ binder.bind(Key.get(Executor.class, ForOpa.class))
+ .toProvider(ExecutorProvider.class)
+ .in(Scopes.SINGLETON);
+ binder.bind(OpaHttpClient.class).in(Scopes.SINGLETON);
+ },
+ new OpaAccessControlModule());
+
+ Injector injector = app
+ .doNotInitializeLogging()
+ .setRequiredConfigurationProperties(config)
+ .initialize();
+ return injector.getInstance(SystemAccessControl.class);
+ }
+
+ private static class ExecutorProvider
+ implements Provider
+ {
+ private final Executor executor;
+
+ private ExecutorProvider()
+ {
+ this.executor = new BoundedExecutor(
+ newCachedThreadPool(daemonThreadsNamed("opa-access-control-http-%s")),
+ Runtime.getRuntime().availableProcessors());
+ }
+
+ @Override
+ public Executor get()
+ {
+ return executor;
+ }
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControlModule.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControlModule.java
new file mode 100644
index 0000000000000..4c0c470351c10
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControlModule.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.google.inject.Binder;
+import com.google.inject.Scopes;
+import io.airlift.configuration.AbstractConfigurationAwareModule;
+import io.trino.plugin.opa.schema.OpaBatchQueryResult;
+import io.trino.spi.security.SystemAccessControl;
+
+import static io.airlift.configuration.ConditionalModule.conditionalModule;
+import static io.airlift.configuration.ConfigBinder.configBinder;
+import static io.airlift.json.JsonCodecBinder.jsonCodecBinder;
+
+public class OpaAccessControlModule
+ extends AbstractConfigurationAwareModule
+{
+ @Override
+ protected void setup(Binder binder)
+ {
+ configBinder(binder).bindConfig(OpaConfig.class);
+ install(conditionalModule(
+ OpaConfig.class,
+ config -> config.getOpaBatchUri().isPresent(),
+ new OpaBatchAccessControlModule(),
+ new OpaSingleAuthorizerModule()));
+ }
+
+ public static class OpaSingleAuthorizerModule
+ extends AbstractConfigurationAwareModule
+ {
+ @Override
+ protected void setup(Binder binder)
+ {
+ binder.bind(SystemAccessControl.class).to(OpaAccessControl.class).in(Scopes.SINGLETON);
+ }
+ }
+
+ public static class OpaBatchAccessControlModule
+ extends AbstractConfigurationAwareModule
+ {
+ @Override
+ protected void setup(Binder binder)
+ {
+ jsonCodecBinder(binder).bindJsonCodec(OpaBatchQueryResult.class);
+ binder.bind(SystemAccessControl.class).to(OpaBatchAccessControl.class).in(Scopes.SINGLETON);
+ }
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControlPlugin.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControlPlugin.java
new file mode 100644
index 0000000000000..0c3140bc43f11
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControlPlugin.java
@@ -0,0 +1,28 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.google.common.collect.ImmutableList;
+import io.trino.spi.Plugin;
+import io.trino.spi.security.SystemAccessControlFactory;
+
+public class OpaAccessControlPlugin
+ implements Plugin
+{
+ @Override
+ public Iterable getSystemAccessControlFactories()
+ {
+ return ImmutableList.of(new OpaAccessControlFactory());
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaBatchAccessControl.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaBatchAccessControl.java
new file mode 100644
index 0000000000000..26b78a0f7f8d0
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaBatchAccessControl.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.inject.Inject;
+import io.airlift.json.JsonCodec;
+import io.trino.plugin.opa.schema.OpaBatchQueryResult;
+import io.trino.plugin.opa.schema.OpaPluginContext;
+import io.trino.plugin.opa.schema.OpaQueryContext;
+import io.trino.plugin.opa.schema.OpaQueryInput;
+import io.trino.plugin.opa.schema.OpaQueryInputAction;
+import io.trino.plugin.opa.schema.OpaQueryInputResource;
+import io.trino.plugin.opa.schema.TrinoFunction;
+import io.trino.plugin.opa.schema.TrinoSchema;
+import io.trino.plugin.opa.schema.TrinoTable;
+import io.trino.plugin.opa.schema.TrinoUser;
+import io.trino.spi.connector.SchemaTableName;
+import io.trino.spi.function.SchemaFunctionName;
+import io.trino.spi.security.Identity;
+import io.trino.spi.security.SystemSecurityContext;
+
+import java.net.URI;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Objects.requireNonNull;
+
+public final class OpaBatchAccessControl
+ extends OpaAccessControl
+{
+ private final JsonCodec batchResultCodec;
+ private final URI opaBatchedPolicyUri;
+ private final OpaHttpClient opaHttpClient;
+
+ @Inject
+ public OpaBatchAccessControl(
+ OpaHighLevelClient opaHighLevelClient,
+ JsonCodec batchResultCodec,
+ OpaHttpClient opaHttpClient,
+ OpaConfig config,
+ OpaPluginContext pluginContext)
+ {
+ super(opaHighLevelClient, config, pluginContext);
+ this.opaBatchedPolicyUri = config.getOpaBatchUri().orElseThrow();
+ this.batchResultCodec = requireNonNull(batchResultCodec, "batchResultCodec is null");
+ this.opaHttpClient = requireNonNull(opaHttpClient, "opaHttpClient is null");
+ }
+
+ @Override
+ public Collection filterViewQueryOwnedBy(Identity identity, Collection queryOwners)
+ {
+ return batchFilterFromOpa(
+ buildQueryContext(identity),
+ "FilterViewQueryOwnedBy",
+ queryOwners,
+ queryOwner -> OpaQueryInputResource.builder()
+ .user(new TrinoUser(queryOwner))
+ .build());
+ }
+
+ @Override
+ public Set filterCatalogs(SystemSecurityContext context, Set catalogs)
+ {
+ return batchFilterFromOpa(
+ buildQueryContext(context),
+ "FilterCatalogs",
+ catalogs,
+ catalog -> OpaQueryInputResource.builder()
+ .catalog(catalog)
+ .build());
+ }
+
+ @Override
+ public Set filterSchemas(SystemSecurityContext context, String catalogName, Set schemaNames)
+ {
+ return batchFilterFromOpa(
+ buildQueryContext(context),
+ "FilterSchemas",
+ schemaNames,
+ schema -> OpaQueryInputResource.builder().schema(new TrinoSchema(catalogName, schema)).build());
+ }
+
+ @Override
+ public Set filterTables(SystemSecurityContext context, String catalogName, Set tableNames)
+ {
+ return batchFilterFromOpa(
+ buildQueryContext(context),
+ "FilterTables",
+ tableNames,
+ table -> OpaQueryInputResource.builder().table(new TrinoTable(catalogName, table.getSchemaName(), table.getTableName())).build());
+ }
+
+ @Override
+ public Map> filterColumns(SystemSecurityContext context, String catalogName, Map> tableColumns)
+ {
+ BiFunction, OpaQueryInput> requestBuilder = batchRequestBuilder(
+ buildQueryContext(context),
+ "FilterColumns",
+ (schemaTableName, columns) -> OpaQueryInputResource.builder()
+ .table(new TrinoTable(catalogName, schemaTableName.getSchemaName(), schemaTableName.getTableName()).withColumns(ImmutableSet.copyOf(columns)))
+ .build());
+ return opaHttpClient.parallelBatchFilterFromOpa(tableColumns, requestBuilder, opaBatchedPolicyUri, batchResultCodec);
+ }
+
+ @Override
+ public Set filterFunctions(SystemSecurityContext context, String catalogName, Set functionNames)
+ {
+ return batchFilterFromOpa(
+ buildQueryContext(context),
+ "FilterFunctions",
+ functionNames,
+ function -> OpaQueryInputResource.builder()
+ .function(new TrinoFunction(new TrinoSchema(catalogName, function.getSchemaName()), function.getFunctionName()))
+ .build());
+ }
+
+ private Set batchFilterFromOpa(OpaQueryContext context, String operation, Collection items, Function converter)
+ {
+ return opaHttpClient.batchFilterFromOpa(
+ items,
+ batchRequestBuilder(context, operation, converter),
+ opaBatchedPolicyUri,
+ batchResultCodec);
+ }
+
+ private static Function, OpaQueryInput> batchRequestBuilder(OpaQueryContext context, String operation, Function resourceMapper)
+ {
+ return items -> new OpaQueryInput(
+ context,
+ OpaQueryInputAction.builder()
+ .operation(operation)
+ .filterResources(items.stream().map(resourceMapper).collect(toImmutableList()))
+ .build());
+ }
+
+ private static BiFunction, OpaQueryInput> batchRequestBuilder(OpaQueryContext context, String operation, BiFunction, OpaQueryInputResource> resourceMapper)
+ {
+ return (resourcesKey, resourcesList) -> new OpaQueryInput(
+ context,
+ OpaQueryInputAction.builder()
+ .operation(operation)
+ .filterResources(ImmutableList.of(resourceMapper.apply(resourcesKey, resourcesList)))
+ .build());
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaConfig.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaConfig.java
new file mode 100644
index 0000000000000..42d85c1c5edc6
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaConfig.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import io.airlift.configuration.Config;
+import io.airlift.configuration.ConfigDescription;
+import jakarta.validation.constraints.NotNull;
+
+import java.net.URI;
+import java.util.Optional;
+
+public class OpaConfig
+{
+ private URI opaUri;
+
+ private Optional opaBatchUri = Optional.empty();
+ private boolean logRequests;
+ private boolean logResponses;
+ private boolean allowPermissionManagementOperations;
+
+ @NotNull
+ public URI getOpaUri()
+ {
+ return opaUri;
+ }
+
+ @Config("opa.policy.uri")
+ @ConfigDescription("URI for OPA policies")
+ public OpaConfig setOpaUri(@NotNull URI opaUri)
+ {
+ this.opaUri = opaUri;
+ return this;
+ }
+
+ public Optional getOpaBatchUri()
+ {
+ return opaBatchUri;
+ }
+
+ @Config("opa.policy.batched-uri")
+ @ConfigDescription("URI for Batch OPA policies - if not set, a single request will be sent for each entry on filtering methods")
+ public OpaConfig setOpaBatchUri(URI opaBatchUri)
+ {
+ this.opaBatchUri = Optional.ofNullable(opaBatchUri);
+ return this;
+ }
+
+ public boolean getLogRequests()
+ {
+ return this.logRequests;
+ }
+
+ @Config("opa.log-requests")
+ @ConfigDescription("Whether to log requests (URI, entire body and headers) prior to sending them to OPA")
+ public OpaConfig setLogRequests(boolean logRequests)
+ {
+ this.logRequests = logRequests;
+ return this;
+ }
+
+ public boolean getLogResponses()
+ {
+ return this.logResponses;
+ }
+
+ @Config("opa.log-responses")
+ @ConfigDescription("Whether to log responses (URI, entire body, status code and headers) received from OPA")
+ public OpaConfig setLogResponses(boolean logResponses)
+ {
+ this.logResponses = logResponses;
+ return this;
+ }
+
+ public boolean getAllowPermissionManagementOperations()
+ {
+ return this.allowPermissionManagementOperations;
+ }
+
+ @Config("opa.allow-permission-management-operations")
+ @ConfigDescription("Whether to allow permission management (GRANT, DENY, ...) and role management operations - OPA will not be queried for any such operations, they will be bulk allowed or denied depending on this setting")
+ public OpaConfig setAllowPermissionManagementOperations(boolean allowPermissionManagementOperations)
+ {
+ this.allowPermissionManagementOperations = allowPermissionManagementOperations;
+ return this;
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaHighLevelClient.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaHighLevelClient.java
new file mode 100644
index 0000000000000..81a99ddde5dc4
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaHighLevelClient.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.google.inject.Inject;
+import io.airlift.json.JsonCodec;
+import io.trino.plugin.opa.schema.OpaQueryContext;
+import io.trino.plugin.opa.schema.OpaQueryInput;
+import io.trino.plugin.opa.schema.OpaQueryInputAction;
+import io.trino.plugin.opa.schema.OpaQueryInputResource;
+import io.trino.plugin.opa.schema.OpaQueryResult;
+import io.trino.spi.security.AccessDeniedException;
+
+import java.net.URI;
+import java.util.Collection;
+import java.util.Set;
+import java.util.function.Function;
+
+import static java.util.Objects.requireNonNull;
+
+public class OpaHighLevelClient
+{
+ private final JsonCodec queryResultCodec;
+ private final URI opaPolicyUri;
+ private final OpaHttpClient opaHttpClient;
+
+ @Inject
+ public OpaHighLevelClient(
+ JsonCodec queryResultCodec,
+ OpaHttpClient opaHttpClient,
+ OpaConfig config)
+ {
+ this.queryResultCodec = requireNonNull(queryResultCodec, "queryResultCodec is null");
+ this.opaHttpClient = requireNonNull(opaHttpClient, "opaHttpClient is null");
+ this.opaPolicyUri = config.getOpaUri();
+ }
+
+ public boolean queryOpa(OpaQueryInput input)
+ {
+ return opaHttpClient.consumeOpaResponse(opaHttpClient.submitOpaRequest(input, opaPolicyUri, queryResultCodec)).result();
+ }
+
+ private boolean queryOpaWithSimpleAction(OpaQueryContext context, String operation)
+ {
+ return queryOpa(buildQueryInputForSimpleAction(context, operation));
+ }
+
+ public boolean queryOpaWithSimpleResource(OpaQueryContext context, String operation, OpaQueryInputResource resource)
+ {
+ return queryOpa(buildQueryInputForSimpleResource(context, operation, resource));
+ }
+
+ public boolean queryOpaWithSourceAndTargetResource(OpaQueryContext context, String operation, OpaQueryInputResource resource, OpaQueryInputResource targetResource)
+ {
+ return queryOpa(
+ new OpaQueryInput(
+ context,
+ OpaQueryInputAction.builder()
+ .operation(operation)
+ .resource(resource)
+ .targetResource(targetResource)
+ .build()));
+ }
+
+ public void queryAndEnforce(
+ OpaQueryContext context,
+ String actionName,
+ Runnable deny,
+ OpaQueryInputResource resource)
+ {
+ if (!queryOpaWithSimpleResource(context, actionName, resource)) {
+ deny.run();
+ // we should never get here because deny should throw
+ throw new AccessDeniedException("Access denied for action %s and resource %s".formatted(actionName, resource));
+ }
+ }
+
+ public void queryAndEnforce(
+ OpaQueryContext context,
+ String actionName,
+ Runnable deny)
+ {
+ if (!queryOpaWithSimpleAction(context, actionName)) {
+ deny.run();
+ // we should never get here because deny should throw
+ throw new AccessDeniedException("Access denied for action %s".formatted(actionName));
+ }
+ }
+
+ public Set parallelFilterFromOpa(
+ Collection items,
+ Function requestBuilder)
+ {
+ return opaHttpClient.parallelFilterFromOpa(items, requestBuilder, opaPolicyUri, queryResultCodec);
+ }
+
+ public static OpaQueryInput buildQueryInputForSimpleResource(OpaQueryContext context, String operation, OpaQueryInputResource resource)
+ {
+ return new OpaQueryInput(context, OpaQueryInputAction.builder().operation(operation).resource(resource).build());
+ }
+
+ private static OpaQueryInput buildQueryInputForSimpleAction(OpaQueryContext context, String operation)
+ {
+ return new OpaQueryInput(context, OpaQueryInputAction.builder().operation(operation).build());
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaHttpClient.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaHttpClient.java
new file mode 100644
index 0000000000000..d207f5d54e70d
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaHttpClient.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.inject.Inject;
+import io.airlift.http.client.FullJsonResponseHandler;
+import io.airlift.http.client.HttpClient;
+import io.airlift.http.client.HttpStatus;
+import io.airlift.http.client.JsonBodyGenerator;
+import io.airlift.http.client.Request;
+import io.airlift.json.JsonCodec;
+import io.airlift.log.Logger;
+import io.trino.plugin.opa.schema.OpaBatchQueryResult;
+import io.trino.plugin.opa.schema.OpaQuery;
+import io.trino.plugin.opa.schema.OpaQueryInput;
+import io.trino.plugin.opa.schema.OpaQueryResult;
+
+import java.net.URI;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
+import static com.google.common.net.MediaType.JSON_UTF_8;
+import static io.airlift.http.client.FullJsonResponseHandler.createFullJsonResponseHandler;
+import static io.airlift.http.client.JsonBodyGenerator.jsonBodyGenerator;
+import static io.airlift.http.client.Request.Builder.preparePost;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+import static java.util.Objects.requireNonNullElse;
+
+public class OpaHttpClient
+{
+ private final HttpClient httpClient;
+ private final JsonCodec serializer;
+ private final Executor executor;
+ private final boolean logRequests;
+ private final boolean logResponses;
+ private static final Logger log = Logger.get(OpaHttpClient.class);
+
+ @Inject
+ public OpaHttpClient(
+ @ForOpa HttpClient httpClient,
+ JsonCodec serializer,
+ @ForOpa Executor executor,
+ OpaConfig config)
+ {
+ this.httpClient = requireNonNull(httpClient, "httpClient is null");
+ this.serializer = requireNonNull(serializer, "serializer is null");
+ this.executor = requireNonNull(executor, "executor is null");
+ this.logRequests = config.getLogRequests();
+ this.logResponses = config.getLogResponses();
+ }
+
+ public FluentFuture submitOpaRequest(OpaQueryInput input, URI uri, JsonCodec deserializer)
+ {
+ Request request;
+ JsonBodyGenerator requestBodyGenerator;
+ try {
+ requestBodyGenerator = jsonBodyGenerator(serializer, new OpaQuery(input));
+ request = preparePost()
+ .addHeader(CONTENT_TYPE, JSON_UTF_8.toString())
+ .setUri(uri)
+ .setBodyGenerator(requestBodyGenerator)
+ .build();
+ }
+ catch (IllegalArgumentException e) {
+ log.error(e, "Failed to serialize OPA request body when attempting to send request to URI \"%s\"", uri.toString());
+ throw new OpaQueryException.SerializeFailed(e);
+ }
+ if (logRequests) {
+ log.debug(
+ "Sending OPA request to URI \"%s\" ; request body = %s ; request headers = %s",
+ uri.toString(),
+ new String(requestBodyGenerator.getBody(), UTF_8),
+ request.getHeaders());
+ }
+ return FluentFuture.from(httpClient.executeAsync(request, createFullJsonResponseHandler(deserializer)))
+ .transform(response -> parseOpaResponse(response, uri), executor);
+ }
+
+ public T consumeOpaResponse(ListenableFuture opaResponseFuture)
+ {
+ try {
+ return opaResponseFuture.get();
+ }
+ catch (ExecutionException e) {
+ if (e.getCause() instanceof OpaQueryException queryException) {
+ throw queryException;
+ }
+ log.error(e, "Failed to obtain response from OPA due to an unknown error");
+ throw new OpaQueryException.QueryFailed(e);
+ }
+ catch (InterruptedException e) {
+ log.error(e, "OPA request was interrupted in flight");
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+
+ public Set parallelFilterFromOpa(Collection items, Function requestBuilder, URI uri, JsonCodec extends OpaQueryResult> deserializer)
+ {
+ if (items.isEmpty()) {
+ return ImmutableSet.of();
+ }
+ List>> allFutures = items.stream()
+ .map(item -> submitOpaRequest(requestBuilder.apply(item), uri, deserializer)
+ .transform(result -> result.result() ? Optional.of(item) : Optional.empty(), executor))
+ .collect(toImmutableList());
+ return consumeOpaResponse(
+ Futures.whenAllComplete(allFutures).call(() -> allFutures.stream()
+ .map(this::consumeOpaResponse)
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .collect(toImmutableSet()),
+ executor));
+ }
+
+ public Set batchFilterFromOpa(Collection items, Function, OpaQueryInput> requestBuilder, URI uri, JsonCodec extends OpaBatchQueryResult> deserializer)
+ {
+ if (items.isEmpty()) {
+ return ImmutableSet.of();
+ }
+ String dummyMapKey = "filter";
+ return parallelBatchFilterFromOpa(ImmutableMap.of(dummyMapKey, items), (mapKey, mapValue) -> requestBuilder.apply(mapValue), uri, deserializer).getOrDefault(dummyMapKey, ImmutableSet.of());
+ }
+
+ public Map> parallelBatchFilterFromOpa(Map> items, BiFunction, OpaQueryInput> requestBuilder, URI uri, JsonCodec extends OpaBatchQueryResult> deserializer)
+ {
+ ImmutableMap.Builder>> allFuturesBuilder = ImmutableMap.builder();
+
+ for (Map.Entry> mapEntry : items.entrySet()) {
+ if (mapEntry.getValue().isEmpty()) {
+ continue;
+ }
+ List orderedItems = ImmutableList.copyOf(mapEntry.getValue());
+ allFuturesBuilder.put(
+ mapEntry.getKey(),
+ submitOpaRequest(requestBuilder.apply(mapEntry.getKey(), orderedItems), uri, deserializer)
+ .transform(
+ response -> requireNonNullElse(response.result(), ImmutableList.of()).stream()
+ .map(orderedItems::get)
+ .collect(toImmutableSet()),
+ executor));
+ }
+
+ ImmutableMap>> allFutures = allFuturesBuilder.buildOrThrow();
+ ImmutableMap.Builder> resultBuilder = ImmutableMap.builder();
+ List>> consumedFutures = consumeOpaResponse(
+ Futures.whenAllComplete(allFutures.values()).call(
+ () -> allFutures.entrySet().stream()
+ .map(entry -> Map.entry(entry.getKey(), consumeOpaResponse(entry.getValue())))
+ .filter(entry -> !entry.getValue().isEmpty())
+ .collect(toImmutableList()),
+ executor));
+ return resultBuilder.putAll(consumedFutures).buildKeepingLast();
+ }
+
+ private T parseOpaResponse(FullJsonResponseHandler.JsonResponse response, URI uri)
+ {
+ int statusCode = response.getStatusCode();
+ String uriString = uri.toString();
+ if (HttpStatus.familyForStatusCode(statusCode) != HttpStatus.Family.SUCCESSFUL) {
+ if (statusCode == HttpStatus.NOT_FOUND.code()) {
+ log.warn("OPA responded with not found error for policy with URI \"%s\"", uriString);
+ throw new OpaQueryException.PolicyNotFound(uriString);
+ }
+
+ log.error("Received unknown error from OPA for URI \"%s\" with status code = %d", uriString, statusCode);
+ throw new OpaQueryException.OpaServerError(uriString, statusCode, response.toString());
+ }
+ if (!response.hasValue()) {
+ log.error(response.getException(), "OPA response for URI \"%s\" with status code = %d could not be deserialized", uriString, statusCode);
+ throw new OpaQueryException.DeserializeFailed(response.getException());
+ }
+ if (logResponses) {
+ log.debug(
+ "OPA response for URI \"%s\" received: status code = %d ; response payload = %s ; response headers = %s",
+ uriString,
+ statusCode,
+ new String(response.getJsonBytes(), UTF_8),
+ response.getHeaders());
+ }
+ return response.getValue();
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaQueryException.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaQueryException.java
new file mode 100644
index 0000000000000..ef94546e0db30
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaQueryException.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+public abstract class OpaQueryException
+ extends RuntimeException
+{
+ public OpaQueryException(String message, Throwable cause)
+ {
+ super(message, cause);
+ }
+
+ public static final class QueryFailed
+ extends OpaQueryException
+ {
+ public QueryFailed(Throwable cause)
+ {
+ super("Failed to query OPA backend", cause);
+ }
+ }
+
+ public static final class SerializeFailed
+ extends OpaQueryException
+ {
+ public SerializeFailed(Throwable cause)
+ {
+ super("Failed to serialize OPA query context", cause);
+ }
+ }
+
+ public static final class DeserializeFailed
+ extends OpaQueryException
+ {
+ public DeserializeFailed(Throwable cause)
+ {
+ super("Failed to deserialize OPA policy response", cause);
+ }
+ }
+
+ public static final class PolicyNotFound
+ extends OpaQueryException
+ {
+ public PolicyNotFound(String policyName)
+ {
+ super("OPA policy named %s did not return a value (or does not exist)".formatted(policyName), null);
+ }
+ }
+
+ public static final class OpaServerError
+ extends OpaQueryException
+ {
+ public OpaServerError(String policyName, int statusCode, String extra)
+ {
+ super("OPA server returned status %d when processing policy %s: %s".formatted(statusCode, policyName, extra), null);
+ }
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaBatchQueryResult.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaBatchQueryResult.java
new file mode 100644
index 0000000000000..572401b569fb8
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaBatchQueryResult.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.collect.ImmutableList;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.List;
+
+import static java.util.Objects.requireNonNullElse;
+
+public record OpaBatchQueryResult(@JsonProperty("decision_id") String decisionId, @NotNull List result)
+{
+ public OpaBatchQueryResult
+ {
+ result = ImmutableList.copyOf(requireNonNullElse(result, ImmutableList.of()));
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaPluginContext.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaPluginContext.java
new file mode 100644
index 0000000000000..341957e4899a4
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaPluginContext.java
@@ -0,0 +1,24 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import static java.util.Objects.requireNonNull;
+
+public record OpaPluginContext(String trinoVersion)
+{
+ public OpaPluginContext
+ {
+ requireNonNull(trinoVersion, "trinoVersion is null");
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQuery.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQuery.java
new file mode 100644
index 0000000000000..df029c281cd0b
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQuery.java
@@ -0,0 +1,24 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import static java.util.Objects.requireNonNull;
+
+public record OpaQuery(OpaQueryInput input)
+{
+ public OpaQuery
+ {
+ requireNonNull(input, "input is null");
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryContext.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryContext.java
new file mode 100644
index 0000000000000..22c9811351262
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryContext.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import static java.util.Objects.requireNonNull;
+
+public record OpaQueryContext(TrinoIdentity identity, OpaPluginContext softwareStack)
+{
+ public OpaQueryContext
+ {
+ requireNonNull(identity, "identity is null");
+ requireNonNull(softwareStack, "softwareStack is null");
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryInput.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryInput.java
new file mode 100644
index 0000000000000..e9023826e5c28
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryInput.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import static java.util.Objects.requireNonNull;
+
+public record OpaQueryInput(OpaQueryContext context, OpaQueryInputAction action)
+{
+ public OpaQueryInput
+ {
+ requireNonNull(context, "context is null");
+ requireNonNull(action, "action is null");
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryInputAction.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryInputAction.java
new file mode 100644
index 0000000000000..f398529385186
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryInputAction.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.google.common.collect.ImmutableList;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.Collection;
+import java.util.List;
+
+import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
+import static java.util.Objects.requireNonNull;
+
+@JsonInclude(NON_NULL)
+public record OpaQueryInputAction(
+ @NotNull String operation,
+ OpaQueryInputResource resource,
+ List filterResources,
+ OpaQueryInputResource targetResource,
+ TrinoGrantPrincipal grantee)
+{
+ public OpaQueryInputAction
+ {
+ requireNonNull(operation, "operation is null");
+ if (filterResources != null && resource != null) {
+ throw new IllegalArgumentException("resource and filterResources cannot both be configured");
+ }
+ if (filterResources != null) {
+ filterResources = ImmutableList.copyOf(filterResources);
+ }
+ }
+
+ public static Builder builder()
+ {
+ return new Builder();
+ }
+
+ public static class Builder
+ {
+ private String operation;
+ private OpaQueryInputResource resource;
+ private List filterResources;
+ private OpaQueryInputResource targetResource;
+ private TrinoGrantPrincipal grantee;
+
+ private Builder() {}
+
+ public Builder operation(String operation)
+ {
+ this.operation = operation;
+ return this;
+ }
+
+ public Builder resource(OpaQueryInputResource resource)
+ {
+ this.resource = resource;
+ return this;
+ }
+
+ public Builder filterResources(Collection resources)
+ {
+ this.filterResources = ImmutableList.copyOf(resources);
+ return this;
+ }
+
+ public Builder targetResource(OpaQueryInputResource targetResource)
+ {
+ this.targetResource = targetResource;
+ return this;
+ }
+
+ public Builder grantee(TrinoGrantPrincipal grantee)
+ {
+ this.grantee = grantee;
+ return this;
+ }
+
+ public OpaQueryInputAction build()
+ {
+ return new OpaQueryInputAction(
+ this.operation,
+ this.resource,
+ this.filterResources,
+ this.targetResource,
+ this.grantee);
+ }
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryInputResource.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryInputResource.java
new file mode 100644
index 0000000000000..61820030882ef
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryInputResource.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import jakarta.validation.constraints.NotNull;
+
+import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
+import static java.util.Objects.requireNonNull;
+
+@JsonInclude(NON_NULL)
+public record OpaQueryInputResource(
+ TrinoUser user,
+ NamedEntity systemSessionProperty,
+ TrinoCatalogSessionProperty catalogSessionProperty,
+ TrinoFunction function,
+ NamedEntity catalog,
+ TrinoSchema schema,
+ TrinoTable table)
+{
+ public record NamedEntity(@NotNull String name)
+ {
+ public NamedEntity
+ {
+ requireNonNull(name, "name is null");
+ }
+ }
+
+ public static Builder builder()
+ {
+ return new Builder();
+ }
+
+ public static class Builder
+ {
+ private TrinoUser user;
+ private NamedEntity systemSessionProperty;
+ private TrinoCatalogSessionProperty catalogSessionProperty;
+ private NamedEntity catalog;
+ private TrinoSchema schema;
+ private TrinoTable table;
+ private TrinoFunction function;
+
+ private Builder() {}
+
+ public Builder user(TrinoUser user)
+ {
+ this.user = user;
+ return this;
+ }
+
+ public Builder systemSessionProperty(String systemSessionProperty)
+ {
+ this.systemSessionProperty = new NamedEntity(systemSessionProperty);
+ return this;
+ }
+
+ public Builder catalogSessionProperty(TrinoCatalogSessionProperty catalogSessionProperty)
+ {
+ this.catalogSessionProperty = catalogSessionProperty;
+ return this;
+ }
+
+ public Builder catalog(String catalog)
+ {
+ this.catalog = new NamedEntity(catalog);
+ return this;
+ }
+
+ public Builder schema(TrinoSchema schema)
+ {
+ this.schema = schema;
+ return this;
+ }
+
+ public Builder table(TrinoTable table)
+ {
+ this.table = table;
+ return this;
+ }
+
+ public Builder function(TrinoFunction function)
+ {
+ this.function = function;
+ return this;
+ }
+
+ public Builder function(String functionName)
+ {
+ this.function = new TrinoFunction(functionName);
+ return this;
+ }
+
+ public OpaQueryInputResource build()
+ {
+ return new OpaQueryInputResource(
+ this.user,
+ this.systemSessionProperty,
+ this.catalogSessionProperty,
+ this.function,
+ this.catalog,
+ this.schema,
+ this.table);
+ }
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryResult.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryResult.java
new file mode 100644
index 0000000000000..c49310bd533f8
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryResult.java
@@ -0,0 +1,18 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public record OpaQueryResult(@JsonProperty("decision_id") String decisionId, boolean result) {}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoCatalogSessionProperty.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoCatalogSessionProperty.java
new file mode 100644
index 0000000000000..59256e4037af9
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoCatalogSessionProperty.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import jakarta.validation.constraints.NotNull;
+
+import static java.util.Objects.requireNonNull;
+
+public record TrinoCatalogSessionProperty(@NotNull String catalogName, @NotNull String propertyName)
+{
+ public TrinoCatalogSessionProperty
+ {
+ requireNonNull(catalogName, "catalogName is null");
+ requireNonNull(propertyName, "propertyName is null");
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoFunction.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoFunction.java
new file mode 100644
index 0000000000000..b25abb0dce4c1
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoFunction.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonUnwrapped;
+import io.trino.spi.connector.CatalogSchemaRoutineName;
+import jakarta.validation.constraints.NotNull;
+
+import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
+import static java.util.Objects.requireNonNull;
+
+@JsonInclude(NON_NULL)
+public record TrinoFunction(
+ @JsonUnwrapped TrinoSchema catalogSchema,
+ @NotNull String functionName)
+{
+ public static TrinoFunction fromTrinoFunction(CatalogSchemaRoutineName catalogSchemaRoutineName)
+ {
+ return new TrinoFunction(
+ new TrinoSchema(catalogSchemaRoutineName.getCatalogName(), catalogSchemaRoutineName.getSchemaName()),
+ catalogSchemaRoutineName.getRoutineName());
+ }
+
+ public TrinoFunction(String functionName)
+ {
+ this(null, functionName);
+ }
+
+ public TrinoFunction
+ {
+ requireNonNull(functionName, "functionName is null");
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoGrantPrincipal.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoGrantPrincipal.java
new file mode 100644
index 0000000000000..3532e2450c57d
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoGrantPrincipal.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.trino.spi.security.TrinoPrincipal;
+import jakarta.validation.constraints.NotNull;
+
+import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
+import static java.util.Objects.requireNonNull;
+
+@JsonInclude(NON_NULL)
+public record TrinoGrantPrincipal(@NotNull String type, @NotNull String name)
+{
+ public static TrinoGrantPrincipal fromTrinoPrincipal(TrinoPrincipal principal)
+ {
+ return new TrinoGrantPrincipal(principal.getType().name(), principal.getName());
+ }
+
+ public TrinoGrantPrincipal
+ {
+ requireNonNull(type, "type is null");
+ requireNonNull(name, "name is null");
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoIdentity.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoIdentity.java
new file mode 100644
index 0000000000000..c1e5500b57bea
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoIdentity.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import com.google.common.collect.ImmutableSet;
+import io.trino.spi.security.Identity;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.Set;
+
+import static java.util.Objects.requireNonNull;
+
+public record TrinoIdentity(
+ @NotNull String user,
+ @NotNull Set groups)
+{
+ public static TrinoIdentity fromTrinoIdentity(Identity identity)
+ {
+ return new TrinoIdentity(
+ identity.getUser(),
+ identity.getGroups());
+ }
+
+ public TrinoIdentity
+ {
+ requireNonNull(user, "user is null");
+ groups = ImmutableSet.copyOf(requireNonNull(groups, "groups is null"));
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoSchema.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoSchema.java
new file mode 100644
index 0000000000000..334411ca05af6
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoSchema.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.google.common.collect.ImmutableMap;
+import io.trino.spi.connector.CatalogSchemaName;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.Map;
+import java.util.Optional;
+
+import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
+import static java.util.Objects.requireNonNull;
+
+@JsonInclude(NON_NULL)
+public record TrinoSchema(
+ @NotNull String catalogName,
+ @NotNull String schemaName,
+ Map> properties)
+{
+ public TrinoSchema
+ {
+ requireNonNull(catalogName, "catalogName is null");
+ requireNonNull(schemaName, "schemaName is null");
+ if (properties != null) {
+ properties = ImmutableMap.copyOf(properties);
+ }
+ }
+
+ public TrinoSchema(CatalogSchemaName schema)
+ {
+ this(schema.getCatalogName(), schema.getSchemaName());
+ }
+
+ public TrinoSchema(String catalogName, String schemaName)
+ {
+ this(catalogName, schemaName, null);
+ }
+
+ public TrinoSchema withProperties(Map> newProperties)
+ {
+ return new TrinoSchema(catalogName, schemaName, newProperties);
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoTable.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoTable.java
new file mode 100644
index 0000000000000..479d955e2a204
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoTable.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import io.trino.spi.connector.CatalogSchemaTableName;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
+import static java.util.Objects.requireNonNull;
+
+@JsonInclude(NON_NULL)
+public record TrinoTable(
+ @NotNull String catalogName,
+ @NotNull String schemaName,
+ @NotNull String tableName,
+ Set columns,
+ Map> properties)
+{
+ public TrinoTable
+ {
+ requireNonNull(catalogName, "catalogName is null");
+ requireNonNull(schemaName, "schemaName is null");
+ requireNonNull(tableName, "tableName is null");
+ if (columns != null) {
+ columns = ImmutableSet.copyOf(columns);
+ }
+ if (properties != null) {
+ properties = ImmutableMap.copyOf(properties);
+ }
+ }
+
+ public TrinoTable(CatalogSchemaTableName table)
+ {
+ this(table.getCatalogName(), table.getSchemaTableName().getSchemaName(), table.getSchemaTableName().getTableName());
+ }
+
+ public TrinoTable(String catalogName, String schemaName, String tableName)
+ {
+ this(catalogName, schemaName, tableName, null, null);
+ }
+
+ public TrinoTable withColumns(Set newColumns)
+ {
+ return new TrinoTable(catalogName, schemaName, tableName, newColumns, properties);
+ }
+
+ public TrinoTable withProperties(Map> newProperties)
+ {
+ return new TrinoTable(catalogName, schemaName, tableName, columns, newProperties);
+ }
+}
diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoUser.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoUser.java
new file mode 100644
index 0000000000000..90829cd427554
--- /dev/null
+++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoUser.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed 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 io.trino.plugin.opa.schema;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonUnwrapped;
+import io.trino.spi.security.Identity;
+
+import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL;
+import static java.util.Objects.requireNonNull;
+
+@JsonInclude(NON_NULL)
+public record TrinoUser(String user, @JsonUnwrapped TrinoIdentity identity)
+{
+ public TrinoUser
+ {
+ if (identity == null) {
+ requireNonNull(user, "user is null");
+ }
+ if (user != null && identity != null) {
+ throw new IllegalArgumentException("user and identity may not both be set");
+ }
+ }
+
+ public TrinoUser(String name)
+ {
+ this(name, null);
+ }
+
+ public TrinoUser(Identity identity)
+ {
+ this(null, TrinoIdentity.fromTrinoIdentity(identity));
+ }
+}
diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/FilteringTestHelpers.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/FilteringTestHelpers.java
new file mode 100644
index 0000000000000..a3f24e1554411
--- /dev/null
+++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/FilteringTestHelpers.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import io.trino.spi.connector.SchemaTableName;
+import io.trino.spi.function.SchemaFunctionName;
+import io.trino.spi.security.Identity;
+import io.trino.spi.security.SystemSecurityContext;
+import org.junit.jupiter.api.Named;
+import org.junit.jupiter.params.provider.Arguments;
+
+import java.util.Collection;
+import java.util.function.BiFunction;
+import java.util.stream.Stream;
+
+import static io.trino.plugin.opa.TestHelpers.createIllegalResponseTestCases;
+
+public final class FilteringTestHelpers
+{
+ private FilteringTestHelpers() {}
+
+ public static Stream emptyInputTestCases()
+ {
+ Stream>> callables = Stream.of(
+ (authorizer, context) -> authorizer.filterViewQueryOwnedBy(context.getIdentity(), ImmutableSet.of()),
+ (authorizer, context) -> authorizer.filterCatalogs(context, ImmutableSet.of()),
+ (authorizer, context) -> authorizer.filterSchemas(context, "my_catalog", ImmutableSet.of()),
+ (authorizer, context) -> authorizer.filterTables(context, "my_catalog", ImmutableSet.of()),
+ (authorizer, context) -> authorizer.filterFunctions(context, "my_catalog", ImmutableSet.of()));
+ Stream testNames = Stream.of("filterViewQueryOwnedBy", "filterCatalogs", "filterSchemas", "filterTables", "filterFunctions");
+ return Streams.zip(testNames, callables, (name, method) -> Arguments.of(Named.of(name, method)));
+ }
+
+ public static Stream prepopulatedErrorCases()
+ {
+ Stream> callables = Stream.of(
+ (authorizer, context) -> authorizer.filterViewQueryOwnedBy(context.getIdentity(), ImmutableSet.of(Identity.ofUser("foo"))),
+ (authorizer, context) -> authorizer.filterCatalogs(context, ImmutableSet.of("foo")),
+ (authorizer, context) -> authorizer.filterSchemas(context, "my_catalog", ImmutableSet.of("foo")),
+ (authorizer, context) -> authorizer.filterTables(context, "my_catalog", ImmutableSet.of(new SchemaTableName("foo", "bar"))),
+ (authorizer, context) -> authorizer.filterColumns(
+ context,
+ "my_catalog",
+ ImmutableMap.of(
+ SchemaTableName.schemaTableName("my_schema", "my_table"),
+ ImmutableSet.of("some_column"))),
+ (authorizer, context) -> authorizer.filterFunctions(
+ context,
+ "my_catalog",
+ ImmutableSet.of(new SchemaFunctionName("some_schema", "some_function"))));
+ Stream testNames = Stream.of("filterViewQueryOwnedBy", "filterCatalogs", "filterSchemas", "filterTables", "filterColumns", "filterFunctions");
+ return createIllegalResponseTestCases(Streams.zip(testNames, callables, (name, method) -> Arguments.of(Named.of(name, method))));
+ }
+}
diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/FunctionalHelpers.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/FunctionalHelpers.java
new file mode 100644
index 0000000000000..25354b7176858
--- /dev/null
+++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/FunctionalHelpers.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+public final class FunctionalHelpers
+{
+ public interface Consumer3
+ {
+ void accept(T1 t1, T2 t2, T3 t3);
+ }
+
+ public interface Consumer4
+ {
+ void accept(T1 t1, T2 t2, T3 t3, T4 t4);
+ }
+
+ public record Pair(T first, U second)
+ {
+ public static Pair of(T first, U second)
+ {
+ return new Pair<>(first, second);
+ }
+ }
+}
diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/HttpClientUtils.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/HttpClientUtils.java
new file mode 100644
index 0000000000000..83ae8c57331f4
--- /dev/null
+++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/HttpClientUtils.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import io.airlift.http.client.HttpStatus;
+import io.airlift.http.client.Request;
+import io.airlift.http.client.Response;
+import io.airlift.http.client.StaticBodyGenerator;
+import io.airlift.http.client.testing.TestingHttpClient;
+import io.airlift.http.client.testing.TestingResponse;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Function;
+
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
+import static com.google.common.net.MediaType.JSON_UTF_8;
+import static java.util.Objects.requireNonNull;
+
+public final class HttpClientUtils
+{
+ private HttpClientUtils() {}
+
+ public static class RecordingHttpProcessor
+ implements TestingHttpClient.Processor
+ {
+ private static final JsonMapper jsonMapper = new JsonMapper();
+ private final List requests = Collections.synchronizedList(new ArrayList<>());
+ private final Function handler;
+ private final URI expectedURI;
+ private final String expectedMethod;
+ private final String expectedContentType;
+
+ public RecordingHttpProcessor(URI expectedURI, String expectedMethod, String expectedContentType, Function handler)
+ {
+ this.expectedMethod = requireNonNull(expectedMethod, "expectedMethod is null");
+ this.expectedContentType = requireNonNull(expectedContentType, "expectedContentType is null");
+ this.expectedURI = requireNonNull(expectedURI, "expectedURI is null");
+ this.handler = requireNonNull(handler, "handler is null");
+ }
+
+ @Override
+ public Response handle(Request request)
+ {
+ if (!requireNonNull(request.getMethod()).equalsIgnoreCase(expectedMethod)) {
+ throw new IllegalArgumentException("Unexpected method: %s".formatted(request.getMethod()));
+ }
+ String actualContentType = request.getHeader(CONTENT_TYPE);
+ if (!requireNonNull(actualContentType).equalsIgnoreCase(expectedContentType)) {
+ throw new IllegalArgumentException("Unexpected content type header: %s".formatted(actualContentType));
+ }
+ if (!requireNonNull(request.getUri()).equals(expectedURI)) {
+ throw new IllegalArgumentException("Unexpected URI: %s".formatted(request.getUri().toString()));
+ }
+ if (requireNonNull(request.getBodyGenerator()) instanceof StaticBodyGenerator bodyGenerator) {
+ String requestContents = new String(bodyGenerator.getBody(), StandardCharsets.UTF_8);
+ try {
+ JsonNode parsedRequest = jsonMapper.readTree(requestContents);
+ requests.add(parsedRequest);
+ return handler.apply(parsedRequest).buildResponse();
+ }
+ catch (IOException e) {
+ throw new IllegalArgumentException("Request has illegal JSON", e);
+ }
+ }
+ else {
+ throw new IllegalArgumentException("Request has an unexpected body generator");
+ }
+ }
+
+ public List getRequests()
+ {
+ return ImmutableList.copyOf(requests);
+ }
+ }
+
+ public static final class InstrumentedHttpClient
+ extends TestingHttpClient
+ {
+ private final RecordingHttpProcessor httpProcessor;
+
+ public InstrumentedHttpClient(URI expectedURI, String expectedMethod, String expectedContentType, Function handler)
+ {
+ this(new RecordingHttpProcessor(expectedURI, expectedMethod, expectedContentType, handler));
+ }
+
+ public InstrumentedHttpClient(RecordingHttpProcessor processor)
+ {
+ super(processor);
+ this.httpProcessor = processor;
+ }
+
+ public List getRequests()
+ {
+ return httpProcessor.getRequests();
+ }
+ }
+
+ public record MockResponse(String contents, int statusCode)
+ {
+ public TestingResponse buildResponse()
+ {
+ return new TestingResponse(
+ HttpStatus.fromStatusCode(this.statusCode),
+ ImmutableListMultimap.of(CONTENT_TYPE, JSON_UTF_8.toString()),
+ this.contents.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+}
diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/RequestTestUtilities.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/RequestTestUtilities.java
new file mode 100644
index 0000000000000..7bf683b5d79cc
--- /dev/null
+++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/RequestTestUtilities.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.json.JsonMapper;
+import com.google.common.collect.ImmutableSet;
+import io.trino.plugin.opa.HttpClientUtils.MockResponse;
+import io.trino.spi.security.Identity;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Set;
+import java.util.function.Function;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static io.trino.plugin.opa.TestHelpers.SYSTEM_ACCESS_CONTROL_CONTEXT;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public final class RequestTestUtilities
+{
+ private RequestTestUtilities() {}
+
+ private static final JsonMapper jsonMapper = new JsonMapper();
+
+ public static void assertStringRequestsEqual(Set expectedRequests, Collection actualRequests, String extractPath)
+ {
+ Set parsedExpectedRequests = expectedRequests.stream()
+ .map(expectedRequest -> {
+ try {
+ return jsonMapper.readTree(expectedRequest);
+ }
+ catch (IOException e) {
+ throw new AssertionError("Cannot parse expected request", e);
+ }
+ })
+ .collect(toImmutableSet());
+ Set extractedActualRequests = actualRequests.stream().map(node -> node.at(extractPath)).collect(toImmutableSet());
+ assertThat(extractedActualRequests).containsExactlyInAnyOrderElementsOf(parsedExpectedRequests);
+ }
+
+ public static Function buildValidatingRequestHandler(Identity expectedUser, int statusCode, String responseContents)
+ {
+ return buildValidatingRequestHandler(expectedUser, new MockResponse(responseContents, statusCode));
+ }
+
+ public static Function buildValidatingRequestHandler(Identity expectedUser, MockResponse response)
+ {
+ return buildValidatingRequestHandler(expectedUser, jsonNode -> response);
+ }
+
+ public static Function buildValidatingRequestHandler(Identity expectedUser, Function customHandler)
+ {
+ return parsedRequest -> {
+ if (!parsedRequest.at("/input/context/identity/user").asText().equals(expectedUser.getUser())) {
+ throw new AssertionError("Request had invalid user in the identity block");
+ }
+ ImmutableSet.Builder groupsInRequestBuilder = ImmutableSet.builder();
+ parsedRequest.at("/input/context/identity/groups").iterator().forEachRemaining(node -> groupsInRequestBuilder.add(node.asText()));
+ if (!groupsInRequestBuilder.build().equals(expectedUser.getGroups())) {
+ throw new AssertionError("Request had invalid set of groups in the identity block");
+ }
+ if (!parsedRequest.at("/input/context/softwareStack/trinoVersion").asText().equals(SYSTEM_ACCESS_CONTROL_CONTEXT.getVersion())) {
+ throw new AssertionError("Request had invalid trinoVersion");
+ }
+ return customHandler.apply(parsedRequest);
+ };
+ }
+}
diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestHelpers.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestHelpers.java
new file mode 100644
index 0000000000000..d47e2a4280d86
--- /dev/null
+++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestHelpers.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
+import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.trace.Tracer;
+import io.trino.execution.QueryIdGenerator;
+import io.trino.plugin.opa.HttpClientUtils.InstrumentedHttpClient;
+import io.trino.plugin.opa.HttpClientUtils.MockResponse;
+import io.trino.spi.security.AccessDeniedException;
+import io.trino.spi.security.Identity;
+import io.trino.spi.security.SystemAccessControlFactory;
+import io.trino.spi.security.SystemSecurityContext;
+import org.junit.jupiter.api.Named;
+import org.junit.jupiter.params.provider.Arguments;
+
+import java.net.URI;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.net.MediaType.JSON_UTF_8;
+
+public final class TestHelpers
+{
+ private TestHelpers() {}
+
+ public static final MockResponse OK_RESPONSE = new MockResponse("""
+ {
+ "decision_id": "",
+ "result": true
+ }
+ """,
+ 200);
+ public static final MockResponse NO_ACCESS_RESPONSE = new MockResponse("""
+ {
+ "decision_id": "",
+ "result": false
+ }
+ """,
+ 200);
+ public static final MockResponse MALFORMED_RESPONSE = new MockResponse("""
+ { "this"": is broken_json; }
+ """,
+ 200);
+ public static final MockResponse UNDEFINED_RESPONSE = new MockResponse("{}", 404);
+ public static final MockResponse BAD_REQUEST_RESPONSE = new MockResponse("{}", 400);
+ public static final MockResponse SERVER_ERROR_RESPONSE = new MockResponse("", 500);
+ public static final SystemAccessControlFactory.SystemAccessControlContext SYSTEM_ACCESS_CONTROL_CONTEXT = new TestingSystemAccessControlContext("TEST_VERSION");
+
+ public static Stream createFailingTestCases(Stream baseTestCases)
+ {
+ return Sets.cartesianProduct(
+ baseTestCases.collect(toImmutableSet()),
+ allErrorCasesArgumentProvider().collect(toImmutableSet()))
+ .stream()
+ .map(items -> Arguments.of(items.stream().flatMap((args) -> Arrays.stream(args.get())).toArray()));
+ }
+
+ public static Stream createIllegalResponseTestCases(Stream baseTestCases)
+ {
+ return Sets.cartesianProduct(
+ baseTestCases.collect(toImmutableSet()),
+ illegalResponseArgumentProvider().collect(toImmutableSet()))
+ .stream()
+ .map(items -> Arguments.of(items.stream().flatMap((args) -> Arrays.stream(args.get())).toArray()));
+ }
+
+ public static Stream illegalResponseArgumentProvider()
+ {
+ // Invalid responses from OPA
+ return Stream.of(
+ Arguments.of(Named.of("Undefined policy response", UNDEFINED_RESPONSE), OpaQueryException.OpaServerError.PolicyNotFound.class, "did not return a value"),
+ Arguments.of(Named.of("Bad request response", BAD_REQUEST_RESPONSE), OpaQueryException.OpaServerError.class, "returned status 400"),
+ Arguments.of(Named.of("Server error response", SERVER_ERROR_RESPONSE), OpaQueryException.OpaServerError.class, "returned status 500"),
+ Arguments.of(Named.of("Malformed JSON response", MALFORMED_RESPONSE), OpaQueryException.class, "Failed to deserialize"));
+ }
+
+ public static Stream allErrorCasesArgumentProvider()
+ {
+ // All possible failure scenarios, including a well-formed access denied response
+ return Stream.concat(
+ illegalResponseArgumentProvider(),
+ Stream.of(Arguments.of(Named.of("No access response", NO_ACCESS_RESPONSE), AccessDeniedException.class, "Access Denied")));
+ }
+
+ public static SystemSecurityContext systemSecurityContextFromIdentity(Identity identity) {
+ return new SystemSecurityContext(identity, new QueryIdGenerator().createNextQueryId(), Instant.now());
+ }
+
+ public abstract static class MethodWrapper {
+ public abstract boolean isAccessAllowed(OpaAccessControl opaAccessControl);
+ }
+
+ public static class ThrowingMethodWrapper extends MethodWrapper {
+ private final Consumer callable;
+
+ public ThrowingMethodWrapper(Consumer callable) {
+ this.callable = callable;
+ }
+
+ @Override
+ public boolean isAccessAllowed(OpaAccessControl opaAccessControl) {
+ try {
+ this.callable.accept(opaAccessControl);
+ return true;
+ } catch (AccessDeniedException e) {
+ if (!e.getMessage().contains("Access Denied")) {
+ throw new AssertionError("Expected AccessDenied exception to contain 'Access Denied' in the message");
+ }
+ return false;
+ }
+ }
+ }
+
+ public static class ReturningMethodWrapper extends MethodWrapper {
+ private final Function callable;
+
+ public ReturningMethodWrapper(Function callable) {
+ this.callable = callable;
+ }
+
+ @Override
+ public boolean isAccessAllowed(OpaAccessControl opaAccessControl) {
+ return this.callable.apply(opaAccessControl);
+ }
+ }
+
+ public static InstrumentedHttpClient createMockHttpClient(URI expectedUri, Function handler)
+ {
+ return new InstrumentedHttpClient(expectedUri, "POST", JSON_UTF_8.toString(), handler);
+ }
+
+ public static OpaAccessControl createOpaAuthorizer(URI opaUri, InstrumentedHttpClient mockHttpClient)
+ {
+ return (OpaAccessControl) OpaAccessControlFactory.create(ImmutableMap.of("opa.policy.uri", opaUri.toString()), Optional.of(mockHttpClient), Optional.of(SYSTEM_ACCESS_CONTROL_CONTEXT));
+ }
+
+ public static OpaAccessControl createOpaAuthorizer(URI opaUri, URI opaBatchUri, InstrumentedHttpClient mockHttpClient)
+ {
+ return (OpaAccessControl) OpaAccessControlFactory.create(
+ ImmutableMap.builder()
+ .put("opa.policy.uri", opaUri.toString())
+ .put("opa.policy.batched-uri", opaBatchUri.toString())
+ .buildOrThrow(),
+ Optional.of(mockHttpClient),
+ Optional.of(SYSTEM_ACCESS_CONTROL_CONTEXT));
+ }
+
+ static final class TestingSystemAccessControlContext
+ implements SystemAccessControlFactory.SystemAccessControlContext
+ {
+ private final String trinoVersion;
+
+ public TestingSystemAccessControlContext(String version)
+ {
+ this.trinoVersion = version;
+ }
+
+ @Override
+ public String getVersion()
+ {
+ return this.trinoVersion;
+ }
+
+ @Override
+ public OpenTelemetry getOpenTelemetry()
+ {
+ return null;
+ }
+
+ @Override
+ public Tracer getTracer()
+ {
+ return null;
+ }
+ }
+}
diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControl.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControl.java
new file mode 100644
index 0000000000000..8c5e90e1f0c55
--- /dev/null
+++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControl.java
@@ -0,0 +1,1102 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import io.trino.plugin.opa.FunctionalHelpers.Pair;
+import io.trino.plugin.opa.HttpClientUtils.InstrumentedHttpClient;
+import io.trino.plugin.opa.HttpClientUtils.MockResponse;
+import io.trino.plugin.opa.TestHelpers.MethodWrapper;
+import io.trino.plugin.opa.TestHelpers.TestingSystemAccessControlContext;
+import io.trino.spi.connector.CatalogSchemaName;
+import io.trino.spi.connector.CatalogSchemaRoutineName;
+import io.trino.spi.connector.CatalogSchemaTableName;
+import io.trino.spi.security.Identity;
+import io.trino.spi.security.PrincipalType;
+import io.trino.spi.security.SystemAccessControlFactory;
+import io.trino.spi.security.SystemSecurityContext;
+import io.trino.spi.security.TrinoPrincipal;
+import org.junit.jupiter.api.Named;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.net.URI;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.stream.Stream;
+
+import static io.trino.plugin.opa.RequestTestUtilities.assertStringRequestsEqual;
+import static io.trino.plugin.opa.RequestTestUtilities.buildValidatingRequestHandler;
+import static io.trino.plugin.opa.TestHelpers.BAD_REQUEST_RESPONSE;
+import static io.trino.plugin.opa.TestHelpers.MALFORMED_RESPONSE;
+import static io.trino.plugin.opa.TestHelpers.NO_ACCESS_RESPONSE;
+import static io.trino.plugin.opa.TestHelpers.OK_RESPONSE;
+import static io.trino.plugin.opa.TestHelpers.SERVER_ERROR_RESPONSE;
+import static io.trino.plugin.opa.TestHelpers.UNDEFINED_RESPONSE;
+import static io.trino.plugin.opa.TestHelpers.createFailingTestCases;
+import static io.trino.plugin.opa.TestHelpers.createMockHttpClient;
+import static io.trino.plugin.opa.TestHelpers.createOpaAuthorizer;
+import static io.trino.plugin.opa.TestHelpers.systemSecurityContextFromIdentity;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class TestOpaAccessControl
+{
+ private static final URI OPA_SERVER_URI = URI.create("http://my-uri/");
+ private static final Identity TEST_IDENTITY = Identity.forUser("source-user").withGroups(ImmutableSet.of("some-group")).build();
+ private static final SystemSecurityContext TEST_SECURITY_CONTEXT = systemSecurityContextFromIdentity(TEST_IDENTITY);
+ // The below identity and security ctx would go away if we move all the tests to use their static constant counterparts above
+ private final Identity requestingIdentity = Identity.ofUser("source-user");
+ private final SystemSecurityContext requestingSecurityContext = systemSecurityContextFromIdentity(requestingIdentity);
+
+ @Test
+ public void testResponseHasExtraFields()
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, 200,"""
+ {
+ "result": true,
+ "decision_id": "foo",
+ "some_debug_info": {"test": ""}
+ }"""));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+ authorizer.checkCanExecuteQuery(requestingIdentity);
+ }
+
+ @Test
+ public void testNoResourceAction()
+ {
+ testNoResourceAction("ExecuteQuery", OpaAccessControl::checkCanExecuteQuery);
+ testNoResourceAction("ReadSystemInformation", OpaAccessControl::checkCanReadSystemInformation);
+ testNoResourceAction("WriteSystemInformation", OpaAccessControl::checkCanWriteSystemInformation);
+ }
+
+ private void testNoResourceAction(String actionName, BiConsumer method)
+ {
+ Set expectedRequests = ImmutableSet.of("""
+ {
+ "operation": "%s"
+ }""".formatted(actionName));
+ TestHelpers.ThrowingMethodWrapper wrappedMethod = new TestHelpers.ThrowingMethodWrapper((accessControl) -> method.accept(accessControl, TEST_IDENTITY));
+ assertAccessControlMethodBehaviour(wrappedMethod, expectedRequests);
+ }
+
+ private static Stream tableResourceTestCases()
+ {
+ Stream> methods = Stream.of(
+ OpaAccessControl::checkCanShowCreateTable,
+ OpaAccessControl::checkCanDropTable,
+ OpaAccessControl::checkCanSetTableComment,
+ OpaAccessControl::checkCanSetViewComment,
+ OpaAccessControl::checkCanSetColumnComment,
+ OpaAccessControl::checkCanShowColumns,
+ OpaAccessControl::checkCanAddColumn,
+ OpaAccessControl::checkCanDropColumn,
+ OpaAccessControl::checkCanAlterColumn,
+ OpaAccessControl::checkCanRenameColumn,
+ OpaAccessControl::checkCanInsertIntoTable,
+ OpaAccessControl::checkCanDeleteFromTable,
+ OpaAccessControl::checkCanTruncateTable,
+ OpaAccessControl::checkCanCreateView,
+ OpaAccessControl::checkCanDropView,
+ OpaAccessControl::checkCanRefreshMaterializedView,
+ OpaAccessControl::checkCanDropMaterializedView);
+ Stream actions = Stream.of(
+ "ShowCreateTable",
+ "DropTable",
+ "SetTableComment",
+ "SetViewComment",
+ "SetColumnComment",
+ "ShowColumns",
+ "AddColumn",
+ "DropColumn",
+ "AlterColumn",
+ "RenameColumn",
+ "InsertIntoTable",
+ "DeleteFromTable",
+ "TruncateTable",
+ "CreateView",
+ "DropView",
+ "RefreshMaterializedView",
+ "DropMaterializedView");
+ return Streams.zip(actions, methods, (action, method) -> Arguments.of(Named.of(action, action), method));
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#tableResourceTestCases")
+ public void testTableResourceActions(
+ String actionName,
+ FunctionalHelpers.Consumer3 callable)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ callable.accept(
+ authorizer,
+ requestingSecurityContext,
+ new CatalogSchemaTableName("my_catalog", "my_schema", "my_table"));
+
+ String expectedRequest = """
+ {
+ "operation": "%s",
+ "resource": {
+ "table": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema",
+ "tableName": "my_table"
+ }
+ }
+ }
+ """.formatted(actionName);
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action");
+ }
+
+ private static Stream tableResourceFailureTestCases()
+ {
+ return createFailingTestCases(tableResourceTestCases());
+ }
+
+ @ParameterizedTest(name = "{index}: {0} - {3}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#tableResourceFailureTestCases")
+ public void testTableResourceFailure(
+ String actionName,
+ FunctionalHelpers.Consumer3 method,
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ assertThatThrownBy(
+ () -> method.accept(
+ authorizer,
+ requestingSecurityContext,
+ new CatalogSchemaTableName("my_catalog", "my_schema", "my_table")))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ private static Stream tableWithPropertiesTestCases()
+ {
+ Stream> methods = Stream.of(
+ OpaAccessControl::checkCanSetTableProperties,
+ OpaAccessControl::checkCanSetMaterializedViewProperties,
+ OpaAccessControl::checkCanCreateTable,
+ OpaAccessControl::checkCanCreateMaterializedView);
+ Stream actions = Stream.of(
+ "SetTableProperties",
+ "SetMaterializedViewProperties",
+ "CreateTable",
+ "CreateMaterializedView");
+ return Streams.zip(actions, methods, (action, method) -> Arguments.of(Named.of(action, action), method));
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#tableWithPropertiesTestCases")
+ public void testTableWithPropertiesActions(
+ String actionName,
+ FunctionalHelpers.Consumer4 callable)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ CatalogSchemaTableName table = new CatalogSchemaTableName("my_catalog", "my_schema", "my_table");
+ Map> properties = ImmutableMap.>builder()
+ .put("string_item", Optional.of("string_value"))
+ .put("empty_item", Optional.empty())
+ .put("boxed_number_item", Optional.of(Integer.valueOf(32)))
+ .buildOrThrow();
+
+ callable.accept(authorizer, requestingSecurityContext, table, properties);
+
+ String expectedRequest = """
+ {
+ "operation": "%s",
+ "resource": {
+ "table": {
+ "tableName": "my_table",
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema",
+ "properties": {
+ "string_item": "string_value",
+ "empty_item": null,
+ "boxed_number_item": 32
+ }
+ }
+ }
+ }
+ """.formatted(actionName);
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action");
+ }
+
+ private static Stream tableWithPropertiesFailureTestCases()
+ {
+ return createFailingTestCases(tableWithPropertiesTestCases());
+ }
+
+ @ParameterizedTest(name = "{index}: {0} - {3}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#tableWithPropertiesFailureTestCases")
+ public void testTableWithPropertiesActionFailure(
+ String actionName,
+ FunctionalHelpers.Consumer4 method,
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ assertThatThrownBy(
+ () -> method.accept(
+ authorizer,
+ requestingSecurityContext,
+ new CatalogSchemaTableName("my_catalog", "my_schema", "my_table"),
+ ImmutableMap.of()))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ private static Stream identityResourceTestCases()
+ {
+ Stream> methods = Stream.of(
+ OpaAccessControl::checkCanViewQueryOwnedBy,
+ OpaAccessControl::checkCanKillQueryOwnedBy);
+ Stream actions = Stream.of(
+ "ViewQueryOwnedBy",
+ "KillQueryOwnedBy");
+ return Streams.zip(actions, methods, (action, method) -> Arguments.of(Named.of(action, action), method));
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#identityResourceTestCases")
+ public void testIdentityResourceActions(
+ String actionName,
+ FunctionalHelpers.Consumer3 callable)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ Identity dummyIdentity = Identity.forUser("dummy-user")
+ .withGroups(ImmutableSet.of("some-group"))
+ .build();
+ callable.accept(authorizer, requestingIdentity, dummyIdentity);
+
+ String expectedRequest = """
+ {
+ "operation": "%s",
+ "resource": {
+ "user": {
+ "user": "dummy-user",
+ "groups": ["some-group"]
+ }
+ }
+ }
+ """.formatted(actionName);
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action");
+ }
+
+ private static Stream identityResourceFailureTestCases()
+ {
+ return createFailingTestCases(identityResourceTestCases());
+ }
+
+ @ParameterizedTest(name = "{index}: {0} - {2}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#identityResourceFailureTestCases")
+ public void testIdentityResourceActionsFailure(
+ String actionName,
+ FunctionalHelpers.Consumer3 method,
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ assertThatThrownBy(
+ () -> method.accept(
+ authorizer,
+ requestingIdentity,
+ Identity.ofUser("dummy-user")))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ private static Stream stringResourceTestCases()
+ {
+ Stream> methods = Stream.of(
+ (accessControl, systemSecurityContext, argument) -> accessControl.checkCanSetSystemSessionProperty(systemSecurityContext.getIdentity(), argument),
+ OpaAccessControl::checkCanCreateCatalog,
+ OpaAccessControl::checkCanDropCatalog,
+ OpaAccessControl::checkCanShowSchemas);
+ Stream> actionAndResource = Stream.of(
+ Pair.of("SetSystemSessionProperty", "systemSessionProperty"),
+ Pair.of("CreateCatalog", "catalog"),
+ Pair.of("DropCatalog", "catalog"),
+ Pair.of("ShowSchemas", "catalog"));
+ return Streams.zip(
+ actionAndResource,
+ methods,
+ (action, method) -> Arguments.of(Named.of(action.first(), action.first()), action.second(), method));
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#stringResourceTestCases")
+ public void testStringResourceAction(
+ String actionName,
+ String resourceName,
+ FunctionalHelpers.Consumer3 callable)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ callable.accept(authorizer, requestingSecurityContext, "resource_name");
+
+ String expectedRequest = """
+ {
+ "operation": "%s",
+ "resource": {
+ "%s": {
+ "name": "resource_name"
+ }
+ }
+ }
+ """.formatted(actionName, resourceName);
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action");
+ }
+
+ public static Stream stringResourceFailureTestCases()
+ {
+ return createFailingTestCases(stringResourceTestCases());
+ }
+
+ @ParameterizedTest(name = "{index}: {0} - {3}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#stringResourceFailureTestCases")
+ public void testStringResourceActionsFailure(
+ String actionName,
+ String resourceName,
+ FunctionalHelpers.Consumer3 method,
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ assertThatThrownBy(
+ () -> method.accept(
+ authorizer,
+ requestingSecurityContext,
+ "dummy_value"))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ @Test
+ public void testCanImpersonateUser()
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ authorizer.checkCanImpersonateUser(requestingIdentity, "some_other_user");
+
+ String expectedRequest = """
+ {
+ "operation": "ImpersonateUser",
+ "resource": {
+ "user": {
+ "user": "some_other_user"
+ }
+ }
+ }
+ """;
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action");
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestHelpers#allErrorCasesArgumentProvider")
+ public void testCanImpersonateUserFailure(
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ assertThatThrownBy(
+ () -> authorizer.checkCanImpersonateUser(requestingIdentity, "some_other_user"))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ @Test
+ public void testCanAccessCatalog()
+ {
+ InstrumentedHttpClient permissiveClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl permissiveAuthorizer = createOpaAuthorizer(OPA_SERVER_URI, permissiveClient);
+ assertThat(permissiveAuthorizer.canAccessCatalog(requestingSecurityContext, "test_catalog")).isTrue();
+
+ InstrumentedHttpClient restrictiveClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, NO_ACCESS_RESPONSE));
+ OpaAccessControl restrictiveAuthorizer = createOpaAuthorizer(OPA_SERVER_URI, restrictiveClient);
+ assertThat(restrictiveAuthorizer.canAccessCatalog(requestingSecurityContext, "test_catalog")).isFalse();
+
+ String expectedRequest = """
+ {
+ "operation": "AccessCatalog",
+ "resource": {
+ "catalog": {
+ "name": "test_catalog"
+ }
+ }
+ }""";
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), permissiveClient.getRequests(), "/input/action");
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), restrictiveClient.getRequests(), "/input/action");
+ }
+
+ @ParameterizedTest(name = "{index}: {0} - {3}")
+ @MethodSource("io.trino.plugin.opa.TestHelpers#illegalResponseArgumentProvider")
+ public void testCanAccessCatalogIllegalResponses(
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ assertThatThrownBy(
+ () -> authorizer.canAccessCatalog(requestingSecurityContext, "my_catalog"))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ private static Stream schemaResourceTestCases()
+ {
+ Stream> methods = Stream.of(
+ OpaAccessControl::checkCanDropSchema,
+ OpaAccessControl::checkCanShowCreateSchema,
+ OpaAccessControl::checkCanShowTables,
+ OpaAccessControl::checkCanShowFunctions);
+ Stream actions = Stream.of(
+ "DropSchema",
+ "ShowCreateSchema",
+ "ShowTables",
+ "ShowFunctions");
+ return Streams.zip(actions, methods, (action, method) -> Arguments.of(Named.of(action, action), method));
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#schemaResourceTestCases")
+ public void testSchemaResourceActions(
+ String actionName,
+ FunctionalHelpers.Consumer3 callable)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ callable.accept(authorizer, requestingSecurityContext, new CatalogSchemaName("my_catalog", "my_schema"));
+
+ String expectedRequest = """
+ {
+ "operation": "%s",
+ "resource": {
+ "schema": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema"
+ }
+ }
+ }
+ """.formatted(actionName);
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action");
+ }
+
+ public static Stream schemaResourceFailureTestCases()
+ {
+ return createFailingTestCases(schemaResourceTestCases());
+ }
+
+ @ParameterizedTest(name = "{index}: {0} - {2}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#schemaResourceFailureTestCases")
+ public void testSchemaResourceActionsFailure(
+ String actionName,
+ FunctionalHelpers.Consumer3 method,
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ assertThatThrownBy(
+ () -> method.accept(
+ authorizer,
+ requestingSecurityContext,
+ new CatalogSchemaName("dummy_catalog", "dummy_schema")))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ @Test
+ public void testCreateSchema()
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ CatalogSchemaName schema = new CatalogSchemaName("my_catalog", "my_schema");
+ authorizer.checkCanCreateSchema(requestingSecurityContext, schema, ImmutableMap.of("some_key", "some_value"));
+ authorizer.checkCanCreateSchema(requestingSecurityContext, schema, ImmutableMap.of());
+
+ Set expectedRequests = ImmutableSet.builder()
+ .add("""
+ {
+ "operation": "CreateSchema",
+ "resource": {
+ "schema": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema",
+ "properties": {
+ "some_key": "some_value"
+ }
+ }
+ }
+ }
+ """)
+ .add("""
+ {
+ "operation": "CreateSchema",
+ "resource": {
+ "schema": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema",
+ "properties": {}
+ }
+ }
+ }
+ """)
+ .build();
+ assertStringRequestsEqual(expectedRequests, mockClient.getRequests(), "/input/action");
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestHelpers#allErrorCasesArgumentProvider")
+ public void testCreateSchemaFailure(
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ assertThatThrownBy(
+ () -> authorizer.checkCanCreateSchema(
+ requestingSecurityContext,
+ new CatalogSchemaName("my_catalog", "my_schema"),
+ ImmutableMap.of()))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ @Test
+ public void testCanRenameSchema()
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ CatalogSchemaName sourceSchema = new CatalogSchemaName("my_catalog", "my_schema");
+ authorizer.checkCanRenameSchema(requestingSecurityContext, sourceSchema, "new_schema_name");
+
+ String expectedRequest = """
+ {
+ "operation": "RenameSchema",
+ "resource": {
+ "schema": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema"
+ }
+ },
+ "targetResource": {
+ "schema": {
+ "catalogName": "my_catalog",
+ "schemaName": "new_schema_name"
+ }
+ }
+ }
+ """;
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action");
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestHelpers#allErrorCasesArgumentProvider")
+ public void testCanRenameSchemaFailure(
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ assertThatThrownBy(
+ () -> authorizer.checkCanRenameSchema(
+ requestingSecurityContext,
+ new CatalogSchemaName("my_catalog", "my_schema"),
+ "new_schema_name"))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ private static Stream renameTableTestCases()
+ {
+ Stream> methods = Stream.of(
+ OpaAccessControl::checkCanRenameTable,
+ OpaAccessControl::checkCanRenameView,
+ OpaAccessControl::checkCanRenameMaterializedView);
+ Stream actions = Stream.of(
+ "RenameTable",
+ "RenameView",
+ "RenameMaterializedView");
+ return Streams.zip(actions, methods, (action, method) -> Arguments.of(Named.of(action, action), method));
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#renameTableTestCases")
+ public void testRenameTableActions(
+ String actionName,
+ FunctionalHelpers.Consumer4 method)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ CatalogSchemaTableName sourceTable = new CatalogSchemaTableName("my_catalog", "my_schema", "my_table");
+ CatalogSchemaTableName targetTable = new CatalogSchemaTableName("my_catalog", "new_schema_name", "new_table_name");
+
+ method.accept(authorizer, requestingSecurityContext, sourceTable, targetTable);
+
+ String expectedRequest = """
+ {
+ "operation": "%s",
+ "resource": {
+ "table": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema",
+ "tableName": "my_table"
+ }
+ },
+ "targetResource": {
+ "table": {
+ "catalogName": "my_catalog",
+ "schemaName": "new_schema_name",
+ "tableName": "new_table_name"
+ }
+ }
+ }
+ """.formatted(actionName);
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action");
+ }
+
+ public static Stream renameTableFailureTestCases()
+ {
+ return createFailingTestCases(renameTableTestCases());
+ }
+
+ @ParameterizedTest(name = "{index}: {0} - {3}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#renameTableFailureTestCases")
+ public void testRenameTableFailure(
+ String actionName,
+ FunctionalHelpers.Consumer4 method,
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ CatalogSchemaTableName sourceTable = new CatalogSchemaTableName("my_catalog", "my_schema", "my_table");
+ CatalogSchemaTableName targetTable = new CatalogSchemaTableName("my_catalog", "new_schema_name", "new_table_name");
+ assertThatThrownBy(
+ () -> method.accept(
+ authorizer,
+ requestingSecurityContext,
+ sourceTable,
+ targetTable))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ @Test
+ public void testCanSetSchemaAuthorization()
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ CatalogSchemaName schema = new CatalogSchemaName("my_catalog", "my_schema");
+
+ authorizer.checkCanSetSchemaAuthorization(requestingSecurityContext, schema, new TrinoPrincipal(PrincipalType.USER, "my_user"));
+
+ String expectedRequest = """
+ {
+ "operation": "SetSchemaAuthorization",
+ "resource": {
+ "schema": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema"
+ }
+ },
+ "grantee": {
+ "name": "my_user",
+ "type": "USER"
+ }
+ }
+ """;
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action");
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestHelpers#allErrorCasesArgumentProvider")
+ public void testCanSetSchemaAuthorizationFailure(
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ CatalogSchemaName schema = new CatalogSchemaName("my_catalog", "my_schema");
+ assertThatThrownBy(
+ () -> authorizer.checkCanSetSchemaAuthorization(
+ requestingSecurityContext,
+ schema,
+ new TrinoPrincipal(PrincipalType.USER, "my_user")))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ private static Stream setTableAuthorizationTestCases()
+ {
+ Stream> methods = Stream.of(
+ OpaAccessControl::checkCanSetTableAuthorization,
+ OpaAccessControl::checkCanSetViewAuthorization);
+ Stream actions = Stream.of(
+ "SetTableAuthorization",
+ "SetViewAuthorization");
+ return Streams.zip(actions, methods, (action, method) -> Arguments.of(Named.of(action, action), method));
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#setTableAuthorizationTestCases")
+ public void testCanSetTableAuthorization(
+ String actionName,
+ FunctionalHelpers.Consumer4 method)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ CatalogSchemaTableName table = new CatalogSchemaTableName("my_catalog", "my_schema", "my_table");
+
+ method.accept(authorizer, requestingSecurityContext, table, new TrinoPrincipal(PrincipalType.USER, "my_user"));
+
+ String expectedRequest = """
+ {
+ "operation": "%s",
+ "resource": {
+ "table": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema",
+ "tableName": "my_table"
+ }
+ },
+ "grantee": {
+ "name": "my_user",
+ "type": "USER"
+ }
+ }
+ """.formatted(actionName);
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action");
+ }
+
+ private static Stream setTableAuthorizationFailureTestCases()
+ {
+ return createFailingTestCases(setTableAuthorizationTestCases());
+ }
+
+ @ParameterizedTest(name = "{index}: {0} - {3}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#setTableAuthorizationFailureTestCases")
+ public void testCanSetTableAuthorizationFailure(
+ String actionName,
+ FunctionalHelpers.Consumer4 method,
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ CatalogSchemaTableName table = new CatalogSchemaTableName("my_catalog", "my_schema", "my_table");
+
+ assertThatThrownBy(
+ () -> method.accept(
+ authorizer,
+ requestingSecurityContext,
+ table,
+ new TrinoPrincipal(PrincipalType.USER, "my_user")))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ private static Stream tableColumnOperationTestCases()
+ {
+ Stream>> methods = Stream.of(
+ OpaAccessControl::checkCanSelectFromColumns,
+ OpaAccessControl::checkCanUpdateTableColumns,
+ OpaAccessControl::checkCanCreateViewWithSelectFromColumns);
+ Stream actionAndResource = Stream.of(
+ "SelectFromColumns",
+ "UpdateTableColumns",
+ "CreateViewWithSelectFromColumns");
+ return Streams.zip(actionAndResource, methods, (action, method) -> Arguments.of(Named.of(action, action), method));
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#tableColumnOperationTestCases")
+ public void testTableColumnOperations(
+ String actionName,
+ FunctionalHelpers.Consumer4> method)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ CatalogSchemaTableName table = new CatalogSchemaTableName("my_catalog", "my_schema", "my_table");
+ Set columns = ImmutableSet.of("my_column");
+
+ method.accept(authorizer, requestingSecurityContext, table, columns);
+
+ String expectedRequest = """
+ {
+ "operation": "%s",
+ "resource": {
+ "table": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema",
+ "tableName": "my_table",
+ "columns": ["my_column"]
+ }
+ }
+ }
+ """.formatted(actionName);
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action");
+ }
+
+ private static Stream tableColumnOperationFailureTestCases()
+ {
+ return createFailingTestCases(tableColumnOperationTestCases());
+ }
+
+ @ParameterizedTest(name = "{index}: {0} - {2}")
+ @MethodSource("io.trino.plugin.opa.TestOpaAccessControl#tableColumnOperationFailureTestCases")
+ public void testTableColumnOperationsFailure(
+ String actionName,
+ FunctionalHelpers.Consumer4> method,
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ CatalogSchemaTableName table = new CatalogSchemaTableName("my_catalog", "my_schema", "my_table");
+ Set columns = ImmutableSet.of("my_column");
+
+ assertThatThrownBy(
+ () -> method.accept(authorizer, requestingSecurityContext, table, columns))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ @Test
+ public void testCanSetCatalogSessionProperty()
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, OK_RESPONSE));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ authorizer.checkCanSetCatalogSessionProperty(
+ requestingSecurityContext, "my_catalog", "my_property");
+
+ String expectedRequest = """
+ {
+ "operation": "SetCatalogSessionProperty",
+ "resource": {
+ "catalogSessionProperty": {
+ "catalogName": "my_catalog",
+ "propertyName": "my_property"
+ }
+ }
+ }
+ """;
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action");
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.TestHelpers#allErrorCasesArgumentProvider")
+ public void testCanSetCatalogSessionPropertyFailure(
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ assertThatThrownBy(
+ () -> authorizer.checkCanSetCatalogSessionProperty(
+ requestingSecurityContext,
+ "my_catalog",
+ "my_property"))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+
+ @Test
+ public void testFunctionResourceActions()
+ {
+ CatalogSchemaRoutineName routine = new CatalogSchemaRoutineName("my_catalog", "my_schema", "my_routine_name");
+ String baseRequest = """
+ {
+ "operation": "%s",
+ "resource": {
+ "function": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema",
+ "functionName": "my_routine_name"
+ }
+ }
+ }""";
+ assertAccessControlMethodBehaviour(
+ new TestHelpers.ThrowingMethodWrapper(authorizer -> authorizer.checkCanExecuteProcedure(TEST_SECURITY_CONTEXT, routine)),
+ ImmutableSet.of(baseRequest.formatted("ExecuteProcedure")));
+ assertAccessControlMethodBehaviour(
+ new TestHelpers.ThrowingMethodWrapper(authorizer -> authorizer.checkCanCreateFunction(TEST_SECURITY_CONTEXT, routine)),
+ ImmutableSet.of(baseRequest.formatted("CreateFunction")));
+ assertAccessControlMethodBehaviour(
+ new TestHelpers.ThrowingMethodWrapper(authorizer -> authorizer.checkCanDropFunction(TEST_SECURITY_CONTEXT, routine)),
+ ImmutableSet.of(baseRequest.formatted("DropFunction")));
+ assertAccessControlMethodBehaviour(
+ new TestHelpers.ReturningMethodWrapper(authorizer -> authorizer.canExecuteFunction(TEST_SECURITY_CONTEXT, routine)),
+ ImmutableSet.of(baseRequest.formatted("ExecuteFunction")));
+ assertAccessControlMethodBehaviour(
+ new TestHelpers.ReturningMethodWrapper(authorizer -> authorizer.canCreateViewWithExecuteFunction(TEST_SECURITY_CONTEXT, routine)),
+ ImmutableSet.of(baseRequest.formatted("CreateViewWithExecuteFunction")));
+ }
+
+ @Test
+ public void testCanExecuteTableProcedure()
+ {
+ CatalogSchemaTableName table = new CatalogSchemaTableName("my_catalog", "my_schema", "my_table");
+ String expectedRequest = """
+ {
+ "operation": "ExecuteTableProcedure",
+ "resource": {
+ "table": {
+ "catalogName": "my_catalog",
+ "schemaName": "my_schema",
+ "tableName": "my_table"
+ },
+ "function": {
+ "functionName": "my_procedure"
+ }
+ }
+ }""";
+ assertAccessControlMethodBehaviour(
+ new TestHelpers.ThrowingMethodWrapper(authorizer -> authorizer.checkCanExecuteTableProcedure(TEST_SECURITY_CONTEXT, table, "my_procedure")),
+ ImmutableSet.of(expectedRequest));
+ }
+
+ @Test
+ public void testRequestContextContentsWithKnownTrinoVersion()
+ {
+ testRequestContextContentsForGivenTrinoVersion(
+ Optional.of(new TestingSystemAccessControlContext("12345.67890")),
+ "12345.67890");
+ }
+
+ @Test
+ public void testRequestContextContentsWithUnknownTrinoVersion()
+ {
+ testRequestContextContentsForGivenTrinoVersion(Optional.empty(), "UNKNOWN");
+ }
+
+ private void testRequestContextContentsForGivenTrinoVersion(Optional accessControlContext, String expectedTrinoVersion)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, request -> OK_RESPONSE);
+ OpaAccessControl authorizer = (OpaAccessControl) OpaAccessControlFactory.create(
+ ImmutableMap.of("opa.policy.uri", OPA_SERVER_URI.toString()),
+ Optional.of(mockClient),
+ accessControlContext);
+ Identity sampleIdentityWithGroups = Identity.forUser("test_user").withGroups(ImmutableSet.of("some_group")).build();
+
+ authorizer.checkCanExecuteQuery(sampleIdentityWithGroups);
+
+ String expectedRequest = """
+ {
+ "action": {
+ "operation": "ExecuteQuery"
+ },
+ "context": {
+ "identity": {
+ "user": "test_user",
+ "groups": ["some_group"]
+ },
+ "softwareStack": {
+ "trinoVersion": "%s"
+ }
+ }
+ }""".formatted(expectedTrinoVersion);
+ assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input");
+ }
+
+ private static void assertAccessControlMethodBehaviour(MethodWrapper method, Set expectedRequests)
+ {
+ InstrumentedHttpClient permissiveMockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(TEST_IDENTITY, OK_RESPONSE));
+ InstrumentedHttpClient restrictiveMockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(TEST_IDENTITY, NO_ACCESS_RESPONSE));
+
+ assertThat(method.isAccessAllowed(createOpaAuthorizer(OPA_SERVER_URI, permissiveMockClient))).isTrue();
+ assertThat(method.isAccessAllowed(createOpaAuthorizer(OPA_SERVER_URI, restrictiveMockClient))).isFalse();
+ assertThat(permissiveMockClient.getRequests()).containsExactlyInAnyOrderElementsOf(restrictiveMockClient.getRequests());
+ assertStringRequestsEqual(expectedRequests, permissiveMockClient.getRequests(), "/input/action");
+ assertAccessControlMethodThrowsForIllegalResponses(method);
+ }
+
+ private static void assertAccessControlMethodThrowsForIllegalResponses(MethodWrapper methodToTest)
+ {
+ assertAccessControlMethodThrowsForResponse(methodToTest, UNDEFINED_RESPONSE, OpaQueryException.OpaServerError.PolicyNotFound.class, "did not return a value");
+ assertAccessControlMethodThrowsForResponse(methodToTest, BAD_REQUEST_RESPONSE, OpaQueryException.OpaServerError.class, "returned status 400");
+ assertAccessControlMethodThrowsForResponse(methodToTest, SERVER_ERROR_RESPONSE, OpaQueryException.OpaServerError.class, "returned status 500");
+ assertAccessControlMethodThrowsForResponse(methodToTest, MALFORMED_RESPONSE, OpaQueryException.class, "Failed to deserialize");
+ }
+
+ private static void assertAccessControlMethodThrowsForResponse(
+ MethodWrapper methodToTest,
+ MockResponse response,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(TEST_IDENTITY, response));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ assertThatThrownBy(() -> methodToTest.isAccessAllowed(authorizer))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ }
+}
diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlFactory.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlFactory.java
new file mode 100644
index 0000000000000..e24fb0dfc5419
--- /dev/null
+++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlFactory.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.google.common.collect.ImmutableMap;
+import io.airlift.bootstrap.ApplicationConfigurationException;
+import io.trino.spi.security.SystemAccessControl;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class TestOpaAccessControlFactory
+{
+ @Test
+ public void testCreatesSimpleAuthorizerIfNoBatchUriProvided()
+ {
+ OpaAccessControlFactory factory = new OpaAccessControlFactory();
+ SystemAccessControl opaAuthorizer = factory.create(ImmutableMap.of("opa.policy.uri", "foo"));
+
+ assertThat(opaAuthorizer).isInstanceOf(OpaAccessControl.class);
+ assertThat(opaAuthorizer).isNotInstanceOf(OpaBatchAccessControl.class);
+ }
+
+ @Test
+ public void testCreatesBatchAuthorizerIfBatchUriProvided()
+ {
+ OpaAccessControlFactory factory = new OpaAccessControlFactory();
+ SystemAccessControl opaAuthorizer = factory.create(
+ ImmutableMap.builder()
+ .put("opa.policy.uri", "foo")
+ .put("opa.policy.batched-uri", "bar")
+ .buildOrThrow());
+
+ assertThat(opaAuthorizer).isInstanceOf(OpaBatchAccessControl.class);
+ assertThat(opaAuthorizer).isInstanceOf(OpaAccessControl.class);
+ }
+
+ @Test
+ public void testBasePolicyUriCannotBeUnset()
+ {
+ OpaAccessControlFactory factory = new OpaAccessControlFactory();
+
+ assertThatThrownBy(() -> factory.create(ImmutableMap.of())).isInstanceOf(ApplicationConfigurationException.class);
+ }
+
+ @Test
+ public void testConfigMayNotBeNull()
+ {
+ OpaAccessControlFactory factory = new OpaAccessControlFactory();
+
+ assertThatThrownBy(() -> factory.create(null)).isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ public void testSupportsAirliftHttpConfigs()
+ {
+ OpaAccessControlFactory factory = new OpaAccessControlFactory();
+ SystemAccessControl opaAuthorizer = factory.create(
+ ImmutableMap.builder()
+ .put("opa.policy.uri", "foo")
+ .put("opa.http-client.log.enabled", "true")
+ .buildOrThrow());
+
+ assertThat(opaAuthorizer).isInstanceOf(OpaAccessControl.class);
+ assertThat(opaAuthorizer).isNotInstanceOf(OpaBatchAccessControl.class);
+ }
+}
diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlFiltering.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlFiltering.java
new file mode 100644
index 0000000000000..bd97a034e52b6
--- /dev/null
+++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlFiltering.java
@@ -0,0 +1,339 @@
+/*
+ * Licensed 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 io.trino.plugin.opa;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import io.trino.plugin.opa.HttpClientUtils.InstrumentedHttpClient;
+import io.trino.plugin.opa.HttpClientUtils.MockResponse;
+import io.trino.spi.connector.SchemaTableName;
+import io.trino.spi.function.SchemaFunctionName;
+import io.trino.spi.security.Identity;
+import io.trino.spi.security.SystemSecurityContext;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.net.URI;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static io.trino.plugin.opa.RequestTestUtilities.assertStringRequestsEqual;
+import static io.trino.plugin.opa.RequestTestUtilities.buildValidatingRequestHandler;
+import static io.trino.plugin.opa.TestHelpers.NO_ACCESS_RESPONSE;
+import static io.trino.plugin.opa.TestHelpers.OK_RESPONSE;
+import static io.trino.plugin.opa.TestHelpers.createMockHttpClient;
+import static io.trino.plugin.opa.TestHelpers.createOpaAuthorizer;
+import static io.trino.plugin.opa.TestHelpers.systemSecurityContextFromIdentity;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class TestOpaAccessControlFiltering
+{
+ private static final URI OPA_SERVER_URI = URI.create("http://my-uri/");
+ private final Identity requestingIdentity = Identity.ofUser("source-user");
+ private final SystemSecurityContext requestingSecurityContext = systemSecurityContextFromIdentity(requestingIdentity);
+
+ @Test
+ public void testFilterViewQueryOwnedBy()
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildHandler("/input/action/resource/user/user", "user-one"));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ Identity userOne = Identity.ofUser("user-one");
+ Identity userTwo = Identity.ofUser("user-two");
+ List requestedIdentities = ImmutableList.of(userOne, userTwo);
+
+ Collection result = authorizer.filterViewQueryOwnedBy(
+ requestingIdentity,
+ requestedIdentities);
+ assertThat(result).containsExactly(userOne);
+
+ Set expectedRequests = ImmutableSet.builder()
+ .add("""
+ {
+ "operation": "FilterViewQueryOwnedBy",
+ "resource": {
+ "user": {
+ "user": "user-one",
+ "groups": []
+ }
+ }
+ }
+ """)
+ .add("""
+ {
+ "operation": "FilterViewQueryOwnedBy",
+ "resource": {
+ "user": {
+ "user": "user-two",
+ "groups": []
+ }
+ }
+ }
+ """)
+ .build();
+ assertStringRequestsEqual(expectedRequests, mockClient.getRequests(), "/input/action");
+ }
+
+ @Test
+ public void testFilterCatalogs()
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildHandler("/input/action/resource/catalog/name", "catalog_two"));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ Set requestedCatalogs = ImmutableSet.of("catalog_one", "catalog_two");
+ Set result = authorizer.filterCatalogs(
+ requestingSecurityContext,
+ requestedCatalogs);
+ assertThat(result).containsExactly("catalog_two");
+
+ Set expectedRequests = ImmutableSet.builder()
+ .add("""
+ {
+ "operation": "FilterCatalogs",
+ "resource": {
+ "catalog": {
+ "name": "catalog_one"
+ }
+ }
+ }
+ """)
+ .add("""
+ {
+ "operation": "FilterCatalogs",
+ "resource": {
+ "catalog": {
+ "name": "catalog_two"
+ }
+ }
+ }
+ """)
+ .build();
+ assertStringRequestsEqual(expectedRequests, mockClient.getRequests(), "/input/action");
+ }
+
+ @Test
+ public void testFilterSchemas()
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildHandler("/input/action/resource/schema/schemaName", "schema_one"));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ Set requestedSchemas = ImmutableSet.of("schema_one", "schema_two");
+
+ Set result = authorizer.filterSchemas(
+ requestingSecurityContext,
+ "my_catalog",
+ requestedSchemas);
+ assertThat(result).containsExactly("schema_one");
+
+ Set expectedRequests = requestedSchemas.stream()
+ .map("""
+ {
+ "operation": "FilterSchemas",
+ "resource": {
+ "schema": {
+ "schemaName": "%s",
+ "catalogName": "my_catalog"
+ }
+ }
+ }
+ """::formatted)
+ .collect(toImmutableSet());
+ assertStringRequestsEqual(expectedRequests, mockClient.getRequests(), "/input/action");
+ }
+
+ @Test
+ public void testFilterTables()
+ {
+ Set tables = ImmutableSet.builder()
+ .add(new SchemaTableName("schema_one", "table_one"))
+ .add(new SchemaTableName("schema_one", "table_two"))
+ .add(new SchemaTableName("schema_two", "table_one"))
+ .add(new SchemaTableName("schema_two", "table_two"))
+ .build();
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildHandler("/input/action/resource/table/tableName", "table_one"));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ Set result = authorizer.filterTables(requestingSecurityContext, "my_catalog", tables);
+ assertThat(result).containsExactlyInAnyOrderElementsOf(tables.stream().filter(table -> table.getTableName().equals("table_one")).collect(toImmutableSet()));
+
+ Set expectedRequests = tables.stream()
+ .map(table -> """
+ {
+ "operation": "FilterTables",
+ "resource": {
+ "table": {
+ "tableName": "%s",
+ "schemaName": "%s",
+ "catalogName": "my_catalog"
+ }
+ }
+ }
+ """.formatted(table.getTableName(), table.getSchemaName()))
+ .collect(toImmutableSet());
+ assertStringRequestsEqual(expectedRequests, mockClient.getRequests(), "/input/action");
+ }
+
+ @Test
+ public void testFilterColumns()
+ {
+ SchemaTableName tableOne = SchemaTableName.schemaTableName("my_schema", "table_one");
+ SchemaTableName tableTwo = SchemaTableName.schemaTableName("my_schema", "table_two");
+ SchemaTableName tableThree = SchemaTableName.schemaTableName("my_schema", "table_three");
+ Map> requestedColumns = ImmutableMap.>builder()
+ .put(tableOne, ImmutableSet.of("table_one_column_one", "table_one_column_two"))
+ .put(tableTwo, ImmutableSet.of("table_two_column_one", "table_two_column_two"))
+ .put(tableThree, ImmutableSet.of("table_three_column_one", "table_three_column_two"))
+ .buildOrThrow();
+ // Allow both columns from one table, one column from another one and no columns from the last one
+ Set columnsToAllow = ImmutableSet.builder()
+ .add("table_one_column_one")
+ .add("table_one_column_two")
+ .add("table_two_column_two")
+ .build();
+
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildHandler("/input/action/resource/table/columns/0", columnsToAllow));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ Map> result = authorizer.filterColumns(requestingSecurityContext, "my_catalog", requestedColumns);
+
+ Set expectedRequests = requestedColumns.entrySet().stream()
+ .mapMulti(
+ (requestedColumnsForTable, accepter) -> requestedColumnsForTable.getValue().forEach(
+ column -> accepter.accept("""
+ {
+ "operation": "FilterColumns",
+ "resource": {
+ "table": {
+ "tableName": "%s",
+ "schemaName": "my_schema",
+ "catalogName": "my_catalog",
+ "columns": ["%s"]
+ }
+ }
+ }
+ """.formatted(requestedColumnsForTable.getKey().getTableName(), column))))
+ .collect(toImmutableSet());
+ assertStringRequestsEqual(expectedRequests, mockClient.getRequests(), "/input/action");
+ assertThat(result).containsExactlyInAnyOrderEntriesOf(
+ ImmutableMap.>builder()
+ .put(tableOne, ImmutableSet.of("table_one_column_one", "table_one_column_two"))
+ .put(tableTwo, ImmutableSet.of("table_two_column_two"))
+ .buildOrThrow());
+ }
+
+ @Test
+ public void testEmptyFilterColumns()
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, request -> OK_RESPONSE);
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ SchemaTableName someTable = SchemaTableName.schemaTableName("my_schema", "my_table");
+ Map> requestedColumns = ImmutableMap.of(someTable, ImmutableSet.of());
+
+ Map> result = authorizer.filterColumns(
+ requestingSecurityContext,
+ "my_catalog",
+ requestedColumns);
+
+ assertThat(mockClient.getRequests()).isEmpty();
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ public void testFilterFunctions()
+ {
+ SchemaFunctionName functionOne = new SchemaFunctionName("my_schema", "function_one");
+ SchemaFunctionName functionTwo = new SchemaFunctionName("my_schema", "function_two");
+ Set requestedFunctions = ImmutableSet.of(functionOne, functionTwo);
+
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildHandler("/input/action/resource/function/functionName", "function_two"));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ Set result = authorizer.filterFunctions(
+ requestingSecurityContext,
+ "my_catalog",
+ requestedFunctions);
+ assertThat(result).containsExactly(functionTwo);
+
+ Set expectedRequests = requestedFunctions.stream()
+ .map(function -> """
+ {
+ "operation": "FilterFunctions",
+ "resource": {
+ "function": {
+ "catalogName": "my_catalog",
+ "schemaName": "%s",
+ "functionName": "%s"
+ }
+ }
+ }""".formatted(function.getSchemaName(), function.getFunctionName()))
+ .collect(toImmutableSet());
+ assertStringRequestsEqual(expectedRequests, mockClient.getRequests(), "/input/action");
+ }
+
+ @ParameterizedTest(name = "{index}: {0}")
+ @MethodSource("io.trino.plugin.opa.FilteringTestHelpers#emptyInputTestCases")
+ public void testEmptyRequests(
+ BiFunction callable)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, request -> OK_RESPONSE);
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ Collection> result = callable.apply(authorizer, requestingSecurityContext);
+ assertThat(result).isEmpty();
+ assertThat(mockClient.getRequests()).isEmpty();
+ }
+
+ @ParameterizedTest(name = "{index}: {0} - {1}")
+ @MethodSource("io.trino.plugin.opa.FilteringTestHelpers#prepopulatedErrorCases")
+ public void testIllegalResponseThrows(
+ BiFunction callable,
+ MockResponse failureResponse,
+ Class extends Throwable> expectedException,
+ String expectedErrorMessage)
+ {
+ InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse));
+ OpaAccessControl authorizer = createOpaAuthorizer(OPA_SERVER_URI, mockClient);
+
+ assertThatThrownBy(() -> callable.apply(authorizer, requestingSecurityContext))
+ .isInstanceOf(expectedException)
+ .hasMessageContaining(expectedErrorMessage);
+ assertThat(mockClient.getRequests()).hasSize(1);
+ }
+
+ private Function buildHandler(String jsonPath, Set