diff --git a/benchmarks/ppc-application.conf b/benchmarks/ppc-application.conf new file mode 100644 index 00000000..ae98ecea --- /dev/null +++ b/benchmarks/ppc-application.conf @@ -0,0 +1,56 @@ +http.polaris-base-url = "http://172.30.4.231:8181" +http.dremio-base-url = "http://172.30.4.231:9047" + +auth { + dremio-type = "ppc" + dremio-pat = "TFYJnlG0QSS2OsFj5LTdBuxwPiHqC02CTrx3K75Kc7FmeoZtpfzoEJm723Ru7w==" +} + +dataset.tree { + namespace-width = 2 + namespace-depth = 8 + tables-per-namespace = 2 + views-per-namespace = 2 + + columns-per-table = 100 + columns-per-view = 100 + + default-base-location = "gs://dremio-ppc/k8s-dac-20182" + + namespace-properties = 100 + table-properties = 100 + view-properties = 100 + + mangle-names = true + ppc-source-name = "gcs-ppc" +} + +workload { + create-tree-dataset { + table-concurrency = 10 + view-concurrency = 10 + } + + read-tree-dataset { + table-concurrency = 20 + view-concurrency = 10 + } + + table-commits-creator { + commits-throughput = 10 + read-throughput = 10 + duration-in-minutes = 1 + } + + read-update-tree-dataset { + read-write-ratio = 0.5 + throughput = 100 + duration-in-minutes = 5 + } + + create-tree-wiki { + namespace-concurrency = 50 + table-concurrency = 50 + view-concurrency = 50 + } +} diff --git a/benchmarks/ppc2dc-application.conf b/benchmarks/ppc2dc-application.conf new file mode 100644 index 00000000..d0417d4a --- /dev/null +++ b/benchmarks/ppc2dc-application.conf @@ -0,0 +1,59 @@ +http.polaris-base-url = "http://dcs-aws-39775-ns.catalog.drem.io/" +http.dremio-base-url = "http://dcs-aws-39775-ns.app.drem.io/" + +auth { + dremio-type = "ppc2dc" + dremio-username = "dcstest@dremio.com" + dremio-password = "dremio@123" + dremio-org-id = "d3b8275c-fbf8-4485-9996-89258b13da3b" +} + +dataset.tree { + catalog-name = "serverlessproject" + namespace-width = 2 + namespace-depth = 8 + tables-per-namespace = 2 + views-per-namespace = 2 + + columns-per-table = 100 + columns-per-view = 100 + + default-base-location = "s3://dcs-aws-customer-39775/catalog" + + namespace-properties = 100 + table-properties = 100 + view-properties = 100 + + mangle-names = false + ppc-source-name = "gcs-ppc" +} + +workload { + create-tree-dataset { + table-concurrency = 10 + view-concurrency = 10 + } + + read-tree-dataset { + table-concurrency = 10 + view-concurrency = 10 + } + + table-commits-creator { + commits-throughput = 10 + read-throughput = 10 + duration-in-minutes = 1 + } + + read-update-tree-dataset { + read-write-ratio = 0.5 + throughput = 100 + duration-in-minutes = 5 + } + + create-tree-wiki { + namespace-concurrency = 50 + table-concurrency = 50 + view-concurrency = 50 + } +} diff --git a/benchmarks/ppc2dc-noauth-application.conf b/benchmarks/ppc2dc-noauth-application.conf new file mode 100644 index 00000000..c4135867 --- /dev/null +++ b/benchmarks/ppc2dc-noauth-application.conf @@ -0,0 +1,59 @@ +http.polaris-base-url = "http://localhost:9181/" +http.dremio-base-url = "" + +auth { + dremio-type = "ppc2dc-without-authentication" + dremio-org-id = "d3b8275c-fbf8-4485-9996-89258b13da3b" + dremio-user-id = "c7b8c04d-264d-4ef7-adfe-fca6c4f6e78a" + dremio-project-id = "38aa0da6-5839-435f-b644-e112b173cbc4" +} + +dataset.tree { + catalog-name = "serverlessproject" + namespace-width = 2 + namespace-depth = 8 + tables-per-namespace = 2 + views-per-namespace = 2 + + columns-per-table = 100 + columns-per-view = 100 + + default-base-location = "s3://dcs-aws-customer-39775/catalog" + + namespace-properties = 100 + table-properties = 100 + view-properties = 100 + + mangle-names = false + ppc-source-name = "gcs-ppc" +} + +workload { + create-tree-dataset { + table-concurrency = 10 + view-concurrency = 10 + } + + read-tree-dataset { + table-concurrency = 20 + view-concurrency = 10 + } + + table-commits-creator { + commits-throughput = 10 + read-throughput = 10 + duration-in-minutes = 1 + } + + read-update-tree-dataset { + read-write-ratio = 0.5 + throughput = 100 + duration-in-minutes = 5 + } + + create-tree-wiki { + namespace-concurrency = 50 + table-concurrency = 50 + view-concurrency = 50 + } +} diff --git a/benchmarks/src/gatling/resources/benchmark-defaults.conf b/benchmarks/src/gatling/resources/benchmark-defaults.conf index e814597d..3b2470b7 100644 --- a/benchmarks/src/gatling/resources/benchmark-defaults.conf +++ b/benchmarks/src/gatling/resources/benchmark-defaults.conf @@ -44,6 +44,33 @@ auth { retryable-http-codes = [500] } +# Role-Based Access Control configuration +rbac { + # Whether to enable RBAC in the CreateTreeDataset simulation + # When enabled, principals, roles, and grants will be created alongside the dataset + # Default: false + enabled = false + + # Number of principals to create + # The first principal (P_0) will be assigned the service_administrator role + # Remaining principals will be assigned specific principal roles (e.g. data_engineer, data_scientist) + # Default: 5 + num-principals = 5 + + # Names of catalog roles to create per catalog + # These roles hold privileges on catalog objects (namespaces, tables, views) + # An additional role "catalog_administrator" will be created implicitly for each catalog + # Default: ["catalog_reader", "catalog_contributor"] + catalog-role-names = ["catalog_reader", "catalog_contributor"] + + # Names of principal roles to create (must match catalog-role-names in count) + # These roles are assigned to principals and mapped to corresponding catalog roles + # An additional role "service_administrator" will be created and assigned to the first principal + # The service_administrator principal role is mapped to the catalog_administrator catalog role for all catalogs + # Default: ["data_engineer", "data_scientist"] + principal-role-names = ["data_engineer", "data_scientist"] +} + # Dataset tree structure configuration dataset.tree { # JSON to supply as a StorageConfigInfo when creating a catalog diff --git a/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/actions/RbacActions.scala b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/actions/RbacActions.scala new file mode 100644 index 00000000..6dc87f54 --- /dev/null +++ b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/actions/RbacActions.scala @@ -0,0 +1,551 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.benchmarks.actions + +import io.gatling.core.Predef._ +import io.gatling.core.feeder.Feeder +import io.gatling.core.structure.ChainBuilder +import io.gatling.http.Predef._ +import org.apache.polaris.benchmarks.RetryOnHttpCodes.{ + retryOnHttpStatus, + HttpRequestBuilderWithStatusSave +} +import org.apache.polaris.benchmarks.parameters.{DatasetParameters, RbacParameters} +import org.apache.polaris.benchmarks.util.Mangler + +import java.util.concurrent.atomic.AtomicReference + +/** + * Actions for RBAC (Role-Based Access Control) operations in Polaris benchmarks. This class + * provides feeders and statements for managing principals, principal roles, catalog roles, and + * grants. + * + * @param dp Dataset parameters controlling the dataset generation + * @param rp RBAC parameters controlling the RBAC setup + * @param accessToken Reference to the authentication token for API requests + * @param maxRetries Maximum number of retry attempts for failed operations + * @param retryableHttpCodes HTTP status codes that should trigger a retry + */ +case class RbacActions( + dp: DatasetParameters, + rp: RbacParameters, + namespaceActions: NamespaceActions, + tableActions: TableActions, + viewActions: ViewActions, + accessToken: AtomicReference[String], + maxRetries: Int = 10, + retryableHttpCodes: Set[Int] = Set(409, 500) +) { + private val mangler = Mangler(dp.mangleNames) + + /** Helper to return 0 if RBAC is disabled, otherwise the given value. */ + private def ifRbacEnabled(value: => Int): Int = if (rp.enableRbac) value else 0 + + // -------------------------------------------------------------------------------- + // Principal Feeders + // -------------------------------------------------------------------------------- + + /** + * Creates a Gatling Feeder that generates principal data. Each principal will be named "P_n" + * where n is a sequential number (optionally mangled for longer names). + * + * @return An iterator providing principal names + */ + def principalFeeder(): Feeder[Any] = Iterator + .from(0) + .map { i => + Map( + "principalName" -> mangler.maybeManglePrincipal(i), + "principalOrdinal" -> i + ) + } + .take(rp.numPrincipals) + + /** Number of principals to create, or 0 if RBAC is disabled. */ + val numPrincipals: Int = ifRbacEnabled(rp.numPrincipals) + + // -------------------------------------------------------------------------------- + // Principal Role Feeders + // -------------------------------------------------------------------------------- + + /** + * Creates a Gatling Feeder that generates principal role data. Principal roles are + * catalog-independent and include: + * - One "service_administrator" role + * - Configured roles from principalRoleNames (e.g., owner, reader, contributor) + * + * @return An iterator providing principal role names and metadata + */ + def principalRoleFeeder(): Feeder[Any] = { + // First, the service_administrator role + val serviceAdminRole = Iterator.single( + Map("principalRoleName" -> mangler.maybeManglePrincipalRole("service_administrator")) + ) + // Then, the configured principal roles + val principalRoles = rp.principalRoleNames.map { roleName => + Map("principalRoleName" -> mangler.maybeManglePrincipalRole(roleName)) + } + serviceAdminRole ++ principalRoles.iterator + } + + /** Number of principal roles to create, or 0 if RBAC is disabled. */ + val numPrincipalRoles: Int = ifRbacEnabled(1 + rp.principalRoleNames.size) + + /** + * Creates a Gatling Feeder that generates principal-to-principal-role assignments. + * - P_0 gets service_administrator + * - P_1 through P_N get assigned to principal roles in round-robin fashion (e.g., P_1 → owner, + * P_2 → reader, P_3 → contributor, P_4 → owner, ...) + * + * @return An iterator providing principal-to-role assignment data + */ + def principalRoleAssignmentFeeder(): Feeder[Any] = { + // P_0 always gets service_administrator + val serviceAdminAssignment = Iterator.single( + Map( + "principalName" -> mangler.maybeManglePrincipal(0), + "principalRoleName" -> "service_administrator" + ) + ) + // Remaining principals get assigned to principal roles round-robin + val roleAssignments = (1 until rp.numPrincipals).map { principalOrdinal => + val roleName = rp.principalRoleNames((principalOrdinal - 1) % rp.principalRoleNames.size) + Map( + "principalName" -> mangler.maybeManglePrincipal(principalOrdinal), + "principalRoleName" -> mangler.maybeManglePrincipalRole(roleName) + ) + } + serviceAdminAssignment ++ roleAssignments.iterator + } + + /** Number of principal role assignments, or 0 if RBAC is disabled. */ + val numPrincipalRoleAssignments: Int = ifRbacEnabled(rp.numPrincipals) + + // -------------------------------------------------------------------------------- + // Catalog Role Feeders + // -------------------------------------------------------------------------------- + + /** + * Creates a Gatling Feeder that generates catalog role data. For each catalog, creates: + * - The implicit "catalog_administrator" role + * - Configured roles from catalogRoleNames (e.g., catalog_reader, catalog_contributor) + * + * @return An iterator providing catalog role names and metadata + */ + def catalogRoleFeeder(): Feeder[Any] = (0 until dp.numCatalogs).flatMap { catalogOrdinal => + val catalogName = s"C_$catalogOrdinal" + allCatalogRoleNames.map { roleName => + Map( + "catalogRoleName" -> mangler.maybeMangleCatalogRole(roleName, catalogName), + "catalogName" -> catalogName, + "catalogOrdinal" -> catalogOrdinal + ) + } + }.iterator + + private val allCatalogRoleNames = "catalog_administrator" +: rp.catalogRoleNames + + /** Number of catalog roles to create, or 0 if RBAC is disabled. */ + val numCatalogRoles: Int = ifRbacEnabled(dp.numCatalogs * allCatalogRoleNames.size) + + /** + * Creates a Gatling Feeder that generates catalog-role-to-principal-role assignments. Each + * principal role is mapped to its corresponding catalog role across ALL catalogs. E.g. + * - service_administrator → catalog_administrator for all catalogs + * - data_engineer → catalog_reader for all catalogs + * - data_scientist → catalog_contributor for all catalogs + * + * @return An iterator providing catalog-role-to-principal-role assignment data + */ + def catalogRoleAssignmentFeeder(): Feeder[Any] = { + // service_administrator gets all catalog_administrator roles + val serviceAdminAssignments = (0 until dp.numCatalogs).map { catalogOrdinal => + val catalogName = s"C_$catalogOrdinal" + Map( + "principalRoleName" -> "service_administrator", + "catalogRoleName" -> mangler.maybeMangleCatalogRole("catalog_administrator", catalogName), + "catalogName" -> s"C_$catalogOrdinal" + ) + } + // Each principal role gets its corresponding catalog role across all catalogs + val principalRoleAssignments = (0 until dp.numCatalogs).flatMap { catalogOrdinal => + val catalogName = s"C_$catalogOrdinal" + rp.catalogRoleNames.zip(rp.principalRoleNames).map { + case (catalogRoleName, principalRoleName) => + Map( + "principalRoleName" -> mangler.maybeManglePrincipalRole(principalRoleName), + "catalogRoleName" -> mangler.maybeMangleCatalogRole(catalogRoleName, catalogName), + "catalogName" -> catalogName + ) + } + } + (serviceAdminAssignments ++ principalRoleAssignments).iterator + } + + /** Number of catalog role assignments, or 0 if RBAC is disabled. */ + val numCatalogRoleAssignments: Int = + ifRbacEnabled(dp.numCatalogs * (rp.catalogRoleNames.size + 1)) + + // -------------------------------------------------------------------------------- + // Grant Feeders + // -------------------------------------------------------------------------------- + + /** + * Mapping of catalog role types to their namespace privileges. + * - catalog_administrator: NAMESPACE_FULL_METADATA + * - all configured roles: NAMESPACE_LIST + */ + private val namespacePrivilegesByRole: Map[String, Seq[String]] = { + val adminPrivilegesMap = Map("catalog_administrator" -> Seq("NAMESPACE_FULL_METADATA")) + val othersPrivilegesMap = rp.catalogRoleNames.map(_ -> Seq("NAMESPACE_LIST")).toMap + adminPrivilegesMap ++ othersPrivilegesMap + } + + /** + * Creates a grant feeder for namespace privileges. For each namespace, generates grants for all + * catalog roles with their respective privileges. + * + * @return An iterator providing grant data for namespace privileges + */ + def namespaceGrantFeeder(): Feeder[Any] = + namespaceActions.namespaceIdentityFeeder().flatMap { ns => + val catalogName = ns("catalogName").asInstanceOf[String] + allCatalogRoleNames.flatMap { roleType => + val catalogRoleName = mangler.maybeMangleCatalogRole(roleType, catalogName) + val privileges = namespacePrivilegesByRole(roleType) + privileges.map { privilege => + Map( + "catalogName" -> ns("catalogName"), + "catalogRoleName" -> catalogRoleName, + "namespaceJsonPath" -> ns("namespaceJsonPath"), + "privilege" -> privilege + ) + } + }.iterator + } + + /** Number of grants per namespace (across all roles), or 0 if RBAC is disabled. */ + val numGrantsPerNamespace: Int = + ifRbacEnabled(namespacePrivilegesByRole.values.map(_.size).sum) + + /** Total number of namespace grants to create, or 0 if RBAC is disabled. */ + val numNamespaceGrants: Int = ifRbacEnabled(dp.nAryTree.numberOfNodes * numGrantsPerNamespace) + + /** + * Mapping of catalog role types to their table privileges. + * - catalog_administrator: TABLE_FULL_METADATA, TABLE_READ_DATA, TABLE_WRITE_DATA + * - all configured roles: either TABLE_LIST, TABLE_READ_DATA or TABLE_LIST, TABLE_WRITE_DATA + */ + private val tablePrivilegesByRole: Map[String, Seq[String]] = { + val adminPrivilegesMap = Map( + "catalog_administrator" -> Seq("TABLE_FULL_METADATA", "TABLE_READ_DATA", "TABLE_WRITE_DATA") + ) + val othersPrivileges = + Seq(Seq("TABLE_LIST", "TABLE_READ_DATA"), Seq("TABLE_LIST", "TABLE_WRITE_DATA")) + val othersPrivilegesMap = rp.catalogRoleNames.zipWithIndex.map { case (roleName, ordinal) => + roleName -> othersPrivileges(ordinal % othersPrivileges.size) + }.toMap + adminPrivilegesMap ++ othersPrivilegesMap + } + + /** + * Creates a grant feeder for table privileges. For each table, generates grants for all catalog + * roles with their respective privileges. + * + * @return An iterator providing grant data for table privileges + */ + def tableGrantFeeder(): Feeder[Any] = tableActions.tableIdentityFeeder().flatMap { table => + val catalogName = table("catalogName").asInstanceOf[String] + val namespaceJsonPath = table("namespaceJsonPath").asInstanceOf[String] + val tableName = table("tableName").asInstanceOf[String] + allCatalogRoleNames.flatMap { roleType => + val catalogRoleName = mangler.maybeMangleCatalogRole(roleType, catalogName) + val privileges = tablePrivilegesByRole(roleType) + privileges.map { privilege => + Map( + "catalogName" -> catalogName, + "catalogRoleName" -> catalogRoleName, + "namespaceJsonPath" -> namespaceJsonPath, + "tableName" -> tableName, + "privilege" -> privilege + ) + } + }.iterator + } + + /** Number of grants per table (across all roles), or 0 if RBAC is disabled. */ + val numGrantsPerTable: Int = ifRbacEnabled(tablePrivilegesByRole.values.map(_.size).sum) + + /** Total number of table grants to create, or 0 if RBAC is disabled. */ + val numTableGrants: Int = ifRbacEnabled(dp.numTables * numGrantsPerTable) + + /** + * Mapping of catalog role types to their view privileges. + * - catalog_administrator: VIEW_FULL_METADATA + * - all configured roles: either VIEW_LIST, VIEW_READ_PROPERTIES or VIEW_LIST, + * VIEW_WRITE_PROPERTIES + */ + private val viewPrivilegesByRole: Map[String, Seq[String]] = { + val adminPrivilegesMap = Map("catalog_administrator" -> Seq("VIEW_FULL_METADATA")) + val othersPrivileges = + Seq(Seq("VIEW_LIST", "VIEW_READ_PROPERTIES"), Seq("VIEW_LIST", "VIEW_WRITE_PROPERTIES")) + val othersPrivilegesMap = rp.catalogRoleNames.zipWithIndex.map { case (roleName, ordinal) => + roleName -> othersPrivileges(ordinal % othersPrivileges.size) + }.toMap + adminPrivilegesMap ++ othersPrivilegesMap + } + + /** + * Creates a grant feeder for view privileges. For each view, generates grants for all catalog + * roles with their respective privileges. + * + * @return An iterator providing grant data for view privileges + */ + def viewGrantFeeder(): Feeder[Any] = viewActions.viewIdentityFeeder().flatMap { view => + val catalogName = view("catalogName").asInstanceOf[String] + val namespaceJsonPath = view("namespaceJsonPath").asInstanceOf[String] + val viewName = view("viewName").asInstanceOf[String] + allCatalogRoleNames.flatMap { roleType => + val catalogRoleName = mangler.maybeMangleCatalogRole(roleType, catalogName) + val privileges = viewPrivilegesByRole(roleType) + privileges.map { privilege => + Map( + "catalogName" -> catalogName, + "catalogRoleName" -> catalogRoleName, + "namespaceJsonPath" -> namespaceJsonPath, + "viewName" -> viewName, + "privilege" -> privilege + ) + } + }.iterator + } + + /** Number of grants per view (across all roles), or 0 if RBAC is disabled. */ + val numGrantsPerView: Int = ifRbacEnabled(viewPrivilegesByRole.values.map(_.size).sum) + + /** Total number of view grants to create, or 0 if RBAC is disabled. */ + val numViewGrants: Int = ifRbacEnabled(dp.numViews * numGrantsPerView) + + // -------------------------------------------------------------------------------- + // Principal Statements + // -------------------------------------------------------------------------------- + + /** + * Creates a new principal. The principal name is defined in the [[RbacActions.principalFeeder]]. + */ + val createPrincipal: ChainBuilder = + retryOnHttpStatus(maxRetries, retryableHttpCodes, "Create principal")( + http("Create Principal") + .post("/api/management/v1/principals") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body( + StringBody( + """{ + | "principal": { + | "name": "#{principalName}" + | } + |}""".stripMargin + ) + ) + .saveHttpStatusCode() + .check(status.is(201)) + ) + + // -------------------------------------------------------------------------------- + // Principal Role Statements + // -------------------------------------------------------------------------------- + + /** + * Creates a new principal role. The role name is defined in the + * [[RbacActions.principalRoleFeeder]]. + */ + val createPrincipalRole: ChainBuilder = + retryOnHttpStatus(maxRetries, retryableHttpCodes, "Create principal role")( + http("Create Principal Role") + .post("/api/management/v1/principal-roles") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body( + StringBody( + """{ + | "principalRole": { + | "name": "#{principalRoleName}" + | } + |}""".stripMargin + ) + ) + .saveHttpStatusCode() + .check(status.is(201)) + ) + + /** + * Assigns a principal role to a principal. The mapping is defined in the + * [[RbacActions.principalRoleAssignmentFeeder]]. + */ + val assignPrincipalRoleToPrincipal: ChainBuilder = + retryOnHttpStatus(maxRetries, retryableHttpCodes, "Assign principal role to principal")( + http("Assign Principal Role to Principal") + .put("/api/management/v1/principals/#{principalName}/principal-roles") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body( + StringBody( + """{ + | "principalRole": { + | "name": "#{principalRoleName}" + | } + |}""".stripMargin + ) + ) + .saveHttpStatusCode() + .check(status.is(201)) + ) + + // -------------------------------------------------------------------------------- + // Catalog Role Statements + // -------------------------------------------------------------------------------- + + /** + * Creates a new catalog role within a catalog. The role details are defined in the + * [[RbacActions.catalogRoleFeeder]]. + */ + val createCatalogRole: ChainBuilder = + retryOnHttpStatus(maxRetries, retryableHttpCodes, "Create catalog role")( + http("Create Catalog Role") + .post("/api/management/v1/catalogs/#{catalogName}/catalog-roles") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body( + StringBody( + """{ + | "catalogRole": { + | "name": "#{catalogRoleName}" + | } + |}""".stripMargin + ) + ) + .saveHttpStatusCode() + .check(status.is(201)) + ) + + /** + * Assigns a catalog role to a principal role. The mapping is defined in the + * [[RbacActions.catalogRoleAssignmentFeeder]]. + */ + val assignCatalogRoleToPrincipalRole: ChainBuilder = + retryOnHttpStatus(maxRetries, retryableHttpCodes, "Assign catalog role to principal role")( + http("Assign Catalog Role to Principal Role") + .put("/api/management/v1/principal-roles/#{principalRoleName}/catalog-roles/#{catalogName}") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body( + StringBody( + """{ + | "catalogRole": { + | "name": "#{catalogRoleName}" + | } + |}""".stripMargin + ) + ) + .saveHttpStatusCode() + .check(status.is(201)) + ) + + // -------------------------------------------------------------------------------- + // Grant Statements + // -------------------------------------------------------------------------------- + + /** + * Grants a namespace privilege to a catalog role. The grant details are defined by calling + * [[RbacActions.namespaceGrantFeeder]] with the appropriate parameters. + */ + val grantNamespacePrivilege: ChainBuilder = + retryOnHttpStatus(maxRetries, retryableHttpCodes, "Grant namespace privilege")( + http("Grant Namespace Privilege") + .put("/api/management/v1/catalogs/#{catalogName}/catalog-roles/#{catalogRoleName}/grants") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body( + StringBody( + """{ + | "grant": { + | "type": "namespace", + | "namespace": #{namespaceJsonPath}, + | "privilege": "#{privilege}" + | } + |}""".stripMargin + ) + ) + .saveHttpStatusCode() + .check(status.is(201)) + ) + + /** + * Grants a table privilege to a catalog role. The grant details are defined by calling + * [[RbacActions.tableGrantFeeder]] with the appropriate parameters. + */ + val grantTablePrivilege: ChainBuilder = + retryOnHttpStatus(maxRetries, retryableHttpCodes, "Grant table privilege")( + http("Grant Table Privilege") + .put("/api/management/v1/catalogs/#{catalogName}/catalog-roles/#{catalogRoleName}/grants") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body( + StringBody( + """{ + | "grant": { + | "type": "table", + | "namespace": #{namespaceJsonPath}, + | "tableName": "#{tableName}", + | "privilege": "#{privilege}" + | } + |}""".stripMargin + ) + ) + .saveHttpStatusCode() + .check(status.is(201)) + ) + + /** + * Grants a view privilege to a catalog role. The grant details are defined by calling + * [[RbacActions.viewGrantFeeder]] with the appropriate parameters. + */ + val grantViewPrivilege: ChainBuilder = + retryOnHttpStatus(maxRetries, retryableHttpCodes, "Grant view privilege")( + http("Grant View Privilege") + .put("/api/management/v1/catalogs/#{catalogName}/catalog-roles/#{catalogRoleName}/grants") + .header("Authorization", "Bearer #{accessToken}") + .header("Content-Type", "application/json") + .body( + StringBody( + """{ + | "grant": { + | "type": "view", + | "namespace": #{namespaceJsonPath}, + | "viewName": "#{viewName}", + | "privilege": "#{privilege}" + | } + |}""".stripMargin + ) + ) + .saveHttpStatusCode() + .check(status.is(201)) + ) +} diff --git a/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/actions/TableActions.scala b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/actions/TableActions.scala index 53243767..48b4458e 100644 --- a/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/actions/TableActions.scala +++ b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/actions/TableActions.scala @@ -75,6 +75,7 @@ case class TableActions( "catalogName" -> "C_0", "parentNamespacePath" -> parentNamespacePath, "multipartNamespace" -> parentNamespacePath.mkString("%1F"), + "namespaceJsonPath" -> Json.toJson(parentNamespacePath).toString(), "tableName" -> mangler.maybeMangleTable(tableId) ) } diff --git a/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/actions/ViewActions.scala b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/actions/ViewActions.scala index 4775ae3e..027e801b 100644 --- a/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/actions/ViewActions.scala +++ b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/actions/ViewActions.scala @@ -66,6 +66,7 @@ case class ViewActions( "catalogName" -> s"C_$catalogId", "parentNamespacePath" -> parentNamespacePath, "multipartNamespace" -> parentNamespacePath.mkString("%1F"), + "namespaceJsonPath" -> Json.toJson(parentNamespacePath).toString(), "viewName" -> mangler.maybeMangleView(viewId), "viewId" -> viewId ) diff --git a/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/parameters/BenchmarkConfig.scala b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/parameters/BenchmarkConfig.scala index dfb3b3d8..8e655914 100644 --- a/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/parameters/BenchmarkConfig.scala +++ b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/parameters/BenchmarkConfig.scala @@ -20,6 +20,8 @@ package org.apache.polaris.benchmarks.parameters import com.typesafe.config.{Config, ConfigFactory} +import scala.jdk.CollectionConverters._ + object BenchmarkConfig { val config: BenchmarkConfig = apply() @@ -28,6 +30,7 @@ object BenchmarkConfig { val http: Config = config.getConfig("http") val auth: Config = config.getConfig("auth") + val rbac: Config = config.getConfig("rbac") val dataset: Config = config.getConfig("dataset.tree") val workload: Config = config.getConfig("workload") @@ -97,7 +100,14 @@ object BenchmarkConfig { dataset.getBoolean("mangle-names") ) - BenchmarkConfig(connectionParams, authParams, workloadParams, datasetParams) + val rbacParams = RbacParameters( + rbac.getBoolean("enabled"), + rbac.getInt("num-principals"), + rbac.getStringList("catalog-role-names").asScala.toSeq, + rbac.getStringList("principal-role-names").asScala.toSeq + ) + + BenchmarkConfig(connectionParams, authParams, workloadParams, datasetParams, rbacParams) } } @@ -105,5 +115,6 @@ case class BenchmarkConfig( connectionParameters: ConnectionParameters, authParameters: AuthParameters, workloadParameters: WorkloadParameters, - datasetParameters: DatasetParameters + datasetParameters: DatasetParameters, + rbacParameters: RbacParameters ) {} diff --git a/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/parameters/RbacParameters.scala b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/parameters/RbacParameters.scala new file mode 100644 index 00000000..e2d72f4d --- /dev/null +++ b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/parameters/RbacParameters.scala @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.polaris.benchmarks.parameters + +/** + * Case class to hold the RBAC parameters for the benchmark. + * + * @param enableRbac Whether to enable RBAC in the CreateTreeDataset simulation. + * @param numPrincipals The number of principals to create. + * @param catalogRoleNames The names of the catalog roles to create per catalog. These are the roles + * that hold privileges on catalog objects. Note: "catalog_administrator" is created implicitly + * for each catalog and should not be included in this list. + * @param principalRoleNames The names of the principal roles to create. These are the roles that + * can be assigned to principals and mapped to catalog roles. Note: "service_administrator" is + * created implicitly and mapped to "catalog_administrator" for all catalogs. + */ +case class RbacParameters( + enableRbac: Boolean, + numPrincipals: Int, + catalogRoleNames: Seq[String], + principalRoleNames: Seq[String] +) { + require(numPrincipals > 0, "Number of principals must be positive") + require(catalogRoleNames.nonEmpty, "At least one catalog role name must be provided") + require(principalRoleNames.nonEmpty, "At least one principal role name must be provided") + require( + catalogRoleNames.size == principalRoleNames.size, + "Number of catalog roles must match number of principal roles" + ) +} diff --git a/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/simulations/CreateTreeDataset.scala b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/simulations/CreateTreeDataset.scala index 00fb5daf..52a4d238 100644 --- a/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/simulations/CreateTreeDataset.scala +++ b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/simulations/CreateTreeDataset.scala @@ -27,6 +27,7 @@ import org.apache.polaris.benchmarks.parameters.{ AuthParameters, ConnectionParameters, DatasetParameters, + RbacParameters, WorkloadParameters } import org.slf4j.LoggerFactory @@ -36,7 +37,8 @@ import scala.concurrent.duration._ /** * This simulation is a 100% write workload that creates a tree dataset in Polaris. It is intended - * to be used against an empty Polaris instance. + * to be used against an empty Polaris instance. When RBAC is enabled, it also creates principals, + * roles, and grants alongside the dataset. */ class CreateTreeDataset extends Simulation { private val logger = LoggerFactory.getLogger(getClass) @@ -48,6 +50,7 @@ class CreateTreeDataset extends Simulation { val ap: AuthParameters = config.authParameters val dp: DatasetParameters = config.datasetParameters val wp: WorkloadParameters = config.workloadParameters + val rp: RbacParameters = config.rbacParameters // -------------------------------------------------------------------------------- // Helper values @@ -58,17 +61,75 @@ class CreateTreeDataset extends Simulation { private val namespaceActions = NamespaceActions(dp, wp, setupActions.accessToken, 5, Set(500)) private val tableActions = TableActions(dp, wp, setupActions.accessToken, 5, Set(500)) private val viewActions = ViewActions(dp, wp, setupActions.accessToken, 5, Set(500)) + private val rbacActions = RbacActions( + dp, + rp, + namespaceActions, + tableActions, + viewActions, + setupActions.accessToken, + 5, + Set(500) + ) + private val createdPrincipals = new AtomicInteger() + private val createdPrincipalRoles = new AtomicInteger() + private val assignedPrincipalRoles = new AtomicInteger() private val createdCatalogs = new AtomicInteger() + private val createdCatalogRoles = new AtomicInteger() + private val assignedCatalogRoles = new AtomicInteger() private val createdNamespaces = new AtomicInteger() private val createdTables = new AtomicInteger() private val createdViews = new AtomicInteger() + // -------------------------------------------------------------------------------- + // Workload: Create principals + // -------------------------------------------------------------------------------- + val createPrincipals: ScenarioBuilder = + scenario("Create principals") + .exec(setupActions.restoreAccessTokenInSession) + .asLongAs(session => + createdPrincipals.getAndIncrement() < rbacActions.numPrincipals && session + .contains("accessToken") + )( + feed(rbacActions.principalFeeder()) + .exec(rbacActions.createPrincipal) + ) + + // -------------------------------------------------------------------------------- + // Workload: Create principal roles + // -------------------------------------------------------------------------------- + val createPrincipalRoles: ScenarioBuilder = + scenario("Create principal roles") + .exec(setupActions.restoreAccessTokenInSession) + .asLongAs(session => + createdPrincipalRoles.getAndIncrement() < rbacActions.numPrincipalRoles && + session.contains("accessToken") + )( + feed(rbacActions.principalRoleFeeder()) + .exec(rbacActions.createPrincipalRole) + ) + + // -------------------------------------------------------------------------------- + // Workload: Assign principal roles to principals + // -------------------------------------------------------------------------------- + val assignPrincipalRoles: ScenarioBuilder = + scenario("Assign principal roles to principals") + .exec(setupActions.restoreAccessTokenInSession) + .asLongAs(session => + assignedPrincipalRoles + .getAndIncrement() < rbacActions.numPrincipalRoleAssignments && session + .contains("accessToken") + )( + feed(rbacActions.principalRoleAssignmentFeeder()) + .exec(rbacActions.assignPrincipalRoleToPrincipal) + ) + // -------------------------------------------------------------------------------- // Workload: Create catalogs // -------------------------------------------------------------------------------- val createCatalogs: ScenarioBuilder = - scenario("Create catalogs using the Polaris Management REST API") + scenario("Create catalogs") .exec(setupActions.restoreAccessTokenInSession) .asLongAs(session => createdCatalogs.getAndIncrement() < dp.numCatalogs && session.contains("accessToken") @@ -77,6 +138,34 @@ class CreateTreeDataset extends Simulation { .exec(catalogActions.createCatalog) ) + // -------------------------------------------------------------------------------- + // Workload: Create catalog roles + // -------------------------------------------------------------------------------- + val createCatalogRoles: ScenarioBuilder = + scenario("Create catalog roles") + .exec(setupActions.restoreAccessTokenInSession) + .asLongAs(session => + createdCatalogRoles.getAndIncrement() < rbacActions.numCatalogRoles && + session.contains("accessToken") + )( + feed(rbacActions.catalogRoleFeeder()) + .exec(rbacActions.createCatalogRole) + ) + + // -------------------------------------------------------------------------------- + // Workload: Assign catalog roles to principal roles + // -------------------------------------------------------------------------------- + val assignCatalogRoles: ScenarioBuilder = + scenario("Assign catalog roles to principal roles") + .exec(setupActions.restoreAccessTokenInSession) + .asLongAs(session => + assignedCatalogRoles.getAndIncrement() < rbacActions.numCatalogRoleAssignments && + session.contains("accessToken") + )( + feed(rbacActions.catalogRoleAssignmentFeeder()) + .exec(rbacActions.assignCatalogRoleToPrincipalRole) + ) + // -------------------------------------------------------------------------------- // Workload: Create namespaces // -------------------------------------------------------------------------------- @@ -87,6 +176,10 @@ class CreateTreeDataset extends Simulation { )( feed(namespaceActions.namespaceCreationFeeder()) .exec(namespaceActions.createNamespace) + .repeat(rbacActions.numGrantsPerNamespace) { + feed(rbacActions.namespaceGrantFeeder()) + .exec(rbacActions.grantNamespacePrivilege) + } ) // -------------------------------------------------------------------------------- @@ -99,6 +192,10 @@ class CreateTreeDataset extends Simulation { )( feed(tableActions.tableCreationFeeder()) .exec(tableActions.createTable) + .repeat(rbacActions.numGrantsPerTable) { + feed(rbacActions.tableGrantFeeder()) + .exec(rbacActions.grantTablePrivilege) + } ) // -------------------------------------------------------------------------------- @@ -111,6 +208,10 @@ class CreateTreeDataset extends Simulation { )( feed(viewActions.viewCreationFeeder()) .exec(viewActions.createView) + .repeat(rbacActions.numGrantsPerView) { + feed(rbacActions.viewGrantFeeder()) + .exec(rbacActions.grantViewPrivilege) + } ) // -------------------------------------------------------------------------------- @@ -126,11 +227,42 @@ class CreateTreeDataset extends Simulation { private val tableThroughput = wp.createTreeDataset.tableThroughput private val viewThroughput = wp.createTreeDataset.viewThroughput + // Build assertions separately based on whether RBAC is enabled + // This is necessary because an assertion requires at least one query to be executed, and that may not be the case if rbac is disabled. + private val baseAssertions = Seq( + details("Create Namespace").successfulRequests.count.is(numNamespaces), + details("Create Table").successfulRequests.count.is(dp.numTables), + details("Create View").successfulRequests.count.is(dp.numViews) + ) + + private val rbacAssertions = if (rp.enableRbac) { + Seq( + details("Create Principal").successfulRequests.count.is(rp.numPrincipals), + details("Create Principal Role").successfulRequests.count.is(rbacActions.numPrincipalRoles), + details("Assign Principal Role to Principal").successfulRequests.count + .is(rbacActions.numPrincipalRoleAssignments), + details("Create Catalog Role").successfulRequests.count.is(rbacActions.numCatalogRoles), + details("Assign Catalog Role to Principal Role").successfulRequests.count + .is(rbacActions.numCatalogRoleAssignments), + details("Grant Namespace Privilege").successfulRequests.count + .is(rbacActions.numNamespaceGrants), + details("Grant Table Privilege").successfulRequests.count.is(rbacActions.numTableGrants), + details("Grant View Privilege").successfulRequests.count.is(rbacActions.numViewGrants) + ) + } else { + Seq.empty + } + setUp( setupActions.continuouslyRefreshOauthToken().inject(atOnceUsers(1)).protocols(httpProtocol), setupActions.waitForAuthentication .inject(atOnceUsers(1)) + .andThen(createPrincipals.inject(atOnceUsers(1)).protocols(httpProtocol)) + .andThen(createPrincipalRoles.inject(atOnceUsers(1)).protocols(httpProtocol)) + .andThen(assignPrincipalRoles.inject(atOnceUsers(1)).protocols(httpProtocol)) .andThen(createCatalogs.inject(atOnceUsers(1)).protocols(httpProtocol)) + .andThen(createCatalogRoles.inject(atOnceUsers(1)).protocols(httpProtocol)) + .andThen(assignCatalogRoles.inject(atOnceUsers(1)).protocols(httpProtocol)) .andThen( createNamespaces .inject( @@ -142,9 +274,5 @@ class CreateTreeDataset extends Simulation { .andThen(createTables.inject(atOnceUsers(tableThroughput)).protocols(httpProtocol)) .andThen(createViews.inject(atOnceUsers(viewThroughput)).protocols(httpProtocol)) .andThen(setupActions.stopRefreshingToken.inject(atOnceUsers(1)).protocols(httpProtocol)) - ).assertions( - details("Create Namespace").successfulRequests.count.is(numNamespaces), - details("Create Table").successfulRequests.count.is(dp.numTables), - details("Create View").successfulRequests.count.is(dp.numViews) - ) + ).assertions(baseAssertions ++ rbacAssertions) } diff --git a/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/util/Mangler.scala b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/util/Mangler.scala index 8590d875..9b0211db 100644 --- a/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/util/Mangler.scala +++ b/benchmarks/src/gatling/scala/org/apache/polaris/benchmarks/util/Mangler.scala @@ -23,6 +23,13 @@ import java.security.MessageDigest case class Mangler(enabled: Boolean) { val MD5: MessageDigest = MessageDigest.getInstance("MD5") + def maybeMangle(s: String): String = + if (enabled) { + MD5.digest(s.getBytes()).map("%02x".format(_)).mkString + } else { + s + } + def maybeMangle(prefix: String, ordinal: Int): String = if (enabled) { MD5.digest((prefix + ordinal).getBytes()).map("%02x".format(_)).mkString @@ -33,4 +40,9 @@ case class Mangler(enabled: Boolean) { def maybeMangleNs(ordinal: Int): String = maybeMangle("NS_", ordinal) def maybeMangleTable(ordinal: Int): String = maybeMangle("T_", ordinal) def maybeMangleView(ordinal: Int): String = maybeMangle("V_", ordinal) + def maybeManglePrincipal(ordinal: Int): String = maybeMangle("P_", ordinal) + def maybeManglePrincipalRole(name: String): String = maybeMangle(name) + def maybeMangleCatalogRole(prefix: String, catalogName: String): String = maybeMangle( + s"${prefix}_${catalogName}" + ) }