diff --git a/core/trino-server/src/main/provisio/trino.xml b/core/trino-server/src/main/provisio/trino.xml index a11e4ade027e5..eb69c2a406970 100644 --- a/core/trino-server/src/main/provisio/trino.xml +++ b/core/trino-server/src/main/provisio/trino.xml @@ -319,4 +319,10 @@ + + + + + + diff --git a/plugin/trino-opa/README.md b/plugin/trino-opa/README.md new file mode 100644 index 0000000000000..93b668a1b80bf --- /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-permissioning-operations` | No | `false` | Determines whether permissioning 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 permissioning operations + +The following operations are controlled by the `opa.allow-permissioning-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..27b1d5526fafb --- /dev/null +++ b/plugin/trino-opa/pom.xml @@ -0,0 +1,174 @@ + + + 4.0.0 + + io.trino + trino-root + 436-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-main + test-jar + 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..fafd8a9d8cd16 --- /dev/null +++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControl.java @@ -0,0 +1,791 @@ +/* + * 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.OpaViewExpression; +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 io.trino.spi.security.ViewExpression; +import io.trino.spi.type.Type; + +import java.security.Principal; +import java.util.Collection; +import java.util.List; +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.ImmutableList.toImmutableList; +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 allowPermissioningOperations; + private final OpaPluginContext pluginContext; + + @Inject + public OpaAccessControl(OpaHighLevelClient opaHighLevelClient, OpaConfig config, OpaPluginContext pluginContext) + { + this.opaHighLevelClient = requireNonNull(opaHighLevelClient, "opaHighLevelClient is null"); + this.allowPermissioningOperations = config.getAllowPermissioningOperations(); + 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) + { + enforcePermissioningOperation(AccessDeniedException::denyGrantSchemaPrivilege, privilege.toString(), schema.toString()); + } + + @Override + public void checkCanDenySchemaPrivilege(SystemSecurityContext context, Privilege privilege, CatalogSchemaName schema, TrinoPrincipal grantee) + { + enforcePermissioningOperation(AccessDeniedException::denyDenySchemaPrivilege, privilege.toString(), schema.toString()); + } + + @Override + public void checkCanRevokeSchemaPrivilege(SystemSecurityContext context, Privilege privilege, CatalogSchemaName schema, TrinoPrincipal revokee, boolean grantOption) + { + enforcePermissioningOperation(AccessDeniedException::denyRevokeSchemaPrivilege, privilege.toString(), schema.toString()); + } + + @Override + public void checkCanGrantTablePrivilege(SystemSecurityContext context, Privilege privilege, CatalogSchemaTableName table, TrinoPrincipal grantee, boolean grantOption) + { + enforcePermissioningOperation(AccessDeniedException::denyGrantTablePrivilege, privilege.toString(), table.toString()); + } + + @Override + public void checkCanDenyTablePrivilege(SystemSecurityContext context, Privilege privilege, CatalogSchemaTableName table, TrinoPrincipal grantee) + { + enforcePermissioningOperation(AccessDeniedException::denyDenyTablePrivilege, privilege.toString(), table.toString()); + } + + @Override + public void checkCanRevokeTablePrivilege(SystemSecurityContext context, Privilege privilege, CatalogSchemaTableName table, TrinoPrincipal revokee, boolean grantOption) + { + enforcePermissioningOperation(AccessDeniedException::denyRevokeTablePrivilege, privilege.toString(), table.toString()); + } + + @Override + public void checkCanCreateRole(SystemSecurityContext context, String role, Optional grantor) + { + enforcePermissioningOperation(AccessDeniedException::denyCreateRole, role); + } + + @Override + public void checkCanDropRole(SystemSecurityContext context, String role) + { + enforcePermissioningOperation(AccessDeniedException::denyDropRole, role); + } + + @Override + public void checkCanGrantRoles(SystemSecurityContext context, Set roles, Set grantees, boolean adminOption, Optional grantor) + { + enforcePermissioningOperation(AccessDeniedException::denyGrantRoles, roles, grantees); + } + + @Override + public void checkCanRevokeRoles(SystemSecurityContext context, Set roles, Set grantees, boolean adminOption, Optional grantor) + { + enforcePermissioningOperation(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()); + } + + @Override + public List getRowFilters(SystemSecurityContext context, CatalogSchemaTableName tableName) + { + List rowFilterExpressions = opaHighLevelClient.getRowFilterExpressionsFromOpa(buildQueryContext(context), tableName); + return rowFilterExpressions.stream() + .map(expression -> expression.toTrinoViewExpression(tableName.getCatalogName(), tableName.getSchemaTableName().getSchemaName())) + .collect(toImmutableList()); + } + + @Override + public Optional getColumnMask(SystemSecurityContext context, CatalogSchemaTableName tableName, String columnName, Type type) + { + return opaHighLevelClient + .getColumnMaskFromOpa(buildQueryContext(context), tableName, columnName, type) + .map(expression -> expression.toTrinoViewExpression(tableName.getCatalogName(), tableName.getSchemaTableName().getSchemaName())); + } + + 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 enforcePermissioningOperation(Consumer deny, T arg) + { + if (!allowPermissioningOperations) { + deny.accept(arg); + } + } + + private void enforcePermissioningOperation(BiConsumer deny, T arg1, U arg2) + { + if (!allowPermissioningOperations) { + 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..6aa6f45dc7739 --- /dev/null +++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaAccessControlFactory.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.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.OpaColumnMaskQueryResult; +import io.trino.plugin.opa.schema.OpaPluginContext; +import io.trino.plugin.opa.schema.OpaQuery; +import io.trino.plugin.opa.schema.OpaQueryResult; +import io.trino.plugin.opa.schema.OpaRowFiltersQueryResult; +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); + jsonCodecBinder(binder).bindJsonCodec(OpaRowFiltersQueryResult.class); + jsonCodecBinder(binder).bindJsonCodec(OpaColumnMaskQueryResult.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..d215c8c0b046e --- /dev/null +++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaConfig.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 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 allowPermissioningOperations; + private Optional opaRowFiltersUri = Optional.empty(); + private Optional opaColumnMaskingUri = Optional.empty(); + + @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; + } + + @NotNull + 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 getAllowPermissioningOperations() + { + return this.allowPermissioningOperations; + } + + @Config("opa.allow-permissioning-operations") + @ConfigDescription("Whether to allow permissioning operations (GRANT, DENY, ...) as well as role management - OPA will not be queried for any such operations, they will be bulk allowed or denied depending on this setting") + public OpaConfig setAllowPermissioningOperations(boolean allowPermissioningOperations) + { + this.allowPermissioningOperations = allowPermissioningOperations; + return this; + } + + @NotNull + public Optional getOpaRowFiltersUri() + { + return opaRowFiltersUri; + } + + @Config("opa.policy.row-filters-uri") + @ConfigDescription("URI for fetching row filters - if not set no row filtering will be applied") + public OpaConfig setOpaRowFiltersUri(@NotNull URI opaRowFiltersUri) + { + this.opaRowFiltersUri = Optional.ofNullable(opaRowFiltersUri); + return this; + } + + @NotNull + public Optional getOpaColumnMaskingUri() + { + return opaColumnMaskingUri; + } + + @Config("opa.policy.column-masking-uri") + @ConfigDescription("URI for fetching column masks - if not set no masking will be applied") + public OpaConfig setOpaColumnMaskingUri(URI opaColumnMaskingUri) + { + this.opaColumnMaskingUri = Optional.ofNullable(opaColumnMaskingUri); + 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..054e187f97a39 --- /dev/null +++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/OpaHighLevelClient.java @@ -0,0 +1,162 @@ +/* + * 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.inject.Inject; +import io.airlift.json.JsonCodec; +import io.trino.plugin.opa.schema.OpaColumnMaskQueryResult; +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.plugin.opa.schema.OpaRowFiltersQueryResult; +import io.trino.plugin.opa.schema.OpaViewExpression; +import io.trino.plugin.opa.schema.TrinoColumn; +import io.trino.plugin.opa.schema.TrinoTable; +import io.trino.spi.connector.CatalogSchemaTableName; +import io.trino.spi.security.AccessDeniedException; +import io.trino.spi.type.Type; + +import java.net.URI; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +import static java.util.Objects.requireNonNull; + +public class OpaHighLevelClient +{ + private final JsonCodec queryResultCodec; + private final JsonCodec rowFiltersQueryResultCodec; + private final JsonCodec columnMaskQueryResultCodec; + private final OpaHttpClient opaHttpClient; + private final URI opaPolicyUri; + private final Optional opaRowFiltersUri; + private final Optional opaColumnMaskingUri; + + @Inject + public OpaHighLevelClient( + JsonCodec queryResultCodec, + JsonCodec rowFiltersQueryResultCodec, + JsonCodec columnMaskQueryResultCodec, + OpaHttpClient opaHttpClient, + OpaConfig config) + { + this.queryResultCodec = requireNonNull(queryResultCodec, "queryResultCodec is null"); + this.rowFiltersQueryResultCodec = requireNonNull(rowFiltersQueryResultCodec, "rowFiltersQueryResultCodec is null"); + this.columnMaskQueryResultCodec = requireNonNull(columnMaskQueryResultCodec, "columnMaskQueryResultCodec is null"); + this.opaHttpClient = requireNonNull(opaHttpClient, "opaHttpClient is null"); + this.opaPolicyUri = config.getOpaUri(); + this.opaRowFiltersUri = config.getOpaRowFiltersUri(); + this.opaColumnMaskingUri = config.getOpaColumnMaskingUri(); + } + + 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 List getRowFilterExpressionsFromOpa(OpaQueryContext context, CatalogSchemaTableName table) + { + OpaQueryInput queryInput = new OpaQueryInput( + context, + OpaQueryInputAction.builder() + .operation("GetRowFilters") + .resource(OpaQueryInputResource.builder().table(new TrinoTable(table)).build()) + .build()); + return opaRowFiltersUri + .map(uri -> opaHttpClient.consumeOpaResponse(opaHttpClient.submitOpaRequest(queryInput, uri, rowFiltersQueryResultCodec)).result()) + .orElse(ImmutableList.of()); + } + + public Optional getColumnMaskFromOpa(OpaQueryContext context, CatalogSchemaTableName table, String columnName, Type type) + { + OpaQueryInput queryInput = new OpaQueryInput( + context, + OpaQueryInputAction.builder() + .operation("GetColumnMask") + .resource(OpaQueryInputResource.builder().column(new TrinoColumn(table, columnName, type)).build()) + .build()); + return opaColumnMaskingUri + .flatMap(uri -> opaHttpClient.consumeOpaResponse(opaHttpClient.submitOpaRequest(queryInput, uri, columnMaskQueryResultCodec)).result()); + } + + 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 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 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 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/OpaColumnMaskQueryResult.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaColumnMaskQueryResult.java new file mode 100644 index 0000000000000..d19ac46c1c262 --- /dev/null +++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaColumnMaskQueryResult.java @@ -0,0 +1,29 @@ +/* + * 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 jakarta.validation.constraints.NotNull; + +import java.util.Optional; + +import static java.util.Objects.requireNonNull; + +public record OpaColumnMaskQueryResult(@JsonProperty("decision_id") String decisionId, @NotNull Optional result) +{ + public OpaColumnMaskQueryResult + { + requireNonNull(result, "result is null"); + } +} 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..a6468fa305a85 --- /dev/null +++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaQueryInputResource.java @@ -0,0 +1,126 @@ +/* + * 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, + TrinoColumn column) +{ + 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 TrinoColumn column; + + 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 Builder column(TrinoColumn column) + { + this.column = column; + return this; + } + + public OpaQueryInputResource build() + { + return new OpaQueryInputResource( + this.user, + this.systemSessionProperty, + this.catalogSessionProperty, + this.function, + this.catalog, + this.schema, + this.table, + this.column); + } + } +} 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/OpaRowFiltersQueryResult.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaRowFiltersQueryResult.java new file mode 100644 index 0000000000000..98e6f5b5facdd --- /dev/null +++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaRowFiltersQueryResult.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 OpaRowFiltersQueryResult(@JsonProperty("decision_id") String decisionId, @NotNull List result) +{ + public OpaRowFiltersQueryResult + { + result = ImmutableList.copyOf(requireNonNullElse(result, ImmutableList.of())); + } +} diff --git a/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaViewExpression.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaViewExpression.java new file mode 100644 index 0000000000000..5d5bd228aa3f8 --- /dev/null +++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/OpaViewExpression.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 io.trino.spi.security.ViewExpression; +import jakarta.validation.constraints.NotNull; + +import java.util.Optional; + +import static java.util.Objects.requireNonNull; + +public record OpaViewExpression(@NotNull String expression, @NotNull Optional identity) +{ + public OpaViewExpression + { + requireNonNull(expression, "expression is null"); + requireNonNull(identity, "identity is null"); + } + + public ViewExpression toTrinoViewExpression(String catalogName, String schemaName) + { + ViewExpression.Builder builder = ViewExpression.builder() + .catalog(catalogName) + .schema(schemaName) + .expression(expression); + identity.ifPresent(builder::identity); + return builder.build(); + } +} 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/TrinoColumn.java b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoColumn.java new file mode 100644 index 0000000000000..1070f02558dc7 --- /dev/null +++ b/plugin/trino-opa/src/main/java/io/trino/plugin/opa/schema/TrinoColumn.java @@ -0,0 +1,63 @@ +/* + * 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 io.trino.spi.connector.CatalogSchemaTableName; +import io.trino.spi.type.Type; +import jakarta.validation.constraints.NotNull; + +import static java.util.Objects.requireNonNull; + +/** + * This class is used to represent information about a column for the purposes of column masking. + * It is (perhaps counterintuitively) only used for column masking and not for operations like + * FilterColumns. This is for 3 reasons: + * - API stability between the batch & non-batch modes: sending an array of TrinoColumn objects would be wasteful for + * the batch authorizer mode (as it would repeat the catalog, schema and table names once per column). As such, this + * object is not used for FilterColumns even if batch mode is disabled + * - This object contains in-depth information about the column (e.g. its type), and it may be modified to include + * additional fields in the future. This level of information is not provided to operations like FilterColumns + * - Backwards compatibility + * + * @param catalogName The name of the catalog this column's table belongs to + * @param schemaName The name of the schema this column's table belongs to + * @param tableName The name of the table this column is in + * @param columnName Column name + * @param columnType String representation of the column type + */ +public record TrinoColumn( + @NotNull String catalogName, + @NotNull String schemaName, + @NotNull String tableName, + @NotNull String columnName, + @NotNull String columnType) +{ + public TrinoColumn + { + requireNonNull(catalogName, "catalogName is null"); + requireNonNull(schemaName, "schemaName is null"); + requireNonNull(tableName, "tableName is null"); + requireNonNull(columnName, "columnName is null"); + requireNonNull(columnType, "columnType is null"); + } + + public TrinoColumn(CatalogSchemaTableName tableName, String columnName, Type type) + { + this(tableName.getCatalogName(), + tableName.getSchemaTableName().getSchemaName(), + tableName.getSchemaTableName().getTableName(), + columnName, + type.getDisplayName()); + } +} 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/DistributedQueryRunnerHelper.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/DistributedQueryRunnerHelper.java new file mode 100644 index 0000000000000..36a8fa737151e --- /dev/null +++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/DistributedQueryRunnerHelper.java @@ -0,0 +1,71 @@ +/* + * 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.trino.Session; +import io.trino.spi.security.Identity; +import io.trino.testing.DistributedQueryRunner; + +import java.util.Map; +import java.util.Set; + +import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static io.trino.testing.TestingSession.testSessionBuilder; + +public final class DistributedQueryRunnerHelper +{ + private final DistributedQueryRunner runner; + + private DistributedQueryRunnerHelper(DistributedQueryRunner runner) + { + this.runner = runner; + } + + public static DistributedQueryRunnerHelper withOpaConfig(Map opaConfig) + throws Exception + { + return new DistributedQueryRunnerHelper( + DistributedQueryRunner.builder(testSessionBuilder().build()) + .setSystemAccessControl(new OpaAccessControlFactory().create(opaConfig)) + .setNodeCount(1) + .build()); + } + + public Set querySetOfStrings(String user, String query) + { + return querySetOfStrings(userSession(user), query); + } + + public Set querySetOfStrings(Session session, String query) + { + return runner.execute(session, query).getMaterializedRows().stream().map(row -> row.getField(0) == null ? "" : row.getField(0).toString()).collect(toImmutableSet()); + } + + public DistributedQueryRunner getBaseQueryRunner() + { + return this.runner; + } + + public void teardown() + { + if (this.runner != null) { + this.runner.close(); + } + } + + private static Session userSession(String user) + { + return testSessionBuilder().setIdentity(Identity.ofUser(user)).build(); + } +} 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/OpaContainer.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/OpaContainer.java new file mode 100644 index 0000000000000..673fafcaf4f14 --- /dev/null +++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/OpaContainer.java @@ -0,0 +1,90 @@ +/* + * 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 org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +public class OpaContainer + implements Startable +{ + private static final int OPA_PORT = 8181; + private static final String OPA_BASE_PATH = "v1/data/trino/"; + private static final String OPA_POLICY_PUSH_BASE_PATH = "v1/policies/trino"; + + private final GenericContainer container; + private URI resolvedUri; + + public OpaContainer() + { + this.container = new GenericContainer<>(DockerImageName.parse("openpolicyagent/opa:latest-rootless")) + .withCommand("run", "--server", "--addr", ":%d".formatted(OPA_PORT), "--set", "decision_logs.console=true") + .withExposedPorts(OPA_PORT) + .waitingFor(Wait.forListeningPort()); + } + + @Override + public synchronized void start() + { + this.container.start(); + this.resolvedUri = null; + } + + @Override + public synchronized void stop() + { + this.container.stop(); + this.resolvedUri = null; + } + + public synchronized URI getOpaServerUri() + { + if (!container.isRunning()) { + this.resolvedUri = null; + throw new IllegalStateException("Container is not running"); + } + if (this.resolvedUri == null) { + this.resolvedUri = URI.create(String.format("http://%s:%d/", container.getHost(), container.getMappedPort(OPA_PORT))); + } + return this.resolvedUri; + } + + public URI getOpaUriForPolicyPath(String relativePath) + { + return getOpaServerUri().resolve(OPA_BASE_PATH + relativePath); + } + + public void submitPolicy(String... policyLines) + throws IOException, InterruptedException + { + HttpClient httpClient = HttpClient.newHttpClient(); + HttpResponse policyResponse = + httpClient.send( + HttpRequest.newBuilder(getOpaServerUri().resolve(OPA_POLICY_PUSH_BASE_PATH)) + .PUT(HttpRequest.BodyPublishers.ofString(String.join("\n", policyLines))) + .header("Content-Type", "text/plain").build(), + HttpResponse.BodyHandlers.ofString()); + if (policyResponse.statusCode() != 200) { + throw new RuntimeException("Failed to submit policy: %s".formatted(policyResponse.body())); + } + } +} 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..e82149c99a77d --- /dev/null +++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestHelpers.java @@ -0,0 +1,252 @@ +/* + * 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.airlift.configuration.ConfigurationMetadata; +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.lang.reflect.InvocationTargetException; +import java.net.URI; +import java.time.Instant; +import java.util.Arrays; +import java.util.Map; +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(Map config, InstrumentedHttpClient mockHttpClient) + { + return (OpaAccessControl) OpaAccessControlFactory.create(config, Optional.of(mockHttpClient), Optional.of(SYSTEM_ACCESS_CONTROL_CONTEXT)); + } + + public static final class OpaConfigBuilder + { + private final OpaConfig config = new OpaConfig(); + + public OpaConfigBuilder withBasePolicy(URI basePolicy) + { + config.setOpaUri(basePolicy); + return this; + } + + public OpaConfigBuilder withBatchPolicy(URI batchPolicy) + { + config.setOpaBatchUri(batchPolicy); + return this; + } + + public OpaConfigBuilder withRowFiltersPolicy(URI rowFiltersPolicy) + { + config.setOpaRowFiltersUri(rowFiltersPolicy); + return this; + } + + public OpaConfigBuilder withColumnMaskingPolicy(URI columnMaskingPolicy) + { + config.setOpaColumnMaskingUri(columnMaskingPolicy); + return this; + } + + public Map buildConfig() + { + ConfigurationMetadata metadata = ConfigurationMetadata.getValidConfigurationMetadata(OpaConfig.class); + ImmutableMap.Builder opaConfigBuilder = ImmutableMap.builder(); + try { + for (ConfigurationMetadata.AttributeMetadata attribute : metadata.getAttributes().values()) { + convertPropertyToString(attribute.getGetter().invoke(config)).ifPresent( + propertyValue -> opaConfigBuilder.put(attribute.getInjectionPoint().getProperty(), propertyValue)); + } + } + catch (InvocationTargetException | IllegalAccessException e) { + throw new AssertionError("Failed to build config map", e); + } + return opaConfigBuilder.buildOrThrow(); + } + + private static Optional convertPropertyToString(Object value) + { + if (value instanceof Optional optionalValue) { + return optionalValue.map(Object::toString); + } + return Optional.ofNullable(value).map(Object::toString); + } + } + + 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..2d0cbcbe85810 --- /dev/null +++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControl.java @@ -0,0 +1,1330 @@ +/* + * 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.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.plugin.opa.schema.OpaViewExpression; +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 io.trino.spi.security.ViewExpression; +import io.trino.spi.type.VarcharType; +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.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +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 URI OPA_SERVER_ROW_FILTERING_URI = URI.create("http://my-row-filtering-uri"); + private static final URI OPA_SERVER_COLUMN_MASK_URI = URI.create("http://my-column-masking-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); + private static final Map OPA_CONFIG_WITH_ONLY_ALLOW = new TestHelpers.OpaConfigBuilder().withBasePolicy(OPA_SERVER_URI).buildConfig(); + // 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_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, permissiveClient); + assertThat(permissiveAuthorizer.canAccessCatalog(requestingSecurityContext, "test_catalog")).isTrue(); + + InstrumentedHttpClient restrictiveClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, NO_ACCESS_RESPONSE)); + OpaAccessControl restrictiveAuthorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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_CONFIG_WITH_ONLY_ALLOW, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, 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"); + } + + @Test + public void testGetRowFiltersThrowsForIllegalResponse() + { + CatalogSchemaTableName tableName = new CatalogSchemaTableName("some_catalog", "some_schema", "some_table"); + assertAccessControlMethodThrowsForIllegalResponses(authorizer -> authorizer.getRowFilters(TEST_SECURITY_CONTEXT, tableName)); + + // Also test a valid JSON response, but containing invalid fields for a row filters request + String validJsonButIllegalSchemaResponseContents = """ + { + "result": ["some-expr"] + }"""; + assertAccessControlMethodThrowsForResponse( + authorizer -> authorizer.getRowFilters(TEST_SECURITY_CONTEXT, tableName), + new MockResponse(validJsonButIllegalSchemaResponseContents, 200), + OpaQueryException.class, + "Failed to deserialize"); + } + + @Test + public void testGetRowFilters() + { + // This example is a bit strange - an undefined policy would in most cases + // result in an access denied situation. However, since this is row-level-filtering + // we will accept this as meaning there are no known filters to be applied. + testGetRowFilters("{}", ImmutableList.of()); + + String noExpressionsResponse = """ + { + "result": [] + }"""; + testGetRowFilters(noExpressionsResponse, ImmutableList.of()); + + String singleExpressionResponse = """ + { + "result": [ + {"expression": "expr1"} + ] + }"""; + testGetRowFilters( + singleExpressionResponse, + ImmutableList.of(new OpaViewExpression("expr1", Optional.empty()))); + + String multipleExpressionsAndIdentitiesResponse = """ + { + "result": [ + {"expression": "expr1"}, + {"expression": "expr2", "identity": "expr2_identity"}, + {"expression": "expr3", "identity": "expr3_identity"} + ] + }"""; + testGetRowFilters( + multipleExpressionsAndIdentitiesResponse, + ImmutableList.builder() + .add(new OpaViewExpression("expr1", Optional.empty())) + .add(new OpaViewExpression("expr2", Optional.of("expr2_identity"))) + .add(new OpaViewExpression("expr3", Optional.of("expr3_identity"))) + .build()); + } + + private void testGetRowFilters(String responseContent, List expectedExpressions) + { + InstrumentedHttpClient httpClient = createMockHttpClient(OPA_SERVER_ROW_FILTERING_URI, buildValidatingRequestHandler(TEST_IDENTITY, new MockResponse(responseContent, 200))); + OpaAccessControl authorizer = createOpaAuthorizer( + new TestHelpers.OpaConfigBuilder() + .withBasePolicy(OPA_SERVER_URI) + .withRowFiltersPolicy(OPA_SERVER_ROW_FILTERING_URI) + .buildConfig(), + httpClient); + CatalogSchemaTableName tableName = new CatalogSchemaTableName("some_catalog", "some_schema", "some_table"); + + List result = authorizer.getRowFilters(TEST_SECURITY_CONTEXT, tableName); + assertThat(result).allSatisfy(expression -> { + assertThat(expression.getCatalog()).contains("some_catalog"); + assertThat(expression.getSchema()).contains("some_schema"); + }); + assertThat(result).map( + viewExpression -> new OpaViewExpression( + viewExpression.getExpression(), + viewExpression.getSecurityIdentity())) + .containsExactlyInAnyOrderElementsOf(expectedExpressions); + + String expectedRequest = """ + { + "operation": "GetRowFilters", + "resource": { + "table": { + "catalogName": "some_catalog", + "schemaName": "some_schema", + "tableName": "some_table" + } + } + }"""; + assertStringRequestsEqual(ImmutableSet.of(expectedRequest), httpClient.getRequests(), "/input/action"); + } + + @Test + public void testGetRowFiltersDoesNothingIfNotConfigured() + { + InstrumentedHttpClient httpClient = createMockHttpClient( + OPA_SERVER_ROW_FILTERING_URI, + request -> { + throw new AssertionError("Should not have been called"); + }); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, httpClient); + CatalogSchemaTableName tableName = new CatalogSchemaTableName("some_catalog", "some_schema", "some_table"); + + List result = authorizer.getRowFilters(TEST_SECURITY_CONTEXT, tableName); + assertThat(result).isEmpty(); + assertThat(httpClient.getRequests()).isEmpty(); + } + + @Test + public void testGetColumnMaskThrowsForIllegalResponse() + { + CatalogSchemaTableName tableName = new CatalogSchemaTableName("some_catalog", "some_schema", "some_table"); + assertAccessControlMethodThrowsForIllegalResponses(authorizer -> authorizer.getColumnMask(TEST_SECURITY_CONTEXT, tableName, "some_column", VarcharType.VARCHAR)); + + // Also test a valid JSON response, but containing invalid fields for a row filters request + String validJsonButIllegalSchemaResponseContents = """ + { + "result": {"expression": {"foo": "bar"}} + }"""; + assertAccessControlMethodThrowsForResponse( + authorizer -> authorizer.getColumnMask(TEST_SECURITY_CONTEXT, tableName, "some_column", VarcharType.VARCHAR), + new MockResponse(validJsonButIllegalSchemaResponseContents, 200), + OpaQueryException.class, + "Failed to deserialize"); + } + + @Test + public void testGetColumnMask() + { + // Similar note to the test for row level filtering: + // This example is a bit strange - an undefined policy would in most cases + // result in an access denied situation. However, since this is column masking, + // we will accept this as meaning there are no masks to be applied. + testGetColumnMask("{}", Optional.empty()); + + String nullResponse = """ + { + "result": null + }"""; + testGetColumnMask(nullResponse, Optional.empty()); + + String expressionWithoutIdentityResponse = """ + { + "result": {"expression": "expr1"} + }"""; + testGetColumnMask( + expressionWithoutIdentityResponse, + Optional.of(new OpaViewExpression("expr1", Optional.empty()))); + + String expressionWithIdentityResponse = """ + { + "result": {"expression": "expr1", "identity": "some_identity"} + }"""; + testGetColumnMask( + expressionWithIdentityResponse, + Optional.of(new OpaViewExpression("expr1", Optional.of("some_identity")))); + } + + private void testGetColumnMask(String responseContent, Optional expectedExpression) + { + InstrumentedHttpClient httpClient = createMockHttpClient(OPA_SERVER_COLUMN_MASK_URI, buildValidatingRequestHandler(TEST_IDENTITY, new MockResponse(responseContent, 200))); + OpaAccessControl authorizer = createOpaAuthorizer( + new TestHelpers.OpaConfigBuilder() + .withBasePolicy(OPA_SERVER_URI) + .withColumnMaskingPolicy(OPA_SERVER_COLUMN_MASK_URI) + .buildConfig(), + httpClient); + CatalogSchemaTableName tableName = new CatalogSchemaTableName("some_catalog", "some_schema", "some_table"); + + Optional result = authorizer.getColumnMask(TEST_SECURITY_CONTEXT, tableName, "some_column", VarcharType.VARCHAR); + + assertThat(result.isEmpty()).isEqualTo(expectedExpression.isEmpty()); + assertThat(result.map(viewExpression -> { + assertThat(viewExpression.getCatalog()).contains("some_catalog"); + assertThat(viewExpression.getSchema()).contains("some_schema"); + return new OpaViewExpression(viewExpression.getExpression(), viewExpression.getSecurityIdentity()); + })).isEqualTo(expectedExpression); + + String expectedRequest = """ + { + "operation": "GetColumnMask", + "resource": { + "column": { + "catalogName": "some_catalog", + "schemaName": "some_schema", + "tableName": "some_table", + "columnName": "some_column", + "columnType": "varchar" + } + } + }"""; + assertStringRequestsEqual(ImmutableSet.of(expectedRequest), httpClient.getRequests(), "/input/action"); + } + + @Test + public void testGetColumnMaskDoesNothingIfNotConfigured() + { + InstrumentedHttpClient httpClient = createMockHttpClient( + OPA_SERVER_COLUMN_MASK_URI, + request -> { + throw new AssertionError("Should not have been called"); + }); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, httpClient); + CatalogSchemaTableName tableName = new CatalogSchemaTableName("some_catalog", "some_schema", "some_table"); + + Optional result = authorizer.getColumnMask(TEST_SECURITY_CONTEXT, tableName, "some_column", VarcharType.VARCHAR); + assertThat(result).isEmpty(); + assertThat(httpClient.getRequests()).isEmpty(); + } + + 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_CONFIG_WITH_ONLY_ALLOW, permissiveMockClient))).isTrue(); + assertThat(method.isAccessAllowed(createOpaAuthorizer(OPA_CONFIG_WITH_ONLY_ALLOW, restrictiveMockClient))).isFalse(); + assertThat(permissiveMockClient.getRequests()).containsExactlyInAnyOrderElementsOf(restrictiveMockClient.getRequests()); + assertStringRequestsEqual(expectedRequests, permissiveMockClient.getRequests(), "/input/action"); + assertAccessControlMethodThrowsForIllegalResponses(method::isAccessAllowed); + } + + private static void assertAccessControlMethodThrowsForIllegalResponses(Consumer 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( + Consumer methodToTest, + MockResponse response, + Class expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(TEST_IDENTITY, response)); + OpaAccessControl authorizer = createOpaAuthorizer( + new TestHelpers.OpaConfigBuilder() + .withBasePolicy(OPA_SERVER_URI) + .withRowFiltersPolicy(OPA_SERVER_URI) + .withColumnMaskingPolicy(OPA_SERVER_URI) + .buildConfig(), + mockClient); + + assertThatThrownBy(() -> methodToTest.accept(authorizer)) + .isInstanceOf(expectedException) + .hasMessageContaining(expectedErrorMessage); + } +} diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlDataFilteringSystem.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlDataFilteringSystem.java new file mode 100644 index 0000000000000..d825233b6f164 --- /dev/null +++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlDataFilteringSystem.java @@ -0,0 +1,285 @@ +/* + * 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 io.trino.connector.MockConnectorFactory; +import io.trino.connector.MockConnectorPlugin; +import io.trino.spi.connector.ColumnMetadata; +import io.trino.spi.type.IntegerType; +import io.trino.spi.type.VarcharType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; + +@Testcontainers +@TestInstance(PER_CLASS) +public class TestOpaAccessControlDataFilteringSystem +{ + @Container + private static final OpaContainer OPA_CONTAINER = new OpaContainer(); + private static final String OPA_ALLOW_POLICY_NAME = "allow"; + private static final String OPA_ROW_LEVEL_FILTERING_POLICY_NAME = "rowFilters"; + private static final String OPA_COLUMN_MASKING_POLICY_NAME = "columnMask"; + private static final String SAMPLE_ROW_LEVEL_FILTERING_POLICY = """ + package trino + import future.keywords.in + import future.keywords.if + import future.keywords.contains + + default allow := true + + table_resource := input.action.resource.table + is_admin { + input.context.identity.user == "admin" + } + + rowFilters contains {"expression": "user_type <> 'customer'"} if { + not is_admin + table_resource.catalogName == "sample_catalog" + table_resource.schemaName == "sample_schema" + table_resource.tableName == "restricted_table" + }"""; + private static final String SAMPLE_COLUMN_MASKING_POLICY = """ + package trino + import future.keywords.in + import future.keywords.if + import future.keywords.contains + + default allow := true + + column_resource := input.action.resource.column + is_admin { + input.context.identity.user == "admin" + } + + columnMask := {"expression": "NULL"} if { + not is_admin + column_resource.catalogName == "sample_catalog" + column_resource.schemaName == "sample_schema" + column_resource.tableName == "restricted_table" + column_resource.columnName == "user_phone" + } + + columnMask := {"expression": "'****' || substring(user_name, -3)"} if { + not is_admin + column_resource.catalogName == "sample_catalog" + column_resource.schemaName == "sample_schema" + column_resource.tableName == "restricted_table" + column_resource.columnName == "user_name" + } + """; + + private static final Set DUMMY_CUSTOMERS_IN_TABLE = ImmutableSet.of("customer_one", "customer_two"); + private static final Set DUMMY_INTERNAL_USERS_IN_TABLE = ImmutableSet.of("some_internal_user"); + private static final Set ALL_DUMMY_USERS_IN_TABLE = ImmutableSet.builder() + .addAll(DUMMY_INTERNAL_USERS_IN_TABLE) + .addAll(DUMMY_CUSTOMERS_IN_TABLE) + .build(); + + private DistributedQueryRunnerHelper runner; + + @AfterEach + public void teardown() + { + if (runner != null) { + runner.teardown(); + } + } + + @Test + public void testRowFilteringEnabled() + throws Exception + { + setupTrinoWithOpa( + new TestHelpers.OpaConfigBuilder() + .withBasePolicy(OPA_CONTAINER.getOpaUriForPolicyPath(OPA_ALLOW_POLICY_NAME)) + .withRowFiltersPolicy(OPA_CONTAINER.getOpaUriForPolicyPath(OPA_ROW_LEVEL_FILTERING_POLICY_NAME)) + .buildConfig()); + OPA_CONTAINER.submitPolicy(SAMPLE_ROW_LEVEL_FILTERING_POLICY); + String restrictedTableQuery = "SELECT user_name FROM sample_catalog.sample_schema.restricted_table"; + String unrestrictedTableQuery = "SELECT user_name FROM sample_catalog.sample_schema.unrestricted_table"; + assertResultsForUser("admin", restrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + assertResultsForUser("admin", unrestrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + + assertResultsForUser("bob", unrestrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + assertResultsForUser("bob", restrictedTableQuery, DUMMY_INTERNAL_USERS_IN_TABLE); + } + + @Test + public void testRowFilteringDisabledDoesNothing() + throws Exception + { + setupTrinoWithOpa( + new TestHelpers.OpaConfigBuilder() + .withBasePolicy(OPA_CONTAINER.getOpaUriForPolicyPath(OPA_ALLOW_POLICY_NAME)) + .buildConfig()); + OPA_CONTAINER.submitPolicy(SAMPLE_ROW_LEVEL_FILTERING_POLICY); + String restrictedTableQuery = "SELECT user_name FROM sample_catalog.sample_schema.restricted_table"; + String unrestrictedTableQuery = "SELECT user_name FROM sample_catalog.sample_schema.unrestricted_table"; + assertResultsForUser("admin", restrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + assertResultsForUser("admin", unrestrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + + assertResultsForUser("bob", unrestrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + assertResultsForUser("bob", restrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + } + + @Test + public void testColumnMasking() + throws Exception + { + setupTrinoWithOpa( + new TestHelpers.OpaConfigBuilder() + .withBasePolicy(OPA_CONTAINER.getOpaUriForPolicyPath(OPA_ALLOW_POLICY_NAME)) + .withColumnMaskingPolicy(OPA_CONTAINER.getOpaUriForPolicyPath(OPA_COLUMN_MASKING_POLICY_NAME)) + .buildConfig()); + OPA_CONTAINER.submitPolicy(SAMPLE_COLUMN_MASKING_POLICY); + + String userNamesInUnrestrictedTableQuery = "SELECT user_name FROM sample_catalog.sample_schema.unrestricted_table"; + String userNamesInRestrictedTableQuery = "SELECT user_name FROM sample_catalog.sample_schema.restricted_table"; + // No masking is applied to the unrestricted table + assertResultsForUser("admin", userNamesInUnrestrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + assertResultsForUser("bob", userNamesInUnrestrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + + // No masking is applied for "admin" even in the restricted table + assertResultsForUser("admin", userNamesInRestrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + + // "bob" can only see the last 3 characters of user names for the restricted table + Set expectedMaskedUserNames = ALL_DUMMY_USERS_IN_TABLE.stream().map(userName -> "****" + userName.substring(userName.length() - 3)).collect(toImmutableSet()); + assertResultsForUser("bob", userNamesInRestrictedTableQuery, expectedMaskedUserNames); + + String phoneNumbersInUnrestrictedTableQuery = "SELECT user_phone FROM sample_catalog.sample_schema.unrestricted_table"; + String phoneNumbersInRestrictedTableQuery = "SELECT user_phone FROM sample_catalog.sample_schema.restricted_table"; + + // Phone numbers are derived by hashing the name of the user + Set allExpectedPhoneNumbers = ALL_DUMMY_USERS_IN_TABLE.stream().map(userName -> String.valueOf(userName.hashCode())).collect(toImmutableSet()); + + // No masking is applied to the unrestricted table + assertResultsForUser("admin", phoneNumbersInUnrestrictedTableQuery, allExpectedPhoneNumbers); + assertResultsForUser("bob", phoneNumbersInUnrestrictedTableQuery, allExpectedPhoneNumbers); + + // No masking is applied for "admin" even in the restricted table + assertResultsForUser("admin", phoneNumbersInRestrictedTableQuery, allExpectedPhoneNumbers); + // "bob" cannot see any phone numbers in the restricted table + assertResultsForUser("bob", phoneNumbersInRestrictedTableQuery, ImmutableSet.of("")); + } + + @Test + public void testColumnMaskingDisabledDoesNothing() + throws Exception + { + setupTrinoWithOpa( + new TestHelpers.OpaConfigBuilder() + .withBasePolicy(OPA_CONTAINER.getOpaUriForPolicyPath(OPA_ALLOW_POLICY_NAME)) + .buildConfig()); + OPA_CONTAINER.submitPolicy(SAMPLE_COLUMN_MASKING_POLICY); + String restrictedTableQuery = "SELECT user_name FROM sample_catalog.sample_schema.restricted_table"; + String unrestrictedTableQuery = "SELECT user_name FROM sample_catalog.sample_schema.unrestricted_table"; + assertResultsForUser("admin", restrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + assertResultsForUser("admin", unrestrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + + assertResultsForUser("bob", unrestrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + assertResultsForUser("bob", restrictedTableQuery, ALL_DUMMY_USERS_IN_TABLE); + } + + @Test + public void testColumnMaskingAndRowFiltering() + throws Exception + { + setupTrinoWithOpa( + new TestHelpers.OpaConfigBuilder() + .withBasePolicy(OPA_CONTAINER.getOpaUriForPolicyPath(OPA_ALLOW_POLICY_NAME)) + .withColumnMaskingPolicy(OPA_CONTAINER.getOpaUriForPolicyPath(OPA_COLUMN_MASKING_POLICY_NAME)) + .withRowFiltersPolicy(OPA_CONTAINER.getOpaUriForPolicyPath(OPA_ROW_LEVEL_FILTERING_POLICY_NAME)) + .buildConfig()); + // Simpler policy than the previous tests: + // Admin has no restrictions + // Any other user can only see rows where "user_type" is not "customer" + // And cannot see any data for field "user_name" + String policy = """ + package trino + import future.keywords.in + import future.keywords.if + import future.keywords.contains + + default allow := true + + is_admin { + input.context.identity.user == "admin" + } + + table_resource := input.action.resource.table + column_resource := input.action.resource.column + + rowFilters contains {"expression": "user_type <> 'customer'"} if { + not is_admin + } + columnMask := {"expression": "NULL"} if { + not is_admin + column_resource.columnName == "user_name" + }"""; + OPA_CONTAINER.submitPolicy(policy); + + String selectUserNameData = "SELECT user_name FROM sample_catalog.sample_schema.restricted_table"; + String selectUserTypeData = "SELECT user_type FROM sample_catalog.sample_schema.restricted_table"; + Set expectedUserTypes = ImmutableSet.of("internal_user", "customer"); + + assertResultsForUser("admin", selectUserNameData, ALL_DUMMY_USERS_IN_TABLE); + assertResultsForUser("admin", selectUserTypeData, expectedUserTypes); + + assertResultsForUser("bob", selectUserNameData, ImmutableSet.of("")); + assertResultsForUser("bob", selectUserTypeData, ImmutableSet.of("internal_user")); + } + + private void assertResultsForUser(String asUser, String query, Set expectedResults) + { + assertThat(runner.querySetOfStrings(asUser, query)).containsExactlyInAnyOrderElementsOf(expectedResults); + } + + private void setupTrinoWithOpa(Map opaConfig) + throws Exception + { + this.runner = DistributedQueryRunnerHelper.withOpaConfig(opaConfig); + MockConnectorFactory connectorFactory = MockConnectorFactory.builder() + .withListSchemaNames(session -> ImmutableList.of("sample_schema")) + .withListTables((session, schema) -> ImmutableList.builder() + .add("restricted_table") + .add("unrestricted_table") + .build()) + .withGetColumns(schemaTableName -> ImmutableList.builder() + .add(ColumnMetadata.builder().setName("user_type").setType(VarcharType.VARCHAR).build()) + .add(ColumnMetadata.builder().setName("user_name").setType(VarcharType.VARCHAR).build()) + .add(ColumnMetadata.builder().setName("user_phone").setType(IntegerType.INTEGER).build()) + .build()) + .withData(schemaTableName -> ImmutableList.>builder() + .addAll(DUMMY_CUSTOMERS_IN_TABLE.stream().map(customer -> ImmutableList.of("customer", customer, customer.hashCode())).collect(toImmutableSet())) + .addAll(DUMMY_INTERNAL_USERS_IN_TABLE.stream().map(internalUser -> ImmutableList.of("internal_user", internalUser, internalUser.hashCode())).collect(toImmutableSet())) + .build()) + .build(); + + runner.getBaseQueryRunner().installPlugin(new MockConnectorPlugin(connectorFactory)); + runner.getBaseQueryRunner().createCatalog("sample_catalog", "mock"); + } +} 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..3ad662efceca5 --- /dev/null +++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlFiltering.java @@ -0,0 +1,340 @@ +/* + * 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 static final Map OPA_CONFIG = new TestHelpers.OpaConfigBuilder().withBasePolicy(OPA_SERVER_URI).buildConfig(); + 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_CONFIG, 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_CONFIG, 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_CONFIG, 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_CONFIG, 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_CONFIG, 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_CONFIG, 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_CONFIG, 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_CONFIG, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG, mockClient); + + assertThatThrownBy(() -> callable.apply(authorizer, requestingSecurityContext)) + .isInstanceOf(expectedException) + .hasMessageContaining(expectedErrorMessage); + assertThat(mockClient.getRequests()).hasSize(1); + } + + private Function buildHandler(String jsonPath, Set resourcesToAccept) + { + return buildValidatingRequestHandler(requestingIdentity, parsedRequest -> { + String requestedItem = parsedRequest.at(jsonPath).asText(); + if (resourcesToAccept.contains(requestedItem)) { + return OK_RESPONSE; + } + return NO_ACCESS_RESPONSE; + }); + } + + private Function buildHandler(String jsonPath, String resourceToAccept) + { + return buildHandler(jsonPath, ImmutableSet.of(resourceToAccept)); + } +} diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlPermissioningOperations.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlPermissioningOperations.java new file mode 100644 index 0000000000000..ef3d2c243dac4 --- /dev/null +++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlPermissioningOperations.java @@ -0,0 +1,149 @@ +/* + * 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 io.trino.plugin.opa.HttpClientUtils.InstrumentedHttpClient; +import io.trino.spi.connector.CatalogSchemaName; +import io.trino.spi.connector.CatalogSchemaTableName; +import io.trino.spi.security.AccessDeniedException; +import io.trino.spi.security.Identity; +import io.trino.spi.security.PrincipalType; +import io.trino.spi.security.Privilege; +import io.trino.spi.security.SystemSecurityContext; +import io.trino.spi.security.TrinoPrincipal; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +import static io.trino.plugin.opa.TestHelpers.SYSTEM_ACCESS_CONTROL_CONTEXT; +import static io.trino.plugin.opa.TestHelpers.createMockHttpClient; +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 TestOpaAccessControlPermissioningOperations +{ + private static final URI OPA_SERVER_URI = URI.create("http://my-uri/"); + private static final Identity REQUESTING_IDENTITY = Identity.ofUser("source-user"); + private static final SystemSecurityContext REQUESTING_SECURITY_CONTEXT = systemSecurityContextFromIdentity(REQUESTING_IDENTITY); + + @Test + public void testTablePrivilegeGrantingOperationsDeniedOrAllowedByConfig() + { + CatalogSchemaTableName sampleTableName = new CatalogSchemaTableName("some_catalog", "some_schema", "some_table"); + TrinoPrincipal samplePrincipal = new TrinoPrincipal(PrincipalType.USER, "some_user"); + + testOperationAllowedOrDeniedByConfig( + authorizer -> authorizer.checkCanGrantTablePrivilege(REQUESTING_SECURITY_CONTEXT, Privilege.CREATE, sampleTableName, samplePrincipal, false)); + testOperationAllowedOrDeniedByConfig( + authorizer -> authorizer.checkCanRevokeTablePrivilege(REQUESTING_SECURITY_CONTEXT, Privilege.CREATE, sampleTableName, samplePrincipal, false)); + testOperationAllowedOrDeniedByConfig( + authorizer -> authorizer.checkCanDenyTablePrivilege(REQUESTING_SECURITY_CONTEXT, Privilege.CREATE, sampleTableName, samplePrincipal)); + } + + @Test + public void testSchemaPrivilegeGrantingOperationsDeniedOrAllowedByConfig() + { + CatalogSchemaName sampleSchemaName = new CatalogSchemaName("some_catalog", "some_schema"); + TrinoPrincipal samplePrincipal = new TrinoPrincipal(PrincipalType.USER, "some_user"); + + testOperationAllowedOrDeniedByConfig( + authorizer -> authorizer.checkCanGrantSchemaPrivilege(REQUESTING_SECURITY_CONTEXT, Privilege.CREATE, sampleSchemaName, samplePrincipal, false)); + testOperationAllowedOrDeniedByConfig( + authorizer -> authorizer.checkCanRevokeSchemaPrivilege(REQUESTING_SECURITY_CONTEXT, Privilege.CREATE, sampleSchemaName, samplePrincipal, false)); + testOperationAllowedOrDeniedByConfig( + authorizer -> authorizer.checkCanDenySchemaPrivilege(REQUESTING_SECURITY_CONTEXT, Privilege.CREATE, sampleSchemaName, samplePrincipal)); + } + + @Test + public void testCanCreateRoleAllowedOrDeniedByConfig() + { + testOperationAllowedOrDeniedByConfig( + authorizer -> authorizer.checkCanCreateRole(REQUESTING_SECURITY_CONTEXT, "some_role", Optional.empty())); + } + + @Test + public void testCanDropRoleAllowedOrDeniedByConfig() + { + testOperationAllowedOrDeniedByConfig( + authorizer -> authorizer.checkCanDropRole(REQUESTING_SECURITY_CONTEXT, "some_role")); + } + + @Test + public void testCanGrantRolesAllowedOrDeniedByConfig() + { + Set roles = ImmutableSet.of("role_one", "role_two"); + Set grantees = ImmutableSet.of(new TrinoPrincipal(PrincipalType.USER, "some_principal")); + testOperationAllowedOrDeniedByConfig( + authorizer -> authorizer.checkCanGrantRoles(REQUESTING_SECURITY_CONTEXT, roles, grantees, true, Optional.empty())); + } + + private static void testOperationAllowedOrDeniedByConfig(Consumer methodToTest) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, request -> null); + OpaAccessControl permissiveAuthorizer = createAuthorizer(true, mockClient); + OpaAccessControl restrictiveAuthorizer = createAuthorizer(false, mockClient); + + methodToTest.accept(permissiveAuthorizer); + assertThatThrownBy(() -> methodToTest.accept(restrictiveAuthorizer)) + .isInstanceOf(AccessDeniedException.class) + .hasMessageContaining("Access Denied:"); + assertThat(mockClient.getRequests()).isEmpty(); + } + + @Test + public void testShowRolesAlwaysAllowedRegardlessOfConfig() + { + testOperationAlwaysAllowedRegardlessOfConfig(authorizer -> authorizer.checkCanShowRoles(REQUESTING_SECURITY_CONTEXT)); + } + + @Test + public void testShowCurrentRolesAlwaysAllowedRegardlessOfConfig() + { + testOperationAlwaysAllowedRegardlessOfConfig(authorizer -> authorizer.checkCanShowCurrentRoles(REQUESTING_SECURITY_CONTEXT)); + } + + @Test + public void testShowRoleGrantsAlwaysAllowedRegardlessOfConfig() + { + testOperationAlwaysAllowedRegardlessOfConfig(authorizer -> authorizer.checkCanShowRoleGrants(REQUESTING_SECURITY_CONTEXT)); + } + + private static void testOperationAlwaysAllowedRegardlessOfConfig(Consumer methodToTest) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_SERVER_URI, request -> null); + OpaAccessControl permissiveAuthorizer = createAuthorizer(true, mockClient); + OpaAccessControl restrictiveAuthorizer = createAuthorizer(false, mockClient); + methodToTest.accept(permissiveAuthorizer); + methodToTest.accept(restrictiveAuthorizer); + + assertThat(mockClient.getRequests()).isEmpty(); + } + + private static OpaAccessControl createAuthorizer(boolean allowPermissioningOperations, InstrumentedHttpClient mockClient) + { + return (OpaAccessControl) OpaAccessControlFactory.create( + ImmutableMap.builder() + .put("opa.policy.uri", OPA_SERVER_URI.toString()) + .put("opa.allow-permissioning-operations", String.valueOf(allowPermissioningOperations)) + .buildOrThrow(), + Optional.of(mockClient), + Optional.of(SYSTEM_ACCESS_CONTROL_CONTEXT)); + } +} diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlPlugin.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlPlugin.java new file mode 100644 index 0000000000000..37ef92a11c980 --- /dev/null +++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlPlugin.java @@ -0,0 +1,32 @@ +/* + * 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.trino.spi.Plugin; +import io.trino.spi.security.SystemAccessControlFactory; +import org.junit.jupiter.api.Test; + +import static com.google.common.collect.Iterables.getOnlyElement; + +public class TestOpaAccessControlPlugin +{ + @Test + public void testCreatePlugin() + { + Plugin opaPlugin = new OpaAccessControlPlugin(); + SystemAccessControlFactory factory = getOnlyElement(opaPlugin.getSystemAccessControlFactories()); + factory.create(ImmutableMap.of("opa.policy.uri", "http://test/")); + } +} diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlSystem.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlSystem.java new file mode 100644 index 0000000000000..2e38f0a33a3ae --- /dev/null +++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaAccessControlSystem.java @@ -0,0 +1,240 @@ +/* + * 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 io.trino.plugin.blackhole.BlackHolePlugin; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import static io.trino.plugin.opa.FunctionalHelpers.Pair; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; + +@Testcontainers +@TestInstance(PER_CLASS) +public class TestOpaAccessControlSystem +{ + private DistributedQueryRunnerHelper runner; + + private static final String OPA_ALLOW_POLICY_NAME = "allow"; + private static final String OPA_BATCH_ALLOW_POLICY_NAME = "batchAllow"; + @Container + private static final OpaContainer OPA_CONTAINER = new OpaContainer(); + + @Nested + @TestInstance(PER_CLASS) + @DisplayName("Unbatched Authorizer Tests") + class UnbatchedAuthorizerTests + { + @BeforeAll + public void setupTrino() + throws Exception + { + setupTrinoWithOpa(new TestHelpers.OpaConfigBuilder() + .withBasePolicy(OPA_CONTAINER.getOpaUriForPolicyPath(OPA_ALLOW_POLICY_NAME)) + .buildConfig()); + } + + @AfterAll + public void teardown() + { + runner.teardown(); + } + + @ParameterizedTest(name = "{index}: {0}") + @MethodSource("io.trino.plugin.opa.TestOpaAccessControlSystem#filterSchemaTests") + public void testAllowsQueryAndFilters(String userName, Set expectedCatalogs) + throws IOException, InterruptedException + { + OPA_CONTAINER.submitPolicy(""" + package trino + import future.keywords.in + import future.keywords.if + + default allow = false + allow { + is_bob + can_be_accessed_by_bob + } + allow if is_admin + + is_admin { + input.context.identity.user == "admin" + } + is_bob { + input.context.identity.user == "bob" + } + can_be_accessed_by_bob { + input.action.operation in ["ImpersonateUser", "ExecuteQuery"] + } + can_be_accessed_by_bob { + input.action.operation in ["FilterCatalogs", "AccessCatalog"] + input.action.resource.catalog.name == "catalog_one" + } + """); + Set catalogs = runner.querySetOfStrings(userName, "SHOW CATALOGS"); + assertThat(catalogs).containsExactlyInAnyOrderElementsOf(expectedCatalogs); + } + + @Test + public void testShouldDenyQueryIfDirected() + throws IOException, InterruptedException + { + OPA_CONTAINER.submitPolicy(""" + package trino + import future.keywords.in + default allow = false + + allow { + input.context.identity.user in ["someone", "admin"] + } + """); + assertThatThrownBy(() -> runner.querySetOfStrings("bob", "SHOW CATALOGS")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Access Denied"); + // smoke test: we can still query if we are the right user + runner.querySetOfStrings("admin", "SHOW CATALOGS"); + } + } + + @Nested + @TestInstance(PER_CLASS) + @DisplayName("Batched Authorizer Tests") + class BatchedAuthorizerTests + { + @BeforeAll + public void setupTrino() + throws Exception + { + setupTrinoWithOpa(new TestHelpers.OpaConfigBuilder() + .withBasePolicy(OPA_CONTAINER.getOpaUriForPolicyPath(OPA_ALLOW_POLICY_NAME)) + .withBatchPolicy(OPA_CONTAINER.getOpaUriForPolicyPath(OPA_BATCH_ALLOW_POLICY_NAME)) + .buildConfig()); + } + + @AfterAll + public void teardown() + { + runner.teardown(); + } + + @ParameterizedTest(name = "{index}: {0}") + @MethodSource("io.trino.plugin.opa.TestOpaAccessControlSystem#filterSchemaTests") + public void testFilterOutItemsBatch(String userName, Set expectedCatalogs) + throws IOException, InterruptedException + { + OPA_CONTAINER.submitPolicy(""" + package trino + import future.keywords.in + import future.keywords.if + default allow = false + + allow if is_admin + + allow { + is_bob + input.action.operation in ["AccessCatalog", "ExecuteQuery", "ImpersonateUser", "ShowSchemas", "SelectFromColumns"] + } + + is_bob { + input.context.identity.user == "bob" + } + + is_admin { + input.context.identity.user == "admin" + } + + batchAllow[i] { + some i + is_bob + input.action.operation == "FilterCatalogs" + input.action.filterResources[i].catalog.name == "catalog_one" + } + + batchAllow[i] { + some i + input.action.filterResources[i] + is_admin + } + """); + Set catalogs = runner.querySetOfStrings(userName, "SHOW CATALOGS"); + assertThat(catalogs).containsExactlyInAnyOrderElementsOf(expectedCatalogs); + } + + @Test + public void testDenyUnbatchedQuery() + throws IOException, InterruptedException + { + OPA_CONTAINER.submitPolicy(""" + package trino + import future.keywords.in + default allow = false + """); + assertThatThrownBy(() -> runner.querySetOfStrings("bob", "SELECT version()")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Access Denied"); + } + + @Test + public void testAllowUnbatchedQuery() + throws IOException, InterruptedException + { + OPA_CONTAINER.submitPolicy(""" + package trino + import future.keywords.in + default allow = false + allow { + input.context.identity.user == "bob" + input.action.operation in ["ImpersonateUser", "ExecuteFunction", "AccessCatalog", "ExecuteQuery"] + } + """); + Set version = runner.querySetOfStrings("bob", "SELECT version()"); + assertThat(version).isNotEmpty(); + } + } + + private void setupTrinoWithOpa(Map opaConfig) + throws Exception + { + this.runner = DistributedQueryRunnerHelper.withOpaConfig(opaConfig); + runner.getBaseQueryRunner().installPlugin(new BlackHolePlugin()); + runner.getBaseQueryRunner().createCatalog("catalog_one", "blackhole"); + runner.getBaseQueryRunner().createCatalog("catalog_two", "blackhole"); + } + + private static Stream filterSchemaTests() + { + Stream>> userAndExpectedCatalogs = Stream.of( + Pair.of("bob", ImmutableSet.of("catalog_one")), + Pair.of("admin", ImmutableSet.of("catalog_one", "catalog_two", "system"))); + return userAndExpectedCatalogs.map(testCase -> Arguments.of(Named.of(testCase.first(), testCase.first()), testCase.second())); + } +} diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaBatchAccessControlFiltering.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaBatchAccessControlFiltering.java new file mode 100644 index 0000000000000..b2514593261fc --- /dev/null +++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaBatchAccessControlFiltering.java @@ -0,0 +1,453 @@ +/* + * 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 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.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.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Stream; + +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.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 TestOpaBatchAccessControlFiltering +{ + private static final URI OPA_SERVER_URI = URI.create("http://my-uri/"); + private static final URI OPA_BATCH_SERVER_URI = URI.create("http://my-batch-uri/"); + private static final Map OPA_CONFIG = new TestHelpers.OpaConfigBuilder() + .withBasePolicy(OPA_SERVER_URI) + .withBatchPolicy(OPA_BATCH_SERVER_URI) + .buildConfig(); + private final Identity requestingIdentity = Identity.ofUser("source-user"); + private final SystemSecurityContext requestingSecurityContext = systemSecurityContextFromIdentity(requestingIdentity); + + @ParameterizedTest(name = "{index}: {0}") + @MethodSource("io.trino.plugin.opa.TestOpaBatchAccessControlFiltering#subsetProvider") + public void testFilterViewQueryOwnedBy( + MockResponse response, + List expectedItems) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_BATCH_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, response)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG, mockClient); + + Identity identityOne = Identity.ofUser("user-one"); + Identity identityTwo = Identity.ofUser("user-two"); + Identity identityThree = Identity.ofUser("user-three"); + List requestedIdentities = ImmutableList.of(identityOne, identityTwo, identityThree); + + Collection result = authorizer.filterViewQueryOwnedBy(requestingIdentity, requestedIdentities); + assertThat(result).containsExactlyInAnyOrderElementsOf(getSubset(requestedIdentities, expectedItems)); + + String expectedRequest = """ + { + "operation": "FilterViewQueryOwnedBy", + "filterResources": [ + { + "user": { + "user": "user-one", + "groups": [] + } + }, + { + "user": { + "user": "user-two", + "groups": [] + } + }, + { + "user": { + "user": "user-three", + "groups": [] + } + } + ] + }"""; + assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action"); + } + + @ParameterizedTest(name = "{index}: {0}") + @MethodSource("io.trino.plugin.opa.TestOpaBatchAccessControlFiltering#subsetProvider") + public void testFilterCatalogs( + MockResponse response, + List expectedItems) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_BATCH_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, response)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG, mockClient); + + List requestedCatalogs = ImmutableList.of("catalog_one", "catalog_two", "catalog_three"); + + Set result = authorizer.filterCatalogs( + requestingSecurityContext, + new LinkedHashSet<>(requestedCatalogs)); + assertThat(result).containsExactlyInAnyOrderElementsOf(getSubset(requestedCatalogs, expectedItems)); + + String expectedRequest = """ + { + "operation": "FilterCatalogs", + "filterResources": [ + { + "catalog": { + "name": "catalog_one" + } + }, + { + "catalog": { + "name": "catalog_two" + } + }, + { + "catalog": { + "name": "catalog_three" + } + } + ] + }"""; + assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action"); + } + + @ParameterizedTest(name = "{index}: {0}") + @MethodSource("io.trino.plugin.opa.TestOpaBatchAccessControlFiltering#subsetProvider") + public void testFilterSchemas( + MockResponse response, + List expectedItems) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_BATCH_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, response)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG, mockClient); + List requestedSchemas = ImmutableList.of("schema_one", "schema_two", "schema_three"); + + Set result = authorizer.filterSchemas( + requestingSecurityContext, + "my_catalog", + new LinkedHashSet<>(requestedSchemas)); + assertThat(result).containsExactlyInAnyOrderElementsOf(getSubset(requestedSchemas, expectedItems)); + + String expectedRequest = """ + { + "operation": "FilterSchemas", + "filterResources": [ + { + "schema": { + "schemaName": "schema_one", + "catalogName": "my_catalog" + } + }, + { + "schema": { + "schemaName": "schema_two", + "catalogName": "my_catalog" + } + }, + { + "schema": { + "schemaName": "schema_three", + "catalogName": "my_catalog" + } + } + ] + }"""; + assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action"); + } + + @ParameterizedTest(name = "{index}: {0}") + @MethodSource("io.trino.plugin.opa.TestOpaBatchAccessControlFiltering#subsetProvider") + public void testFilterTables( + MockResponse response, + List expectedItems) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_BATCH_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, response)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG, mockClient); + List tables = ImmutableList.builder() + .add(new SchemaTableName("schema_one", "table_one")) + .add(new SchemaTableName("schema_one", "table_two")) + .add(new SchemaTableName("schema_two", "table_one")) + .build(); + + Set result = authorizer.filterTables( + requestingSecurityContext, + "my_catalog", + new LinkedHashSet<>(tables)); + assertThat(result).containsExactlyInAnyOrderElementsOf(getSubset(tables, expectedItems)); + + String expectedRequest = """ + { + "operation": "FilterTables", + "filterResources": [ + { + "table": { + "tableName": "table_one", + "schemaName": "schema_one", + "catalogName": "my_catalog" + } + }, + { + "table": { + "tableName": "table_two", + "schemaName": "schema_one", + "catalogName": "my_catalog" + } + }, + { + "table": { + "tableName": "table_one", + "schemaName": "schema_two", + "catalogName": "my_catalog" + } + } + ] + }"""; + assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action"); + } + + private static Function buildHandler(Function dataBuilder) + { + return request -> new MockResponse(dataBuilder.apply(request), 200); + } + + @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 + InstrumentedHttpClient mockClient = createMockHttpClient( + OPA_BATCH_SERVER_URI, + buildValidatingRequestHandler( + requestingIdentity, + parsedRequest -> { + String tableName = parsedRequest.at("/input/action/filterResources/0/table/tableName").asText(); + String responseContents = switch (tableName) { + case "table_one" -> "{\"result\": [0, 1]}"; + case "table_two" -> "{\"result\": [1]}"; + default -> "{\"result\": []}"; + }; + return new MockResponse(responseContents, 200); + })); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG, mockClient); + Map> result = authorizer.filterColumns( + requestingSecurityContext, + "my_catalog", + requestedColumns); + + Set expectedRequests = Stream.of("table_one", "table_two", "table_three") + .map(tableName -> """ + { + "operation": "FilterColumns", + "filterResources": [ + { + "table": { + "tableName": "%s", + "schemaName": "my_schema", + "catalogName": "my_catalog", + "columns": ["%s_column_one", "%s_column_two"] + } + } + ] + } + """.formatted(tableName, tableName, tableName)) + .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()); + } + + @ParameterizedTest(name = "{index}: {0}") + @MethodSource("io.trino.plugin.opa.TestOpaBatchAccessControlFiltering#subsetProvider") + public void testFilterFunctions( + MockResponse response, + List expectedItems) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_BATCH_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, response)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG, mockClient); + List requestedFunctions = ImmutableList.builder() + .add(new SchemaFunctionName("my_schema", "function_one")) + .add(new SchemaFunctionName("my_schema", "function_two")) + .add(new SchemaFunctionName("my_schema", "function_three")) + .build(); + + Set result = authorizer.filterFunctions( + requestingSecurityContext, + "my_catalog", + new LinkedHashSet<>(requestedFunctions)); + assertThat(result).containsExactlyInAnyOrderElementsOf(getSubset(requestedFunctions, expectedItems)); + + String expectedRequest = """ + { + "operation": "FilterFunctions", + "filterResources": [ + { + "function": { + "catalogName": "my_catalog", + "schemaName": "my_schema", + "functionName": "function_one" + } + }, + { + "function": { + "catalogName": "my_catalog", + "schemaName": "my_schema", + "functionName": "function_two" + } + }, + { + "function": { + "catalogName": "my_catalog", + "schemaName": "my_schema", + "functionName": "function_three" + } + } + ] + }"""; + assertStringRequestsEqual(ImmutableSet.of(expectedRequest), mockClient.getRequests(), "/input/action"); + } + + @Test + public void testEmptyFilterColumns() + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_BATCH_SERVER_URI, request -> OK_RESPONSE); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG, mockClient); + + SchemaTableName tableOne = SchemaTableName.schemaTableName("my_schema", "table_one"); + SchemaTableName tableTwo = SchemaTableName.schemaTableName("my_schema", "table_two"); + Map> requestedColumns = ImmutableMap.>builder() + .put(tableOne, ImmutableSet.of()) + .put(tableTwo, ImmutableSet.of()) + .buildOrThrow(); + + Map> result = authorizer.filterColumns( + requestingSecurityContext, + "my_catalog", + requestedColumns); + assertThat(mockClient.getRequests()).isEmpty(); + assertThat(result).isEmpty(); + } + + @ParameterizedTest(name = "{index}: {0}") + @MethodSource("io.trino.plugin.opa.FilteringTestHelpers#emptyInputTestCases") + public void testEmptyRequests( + BiFunction callable) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_BATCH_SERVER_URI, request -> OK_RESPONSE); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG, 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 expectedException, + String expectedErrorMessage) + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_BATCH_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, failureResponse)); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG, mockClient); + + assertThatThrownBy(() -> callable.apply(authorizer, requestingSecurityContext)) + .isInstanceOf(expectedException) + .hasMessageContaining(expectedErrorMessage); + assertThat(mockClient.getRequests()).isNotEmpty(); + } + + @Test + public void testResponseOutOfBoundsThrows() + { + InstrumentedHttpClient mockClient = createMockHttpClient(OPA_BATCH_SERVER_URI, buildValidatingRequestHandler(requestingIdentity, 200, "{\"result\": [0, 1, 2]}")); + OpaAccessControl authorizer = createOpaAuthorizer(OPA_CONFIG, mockClient); + + assertThatThrownBy(() -> authorizer.filterCatalogs(requestingSecurityContext, ImmutableSet.of("catalog_one", "catalog_two"))) + .isInstanceOf(OpaQueryException.QueryFailed.class); + assertThatThrownBy(() -> authorizer.filterSchemas(requestingSecurityContext, "some_catalog", ImmutableSet.of("schema_one", "schema_two"))) + .isInstanceOf(OpaQueryException.QueryFailed.class); + assertThatThrownBy(() -> authorizer.filterTables( + requestingSecurityContext, + "some_catalog", + ImmutableSet.of( + new SchemaTableName("some_schema", "table_one"), + new SchemaTableName("some_schema", "table_two")))) + .isInstanceOf(OpaQueryException.QueryFailed.class); + assertThatThrownBy(() -> authorizer.filterColumns( + requestingSecurityContext, + "some_catalog", + ImmutableMap.>builder() + .put(new SchemaTableName("some_schema", "some_table"), ImmutableSet.of("column_one", "column_two")) + .buildOrThrow())) + .isInstanceOf(OpaQueryException.QueryFailed.class); + assertThatThrownBy(() -> authorizer.filterViewQueryOwnedBy( + requestingIdentity, + ImmutableSet.of(Identity.ofUser("identity_one"), Identity.ofUser("identity_two")))) + .isInstanceOf(OpaQueryException.QueryFailed.class); + } + + private static Stream subsetProvider() + { + return Stream.of( + Arguments.of(Named.of("All-3-resources", new MockResponse("{\"result\": [0, 1, 2]}", 200)), ImmutableList.of(0, 1, 2)), + Arguments.of(Named.of("First-and-last-resources", new MockResponse("{\"result\": [0, 2]}", 200)), ImmutableList.of(0, 2)), + Arguments.of(Named.of("Only-one-resource", new MockResponse("{\"result\": [2]}", 200)), ImmutableList.of(2)), + Arguments.of(Named.of("No-resources", new MockResponse("{\"result\": []}", 200)), ImmutableList.of())); + } + + private List getSubset(List allItems, List subsetPositions) + { + List result = new ArrayList<>(); + for (int i : subsetPositions) { + if (i < 0 || i >= allItems.size()) { + throw new IllegalArgumentException("Invalid subset of items provided"); + } + result.add(allItems.get(i)); + } + return result; + } +} diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaConfig.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaConfig.java new file mode 100644 index 0000000000000..766b043234125 --- /dev/null +++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaConfig.java @@ -0,0 +1,65 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.Map; + +import static io.airlift.configuration.testing.ConfigAssertions.assertFullMapping; +import static io.airlift.configuration.testing.ConfigAssertions.assertRecordedDefaults; +import static io.airlift.configuration.testing.ConfigAssertions.recordDefaults; + +public class TestOpaConfig +{ + @Test + public void testDefaults() + { + assertRecordedDefaults(recordDefaults(OpaConfig.class) + .setOpaUri(null) + .setOpaBatchUri(null) + .setOpaRowFiltersUri(null) + .setOpaColumnMaskingUri(null) + .setLogRequests(false) + .setLogResponses(false) + .setAllowPermissioningOperations(false)); + } + + @Test + public void testExplicitPropertyMappings() + { + Map properties = ImmutableMap.builder() + .put("opa.policy.uri", "https://opa.example.com") + .put("opa.policy.batched-uri", "https://opa-batch.example.com") + .put("opa.policy.row-filters-uri", "https://opa-row-filtering.example.com") + .put("opa.policy.column-masking-uri", "https://opa-column-masking.example.com") + .put("opa.log-requests", "true") + .put("opa.log-responses", "true") + .put("opa.allow-permissioning-operations", "true") + .buildOrThrow(); + + OpaConfig expected = new OpaConfig() + .setOpaUri(URI.create("https://opa.example.com")) + .setOpaBatchUri(URI.create("https://opa-batch.example.com")) + .setOpaRowFiltersUri(URI.create("https://opa-row-filtering.example.com")) + .setOpaColumnMaskingUri(URI.create("https://opa-column-masking.example.com")) + .setLogRequests(true) + .setLogResponses(true) + .setAllowPermissioningOperations(true); + + assertFullMapping(properties, expected); + } +} diff --git a/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaResponseDecoding.java b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaResponseDecoding.java new file mode 100644 index 0000000000000..ad08d98878131 --- /dev/null +++ b/plugin/trino-opa/src/test/java/io/trino/plugin/opa/TestOpaResponseDecoding.java @@ -0,0 +1,294 @@ +/* + * 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.json.JsonCodec; +import io.airlift.json.JsonCodecFactory; +import io.trino.plugin.opa.schema.OpaBatchQueryResult; +import io.trino.plugin.opa.schema.OpaColumnMaskQueryResult; +import io.trino.plugin.opa.schema.OpaQueryResult; +import io.trino.plugin.opa.schema.OpaRowFiltersQueryResult; +import io.trino.plugin.opa.schema.OpaViewExpression; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class TestOpaResponseDecoding +{ + private final JsonCodec responseCodec = new JsonCodecFactory().jsonCodec(OpaQueryResult.class); + private final JsonCodec batchResponseCodec = new JsonCodecFactory().jsonCodec(OpaBatchQueryResult.class); + private final JsonCodec rowFilteringResponseCodec = new JsonCodecFactory().jsonCodec(OpaRowFiltersQueryResult.class); + private final JsonCodec columnMaskingResponseCodec = new JsonCodecFactory().jsonCodec(OpaColumnMaskQueryResult.class); + + @Test + public void testCanDeserializeOpaSingleResponse() + { + testCanDeserializeOpaSingleResponse(true); + testCanDeserializeOpaSingleResponse(false); + } + + private void testCanDeserializeOpaSingleResponse(boolean response) + { + OpaQueryResult result = this.responseCodec.fromJson(""" + { + "decision_id": "foo", + "result": %s + }""".formatted(String.valueOf(response))); + assertThat(response).isEqualTo(result.result()); + assertThat(result.decisionId()).isEqualTo("foo"); + } + + @Test + public void testCanDeserializeOpaSingleResponseWithNoDecisionId() + { + testCanDeserializeOpaSingleResponseWithNoDecisionId(true); + testCanDeserializeOpaSingleResponseWithNoDecisionId(false); + } + + private void testCanDeserializeOpaSingleResponseWithNoDecisionId(boolean response) + { + OpaQueryResult result = this.responseCodec.fromJson(""" + { + "result": %s + }""".formatted(String.valueOf(response))); + assertThat(response).isEqualTo(result.result()); + assertThat(result.decisionId()).isNull(); + } + + @Test + public void testSingleResponseWithExtraFields() + { + OpaQueryResult result = this.responseCodec.fromJson(""" + { + "result": true, + "someExtraInfo": ["foo"] + }"""); + assertThat(result.result()).isTrue(); + assertThat(result.decisionId()).isNull(); + } + + @Test + public void testUndefinedDecisionSingleResponseTreatedAsDeny() + { + OpaQueryResult result = this.responseCodec.fromJson("{}"); + assertThat(result.result()).isFalse(); + assertThat(result.decisionId()).isNull(); + } + + @Test + public void testIllegalResponseThrows() + { + testIllegalResponseDecodingThrows("{\"result\": \"foo\"}", responseCodec); + } + + @Test + public void testBatchEmptyOrUndefinedResponses() + { + testBatchEmptyOrUndefinedResponses("{}"); + testBatchEmptyOrUndefinedResponses("{\"result\": []}"); + } + + private void testBatchEmptyOrUndefinedResponses(String response) + { + OpaBatchQueryResult result = this.batchResponseCodec.fromJson(response); + assertThat(result.result()).isEmpty(); + assertThat(result.decisionId()).isNull(); + } + + @Test + public void testBatchResponseWithItemsNoDecisionId() + { + OpaBatchQueryResult result = this.batchResponseCodec.fromJson(""" + { + "result": [1, 2, 3] + }"""); + assertThat(result.result()).containsExactly(1, 2, 3); + assertThat(result.decisionId()).isNull(); + } + + @Test + public void testBatchResponseWithItemsAndDecisionId() + { + OpaBatchQueryResult result = this.batchResponseCodec.fromJson(""" + { + "result": [1, 2, 3], + "decision_id": "foobar" + }"""); + assertThat(result.result()).containsExactly(1, 2, 3); + assertThat(result.decisionId()).isEqualTo("foobar"); + } + + @Test + public void testBatchResponseIllegalResponseThrows() + { + testIllegalResponseDecodingThrows(""" + { + "result": ["foo"], + "decision_id": "foobar" + }""", batchResponseCodec); + } + + @Test + public void testBatchResponseWithExtraFields() + { + OpaBatchQueryResult result = this.batchResponseCodec.fromJson(""" + { + "result": [1, 2, 3], + "decision_id": "foobar", + "someInfo": "foo", + "andAnObject": {} + }"""); + assertThat(result.result()).containsExactly(1, 2, 3); + assertThat(result.decisionId()).isEqualTo("foobar"); + } + + @Test + public void testRowFilteringEmptyOrUndefinedResponses() + { + testRowFilteringEmptyOrUndefinedResponses("{}"); + testRowFilteringEmptyOrUndefinedResponses("{\"result\": []}"); + } + + private void testRowFilteringEmptyOrUndefinedResponses(String response) + { + OpaRowFiltersQueryResult result = this.rowFilteringResponseCodec.fromJson(response); + assertThat(result.result()).isEmpty(); + assertThat(result.decisionId()).isNull(); + } + + @Test + public void testRowFilteringResponseWithItemsNoDecisionId() + { + OpaRowFiltersQueryResult result = this.rowFilteringResponseCodec.fromJson(""" + { + "result": [ + {"expression": "foo"}, + {"expression": "bar", "identity": "some_identity"} + ] + }"""); + assertThat(result.result()).containsExactlyInAnyOrder( + new OpaViewExpression("foo", Optional.empty()), + new OpaViewExpression("bar", Optional.of("some_identity"))); + assertThat(result.decisionId()).isNull(); + } + + @Test + public void testRowFilteringResponseWithItemsAndDecisionId() + { + OpaRowFiltersQueryResult result = this.rowFilteringResponseCodec.fromJson(""" + { + "result": [{"expression": "test_expression"}], + "decision_id": "some_id" + }"""); + assertThat(result.result()).containsExactly(new OpaViewExpression("test_expression", Optional.empty())); + assertThat(result.decisionId()).isEqualTo("some_id"); + } + + @Test + public void testRowFilteringResponseWithExtraFields() + { + OpaRowFiltersQueryResult result = this.rowFilteringResponseCodec.fromJson(""" + { + "result": [{"expression": "test_expression"}], + "decision_id": "foobar", + "someInfo": "foo", + "andAnObject": {} + }"""); + assertThat(result.result()).containsExactly(new OpaViewExpression("test_expression", Optional.empty())); + assertThat(result.decisionId()).isEqualTo("foobar"); + } + + @Test + public void testRowFilteringResponseIllegalResponseThrows() + { + testIllegalResponseDecodingThrows(""" + { + "result": ["foo"] + }""", rowFilteringResponseCodec); + } + + @Test + public void testColumnMaskingEmptyOrUndefinedResponse() + { + OpaColumnMaskQueryResult emptyResult = columnMaskingResponseCodec.fromJson("{}"); + assertThat(emptyResult.result()).isEmpty(); + assertThat(emptyResult.decisionId()).isNull(); + OpaColumnMaskQueryResult undefinedResult = columnMaskingResponseCodec.fromJson("{\"result\": null}"); + assertThat(undefinedResult.result()).isEmpty(); + assertThat(undefinedResult.decisionId()).isNull(); + } + + @Test + public void testColumnMaskingResponsesWithNoDecisionId() + { + OpaColumnMaskQueryResult result = this.columnMaskingResponseCodec.fromJson(""" + { + "result": {"expression": "test_expression"} + }"""); + assertThat(result.result()).contains(new OpaViewExpression("test_expression", Optional.empty())); + assertThat(result.decisionId()).isNull(); + } + + @Test + public void testColumnMaskingResponsesWithDecisionId() + { + OpaColumnMaskQueryResult resultWithExpression = this.columnMaskingResponseCodec.fromJson(""" + { + "result": {"expression": "test_expression"}, + "decision_id": "foobar" + }"""); + OpaColumnMaskQueryResult resultWithExpressionAndIdentity = this.columnMaskingResponseCodec.fromJson(""" + { + "result": {"expression": "test_expression", "identity": "some_identity"}, + "decision_id": "foobar" + }"""); + assertThat(resultWithExpression.result()).contains(new OpaViewExpression("test_expression", Optional.empty())); + assertThat(resultWithExpressionAndIdentity.result()).contains(new OpaViewExpression("test_expression", Optional.of("some_identity"))); + assertThat(resultWithExpression.decisionId()).isEqualTo("foobar"); + assertThat(resultWithExpressionAndIdentity.decisionId()).isEqualTo("foobar"); + } + + @Test + public void testColumnMaskingResponseWithExtraFields() + { + OpaColumnMaskQueryResult result = this.columnMaskingResponseCodec.fromJson(""" + { + "result": {"expression": "test_expression"}, + "decision_id": "foobar", + "someInfo": "foo", + "andAnObject": {} + }"""); + assertThat(result.result()).contains(new OpaViewExpression("test_expression", Optional.empty())); + assertThat(result.decisionId()).isEqualTo("foobar"); + } + + @Test + public void testColumnMaskingResponseIllegalResponseThrows() + { + testIllegalResponseDecodingThrows(""" + { + "result": {"foo": "bar"} + }""", columnMaskingResponseCodec); + } + + private void testIllegalResponseDecodingThrows(String rawResponse, JsonCodec codec) + { + assertThatThrownBy(() -> codec.fromJson(rawResponse)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid JSON"); + } +} diff --git a/pom.xml b/pom.xml index 2284eccc29681..f5c4fc8dc8e9f 100644 --- a/pom.xml +++ b/pom.xml @@ -92,6 +92,7 @@ plugin/trino-mongodb plugin/trino-mysql plugin/trino-mysql-event-listener + plugin/trino-opa plugin/trino-oracle plugin/trino-password-authenticators plugin/trino-phoenix5