From 3ef4e183bbfccf1a262916a687bf1df06f8ff986 Mon Sep 17 00:00:00 2001
From: morningman
Date: Wed, 12 Aug 2026 00:35:21 +0800
Subject: [PATCH 01/22] [feat](authorization) add fe-authorization-api with
neutral data-policy payloads
First artifact of the FE authorization plugin work: a dependency-free API
module that will hold the types an authorization plugin exchanges with the
engine, starting with the two that have a consumer in this series - the row
filter and column mask payloads.
Both carry a SQL expression in Doris dialect rather than a parsed expression
tree. That is the lossless form for every producer that exists: an internal
row policy comes from CREATE ROW POLICY text, a Ranger filter is text a human
typed into the Ranger UI, and a Ranger mask is a transformer template from the
service definition - which is always a function call, a shape the neutral
connector expression layer's reverse converter does not accept at all. Trino
splits the same way: structured expressions engine-to-plugin (pushdown), SQL
text plugin-to-engine (ViewExpression, masks).
Both types are immutable values with real equality, which is load bearing
rather than cosmetic: the SQL result cache decides "did the policies change?"
by comparing the specs recorded at plan time with the specs evaluated now, so
identity equality there evicts the cache on every lookup. The tests assert
both directions - equal while the policy is untouched, unequal the moment its
version or text changes.
Verified: mvn -pl fe-authorization/fe-authorization-api -am test (7/7, build
cache disabled) and checkstyle:check, both green.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../fe-authorization-api/pom.xml | 57 +++++++++
.../doris/authorization/DataMaskSpec.java | 86 ++++++++++++++
.../authorization/RowFilterMergeType.java | 32 ++++++
.../doris/authorization/RowFilterSpec.java | 108 ++++++++++++++++++
.../doris/authorization/DataMaskSpecTest.java | 57 +++++++++
.../authorization/RowFilterSpecTest.java | 74 ++++++++++++
fe/fe-authorization/pom.xml | 41 +++++++
fe/fe-core/pom.xml | 5 +
fe/pom.xml | 1 +
9 files changed, 461 insertions(+)
create mode 100644 fe/fe-authorization/fe-authorization-api/pom.xml
create mode 100644 fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/DataMaskSpec.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/RowFilterMergeType.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/RowFilterSpec.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/DataMaskSpecTest.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/RowFilterSpecTest.java
create mode 100644 fe/fe-authorization/pom.xml
diff --git a/fe/fe-authorization/fe-authorization-api/pom.xml b/fe/fe-authorization/fe-authorization-api/pom.xml
new file mode 100644
index 00000000000000..160c4da96bdd07
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/pom.xml
@@ -0,0 +1,57 @@
+
+
+
+ 4.0.0
+
+ org.apache.doris
+ ${revision}
+ fe-authorization
+ ../pom.xml
+
+ fe-authorization-api
+ jar
+ Doris FE Authorization API
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*Test.java
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 17
+ 17
+
+
+
+
+
diff --git a/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/DataMaskSpec.java b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/DataMaskSpec.java
new file mode 100644
index 00000000000000..80fb7d500c71d8
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/DataMaskSpec.java
@@ -0,0 +1,86 @@
+// 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.doris.authorization;
+
+import java.util.Objects;
+
+/**
+ * How one column must be rewritten before a subject may read it.
+ *
+ *
The payload is a scalar SQL expression in Doris dialect that replaces the column in the projection
+ * ({@code CONCAT('XXXX', SUBSTR(phone, -4))}, {@code NULL}). Sources hold this as text natively - Ranger, for
+ * one, stores a transformer template in its service definition and substitutes the column name into it - so
+ * text is the lossless form; see {@link RowFilterSpec} for the full rationale.
+ *
+ *
Like {@link RowFilterSpec} this is an immutable value with real equality, because the SQL result cache
+ * uses spec equality to decide whether a column's masking changed.
+ */
+public final class DataMaskSpec {
+
+ private final String policyIdent;
+ private final String maskSql;
+
+ /**
+ * @param policyIdent identifies the policy that produced this mask, for auditing and for change
+ * detection; it must change when the policy changes (e.g. {@code ":"})
+ * @param maskSql a scalar SQL expression in Doris dialect yielding the value the subject may see
+ */
+ public DataMaskSpec(String policyIdent, String maskSql) {
+ this.policyIdent = requireNonBlank(policyIdent, "policyIdent");
+ this.maskSql = requireNonBlank(maskSql, "maskSql");
+ }
+
+ public String getPolicyIdent() {
+ return policyIdent;
+ }
+
+ public String getMaskSql() {
+ return maskSql;
+ }
+
+ private static String requireNonBlank(String value, String field) {
+ Objects.requireNonNull(value, field + " is required");
+ if (value.trim().isEmpty()) {
+ // A blank mask would parse to nothing and leave the raw column exposed. Fail at the source.
+ throw new IllegalArgumentException(field + " must not be blank");
+ }
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof DataMaskSpec)) {
+ return false;
+ }
+ DataMaskSpec that = (DataMaskSpec) o;
+ return policyIdent.equals(that.policyIdent) && maskSql.equals(that.maskSql);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(policyIdent, maskSql);
+ }
+
+ @Override
+ public String toString() {
+ return "DataMaskSpec{policyIdent='" + policyIdent + "', maskSql='" + maskSql + "'}";
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/RowFilterMergeType.java b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/RowFilterMergeType.java
new file mode 100644
index 00000000000000..43de03fd6019b7
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/RowFilterMergeType.java
@@ -0,0 +1,32 @@
+// 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.doris.authorization;
+
+/**
+ * How the engine combines several {@link RowFilterSpec}s that apply to the same table for the same subject.
+ *
+ *
The engine owns the combination semantics (an authorization source never gets to define how its filters
+ * merge with another's): restrictive filters are ANDed, permissive filters are ORed, and when both kinds are
+ * present the result is {@code AND(restrictive...) AND OR(permissive...)}.
+ */
+public enum RowFilterMergeType {
+ /** The subject sees only rows matching this filter - ANDed with every other restrictive filter. */
+ RESTRICTIVE,
+ /** The subject additionally sees rows matching this filter - ORed with every other permissive filter. */
+ PERMISSIVE
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/RowFilterSpec.java b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/RowFilterSpec.java
new file mode 100644
index 00000000000000..40d702348de9a4
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/RowFilterSpec.java
@@ -0,0 +1,108 @@
+// 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.doris.authorization;
+
+import java.util.Objects;
+
+/**
+ * One row-level security filter an authorization source applies to a table for one subject.
+ *
+ *
The payload is a SQL boolean expression in Doris dialect ({@code region = 'cn' AND dept IN
+ * ('a','b')}), not a parsed expression tree. The engine parses it, checks that it is boolean, and injects it
+ * as a filter above the scan. This is deliberate and mirrors Trino's {@code ViewExpression}: a row filter
+ * originates as SQL text authored by a human in an external system (a Ranger policy, a {@code CREATE ROW
+ * POLICY} statement), so text is its lossless native form. A structured tree would force every source to
+ * embed a SQL parser, and cannot express what real policies contain - function calls above all.
+ *
+ *
Value semantics are load bearing, not cosmetic: the SQL result cache decides "did the policies change?"
+ * by comparing the specs recorded at plan time against the specs evaluated now. Identity equality there means
+ * a freshly built - but identical - spec reads as a policy change and evicts the cache on every single
+ * lookup. Hence this type is an immutable value with {@link #equals}/{@link #hashCode}, and every producer
+ * must fold whatever version token it has into {@link #getPolicyIdent()} so that a genuinely updated policy
+ * does compare unequal.
+ */
+public final class RowFilterSpec {
+
+ private final String policyIdent;
+ private final String filterSql;
+ private final RowFilterMergeType mergeType;
+
+ /**
+ * @param policyIdent identifies the policy that produced this filter, for auditing and for change
+ * detection; it must change when the policy changes (e.g. {@code ":"})
+ * @param filterSql a boolean SQL expression in Doris dialect over the table's columns
+ * @param mergeType how this filter combines with the table's other filters
+ */
+ public RowFilterSpec(String policyIdent, String filterSql, RowFilterMergeType mergeType) {
+ this.policyIdent = requireNonBlank(policyIdent, "policyIdent");
+ this.filterSql = requireNonBlank(filterSql, "filterSql");
+ this.mergeType = Objects.requireNonNull(mergeType, "mergeType is required");
+ }
+
+ /** Creates a restrictive (ANDed) filter, the default for an authorization source. */
+ public static RowFilterSpec restrictive(String policyIdent, String filterSql) {
+ return new RowFilterSpec(policyIdent, filterSql, RowFilterMergeType.RESTRICTIVE);
+ }
+
+ public String getPolicyIdent() {
+ return policyIdent;
+ }
+
+ public String getFilterSql() {
+ return filterSql;
+ }
+
+ public RowFilterMergeType getMergeType() {
+ return mergeType;
+ }
+
+ private static String requireNonBlank(String value, String field) {
+ Objects.requireNonNull(value, field + " is required");
+ if (value.trim().isEmpty()) {
+ // A blank filter would silently degrade to "no restriction" once parsed, i.e. it would widen
+ // access. Reject at construction so a broken policy fails loudly at its source.
+ throw new IllegalArgumentException(field + " must not be blank");
+ }
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof RowFilterSpec)) {
+ return false;
+ }
+ RowFilterSpec that = (RowFilterSpec) o;
+ return policyIdent.equals(that.policyIdent)
+ && filterSql.equals(that.filterSql)
+ && mergeType == that.mergeType;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(policyIdent, filterSql, mergeType);
+ }
+
+ @Override
+ public String toString() {
+ return "RowFilterSpec{policyIdent='" + policyIdent + "', filterSql='" + filterSql
+ + "', mergeType=" + mergeType + '}';
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/DataMaskSpecTest.java b/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/DataMaskSpecTest.java
new file mode 100644
index 00000000000000..eab9896e8042e9
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/DataMaskSpecTest.java
@@ -0,0 +1,57 @@
+// 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.doris.authorization;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Same contract as {@link RowFilterSpecTest}, for column masking: the SQL result cache compares the mask
+ * recorded at plan time with the mask evaluated now, so equality must track the policy exactly - equal while
+ * the policy is untouched, unequal the moment it is edited.
+ */
+public class DataMaskSpecTest {
+
+ @Test
+ public void testSpecsWithSameContentAreEqual() {
+ DataMaskSpec planned = new DataMaskSpec("7:3", "CONCAT('XXXX', SUBSTR(phone, -4))");
+ DataMaskSpec reevaluated = new DataMaskSpec("7:3", "CONCAT('XXXX', SUBSTR(phone, -4))");
+
+ Assertions.assertEquals(planned, reevaluated,
+ "an unchanged mask policy must re-evaluate to an equal spec, or the cache is evicted every lookup");
+ Assertions.assertEquals(planned.hashCode(), reevaluated.hashCode());
+ }
+
+ @Test
+ public void testUpdatedPolicyIsNotEqual() {
+ DataMaskSpec planned = new DataMaskSpec("7:3", "NULL");
+
+ Assertions.assertNotEquals(planned, new DataMaskSpec("7:4", "NULL"),
+ "a new policy version must compare unequal so the cache invalidates");
+ Assertions.assertNotEquals(planned, new DataMaskSpec("7:3", "phone"),
+ "unmasking a column must compare unequal so the cache invalidates");
+ }
+
+ @Test
+ public void testBlankMaskIsRejected() {
+ // A blank mask expression would leave the raw column in the projection - i.e. no masking at all.
+ Assertions.assertThrows(IllegalArgumentException.class, () -> new DataMaskSpec("7:3", " "));
+ Assertions.assertThrows(NullPointerException.class, () -> new DataMaskSpec("7:3", null));
+ Assertions.assertThrows(NullPointerException.class, () -> new DataMaskSpec(null, "NULL"));
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/RowFilterSpecTest.java b/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/RowFilterSpecTest.java
new file mode 100644
index 00000000000000..18af56bb527090
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/RowFilterSpecTest.java
@@ -0,0 +1,74 @@
+// 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.doris.authorization;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The equality contract of {@link RowFilterSpec} is what the SQL result cache uses to answer "did this
+ * table's row policies change since we planned this query?". Both directions of that answer are a real
+ * defect when wrong, so both are asserted here: equal-when-unchanged (else every lookup evicts the cache and
+ * a Ranger user never gets a cache hit) and unequal-when-updated (else an edited policy keeps serving rows
+ * the subject may no longer see).
+ */
+public class RowFilterSpecTest {
+
+ @Test
+ public void testSpecsWithSameContentAreEqual() {
+ RowFilterSpec planned = new RowFilterSpec("7:3", "region = 'cn'", RowFilterMergeType.RESTRICTIVE);
+ RowFilterSpec reevaluated = new RowFilterSpec("7:3", "region = 'cn'", RowFilterMergeType.RESTRICTIVE);
+
+ Assertions.assertEquals(planned, reevaluated,
+ "an unchanged policy must re-evaluate to an equal spec, or the cache is evicted every lookup");
+ Assertions.assertEquals(planned.hashCode(), reevaluated.hashCode());
+ }
+
+ @Test
+ public void testUpdatedPolicyIsNotEqual() {
+ RowFilterSpec planned = new RowFilterSpec("7:3", "region = 'cn'", RowFilterMergeType.RESTRICTIVE);
+
+ Assertions.assertNotEquals(planned,
+ new RowFilterSpec("7:4", "region = 'cn'", RowFilterMergeType.RESTRICTIVE),
+ "a new policy version must compare unequal so the cache invalidates");
+ Assertions.assertNotEquals(planned,
+ new RowFilterSpec("7:3", "region = 'us'", RowFilterMergeType.RESTRICTIVE),
+ "a rewritten predicate must compare unequal so the cache invalidates");
+ Assertions.assertNotEquals(planned,
+ new RowFilterSpec("7:3", "region = 'cn'", RowFilterMergeType.PERMISSIVE),
+ "flipping restrictive to permissive changes which rows are visible");
+ }
+
+ @Test
+ public void testRestrictiveIsTheDefaultShorthand() {
+ Assertions.assertEquals(RowFilterMergeType.RESTRICTIVE,
+ RowFilterSpec.restrictive("7:3", "region = 'cn'").getMergeType());
+ }
+
+ @Test
+ public void testBlankFilterIsRejected() {
+ // A blank predicate parses to nothing, which would widen access to the whole table instead of
+ // restricting it - the failure mode that must never be silent.
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> RowFilterSpec.restrictive("7:3", " "));
+ Assertions.assertThrows(NullPointerException.class,
+ () -> RowFilterSpec.restrictive("7:3", null));
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> RowFilterSpec.restrictive("", "region = 'cn'"));
+ }
+}
diff --git a/fe/fe-authorization/pom.xml b/fe/fe-authorization/pom.xml
new file mode 100644
index 00000000000000..138ee4ba32aed5
--- /dev/null
+++ b/fe/fe-authorization/pom.xml
@@ -0,0 +1,41 @@
+
+
+
+ 4.0.0
+
+ org.apache.doris
+ ${revision}
+ fe
+ ../pom.xml
+
+ fe-authorization
+ pom
+ Doris FE Authorization
+
+
+ fe-authorization-api
+
+
diff --git a/fe/fe-core/pom.xml b/fe/fe-core/pom.xml
index 96d3d9ec22bcea..e7825f36633a50 100644
--- a/fe/fe-core/pom.xml
+++ b/fe/fe-core/pom.xml
@@ -390,6 +390,11 @@ under the License.
fe-authentication-role-mapping${project.version}
+
+ ${project.groupId}
+ fe-authorization-api
+ ${project.version}
+ org.springframework.bootspring-boot-devtools
diff --git a/fe/pom.xml b/fe/pom.xml
index fa1ef0a650a524..60371c37bc9d08 100644
--- a/fe/pom.xml
+++ b/fe/pom.xml
@@ -235,6 +235,7 @@ under the License.
hive-udfbe-java-extensionsfe-authentication
+ fe-authorizationfe-thriftfe-typefe-grpc
From a4ec9668b3c5553d4f83fe133eafbdfedb7c2998 Mon Sep 17 00:00:00 2001
From: morningman
Date: Wed, 12 Aug 2026 00:48:13 +0800
Subject: [PATCH 02/22] [test](authorization) reproduce the SQL cache never
hitting under Ranger data policies
Pins the contract NereidsSqlCacheManager relies on before serving a cached
result: it re-evaluates a query's row-filter and data-mask policies and
compares them by value with what was recorded at plan time, so an untouched
policy must re-evaluate to an equal object.
The Ranger policy types do not hold up their end - neither implements equals,
and RangerAccessController builds a fresh instance on every evaluation - so
the comparison degrades to identity and always reports a change. A user with
any row filter or masked column configured therefore never gets a SQL cache
hit; the entry is evicted on every single lookup. Confirmed here rather than
inferred: the two "untouched policy" cases fail and the five change-detection
cases pass, which is the exact signature of missing value equality.
This commit is deliberately RED - the payload rework in the next commit turns
it green. The change-detection cases are pinned alongside so that fix cannot
buy cache hits by weakening the comparison: an edited or revoked policy must
still invalidate, or the cache keeps serving rows the user may no longer see.
Verified: mvn -pl fe-core -am test -Dtest=NereidsSqlCacheDataPolicyTest
(build cache disabled) - 7 run, 2 failures, both the untouched-policy cases.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../cache/NereidsSqlCacheDataPolicyTest.java | 188 ++++++++++++++++++
1 file changed, 188 insertions(+)
create mode 100644 fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheDataPolicyTest.java
diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheDataPolicyTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheDataPolicyTest.java
new file mode 100644
index 00000000000000..3aba8399659211
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheDataPolicyTest.java
@@ -0,0 +1,188 @@
+// 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.doris.common.cache;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.mysql.privilege.AccessControllerManager;
+import org.apache.doris.mysql.privilege.DataMaskPolicy;
+import org.apache.doris.mysql.privilege.RangerDataMaskPolicy;
+import org.apache.doris.mysql.privilege.RangerRowFilterPolicy;
+import org.apache.doris.mysql.privilege.RowFilterPolicy;
+import org.apache.doris.nereids.SqlCacheContext;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mockito;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Before serving a cached result, {@link NereidsSqlCacheManager} re-evaluates the row-filter and data-mask
+ * policies of every table the query touched and compares them with what was recorded at plan time. The
+ * comparison is by value ({@code isEqualCollection} / {@code Objects.equals}), so it only answers the
+ * intended question - "has the administrator changed a policy?" - if an unchanged policy re-evaluates to an
+ * equal object.
+ *
+ *
An authorization source builds its policy objects fresh on every evaluation (it is answering a request,
+ * not handing out a cached object), so value equality is the source's obligation, and the "unchanged" tests
+ * below are what enforce it. They are RED for the Ranger policy types, which have no equals: the comparison
+ * degrades to identity, every lookup reads as a policy change, and a user with any row filter or column mask
+ * configured never gets a cache hit at all.
+ *
+ *
The "changed" tests pin the other half of the contract, so a fix cannot buy cache hits by weakening the
+ * comparison: an edited policy must still invalidate, or the cache keeps serving rows the user may no longer
+ * see.
+ */
+public class NereidsSqlCacheDataPolicyTest {
+
+ private static final String CTL = "internal";
+ private static final String DB = "test_db";
+ private static final String TBL = "orders";
+ private static final String COL = "phone";
+ private static final UserIdentity USER = UserIdentity.ROOT;
+
+ private RowFilterPolicy rowFilter(long policyVersion, String filterExpr) {
+ return new RangerRowFilterPolicy(USER, CTL, DB, TBL, 7L, policyVersion, filterExpr);
+ }
+
+ private DataMaskPolicy dataMask(long policyVersion, String maskTypeDef) {
+ return new RangerDataMaskPolicy(USER, CTL, DB, TBL, COL, 7L, policyVersion, "CUSTOM", maskTypeDef);
+ }
+
+ /**
+ * Wires an {@link Env} whose access manager re-evaluates to the supplied policies. The supplier is
+ * invoked per call so every evaluation yields a distinct object, exactly as a live authorization source
+ * behaves.
+ */
+ private Env mockEnvEvaluating(java.util.function.Supplier rowFilterSupplier,
+ java.util.function.Supplier> dataMaskSupplier) {
+ AccessControllerManager accessManager = Mockito.mock(AccessControllerManager.class);
+ Mockito.doAnswer(invocation -> {
+ RowFilterPolicy evaluated = rowFilterSupplier.get();
+ return evaluated == null ? ImmutableList.of() : ImmutableList.of(evaluated);
+ })
+ .when(accessManager).evalRowFilterPolicies(ArgumentMatchers.any(UserIdentity.class),
+ ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString());
+ Mockito.doAnswer(invocation -> dataMaskSupplier.get())
+ .when(accessManager).evalDataMaskPolicy(ArgumentMatchers.any(UserIdentity.class),
+ ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), ArgumentMatchers.anyString(),
+ ArgumentMatchers.anyString());
+
+ Env env = Mockito.mock(Env.class);
+ Mockito.when(env.getAccessManager()).thenReturn(accessManager);
+ return env;
+ }
+
+ private boolean rowPoliciesChanged(Env env, SqlCacheContext context) {
+ return Deencapsulation.invoke(new NereidsSqlCacheManager(), "rowPoliciesChanged", USER, env, context);
+ }
+
+ private boolean dataMaskPoliciesChanged(Env env, SqlCacheContext context) {
+ return Deencapsulation.invoke(new NereidsSqlCacheManager(), "dataMaskPoliciesChanged", USER, env, context);
+ }
+
+ private SqlCacheContext contextWithRowFilter(RowFilterPolicy policy) {
+ SqlCacheContext context = new SqlCacheContext(USER);
+ List policies = policy == null ? ImmutableList.of() : ImmutableList.of(policy);
+ context.setRowFilterPolicy(CTL, DB, TBL, policies);
+ return context;
+ }
+
+ private SqlCacheContext contextWithDataMask(Optional policy) {
+ SqlCacheContext context = new SqlCacheContext(USER);
+ context.addDataMaskPolicy(CTL, DB, TBL, COL, policy);
+ return context;
+ }
+
+ /** The administrator touched nothing: the same filter must re-evaluate equal and keep the cache. */
+ @Test
+ public void testUntouchedRowFilterKeepsCache() {
+ SqlCacheContext context = contextWithRowFilter(rowFilter(3L, "region = 'cn'"));
+ Env env = mockEnvEvaluating(() -> rowFilter(3L, "region = 'cn'"), Optional::empty);
+
+ Assertions.assertFalse(rowPoliciesChanged(env, context),
+ "an unchanged row filter must not read as a policy change, otherwise every cache lookup for a "
+ + "user with row-level security evicts the entry and the SQL cache never serves anything");
+ }
+
+ /** The administrator edited the policy: the cache must not serve rows filtered by the old predicate. */
+ @Test
+ public void testEditedRowFilterInvalidatesCache() {
+ SqlCacheContext context = contextWithRowFilter(rowFilter(3L, "region = 'cn'"));
+ Env env = mockEnvEvaluating(() -> rowFilter(4L, "region IN ('cn', 'us')"), Optional::empty);
+
+ Assertions.assertTrue(rowPoliciesChanged(env, context),
+ "an edited row filter must invalidate the cache");
+ }
+
+ /** A revoked policy widens nothing but still changes the result set, so it must invalidate too. */
+ @Test
+ public void testRemovedRowFilterInvalidatesCache() {
+ SqlCacheContext context = contextWithRowFilter(rowFilter(3L, "region = 'cn'"));
+ Env env = mockEnvEvaluating(() -> null, Optional::empty);
+
+ Assertions.assertTrue(rowPoliciesChanged(env, context),
+ "dropping a row filter changes which rows the query returns");
+ }
+
+ /** Same contract for column masking. */
+ @Test
+ public void testUntouchedDataMaskKeepsCache() {
+ SqlCacheContext context = contextWithDataMask(Optional.of(dataMask(3L, "CONCAT('XXXX', SUBSTR(phone, -4))")));
+ Env env = mockEnvEvaluating(() -> null,
+ () -> Optional.of(dataMask(3L, "CONCAT('XXXX', SUBSTR(phone, -4))")));
+
+ Assertions.assertFalse(dataMaskPoliciesChanged(env, context),
+ "an unchanged column mask must not read as a policy change, otherwise every cache lookup for a "
+ + "user with a masked column evicts the entry");
+ }
+
+ @Test
+ public void testEditedDataMaskInvalidatesCache() {
+ SqlCacheContext context = contextWithDataMask(Optional.of(dataMask(3L, "CONCAT('XXXX', SUBSTR(phone, -4))")));
+ Env env = mockEnvEvaluating(() -> null, () -> Optional.of(dataMask(4L, "NULL")));
+
+ Assertions.assertTrue(dataMaskPoliciesChanged(env, context),
+ "a strengthened column mask must invalidate the cache");
+ }
+
+ /** Unmasking a column exposes raw values: the cached masked result is stale, and so is the reverse. */
+ @Test
+ public void testRemovedDataMaskInvalidatesCache() {
+ SqlCacheContext context = contextWithDataMask(Optional.of(dataMask(3L, "NULL")));
+ Env env = mockEnvEvaluating(() -> null, Optional::empty);
+
+ Assertions.assertTrue(dataMaskPoliciesChanged(env, context),
+ "dropping a column mask changes the values the query returns");
+ }
+
+ /** A column that never had a mask must not keep evicting the cache either. */
+ @Test
+ public void testColumnWithoutMaskKeepsCache() {
+ SqlCacheContext context = contextWithDataMask(Optional.empty());
+ Env env = mockEnvEvaluating(() -> null, Optional::empty);
+
+ Assertions.assertFalse(dataMaskPoliciesChanged(env, context),
+ "an unmasked column must not read as a policy change");
+ }
+}
From 57e2d278be63896d16ba18a9e1638de85268c104 Mon Sep 17 00:00:00 2001
From: morningman
Date: Wed, 12 Aug 2026 01:35:59 +0800
Subject: [PATCH 03/22] [improvement](authorization) hand row filters and
column masks to the planner as SQL text
The two data-policy payloads an access controller returns were fe-core types
that leaked the planner into the authorization surface: RowFilterPolicy handed
back a Nereids Expression, so any third-party controller had to compile
against fe-core and build an expression tree to express "region = 'cn'". They
are now the neutral value types from fe-authorization-api, carrying the
predicate and the mask as SQL text in Doris dialect, and the planner does the
parsing - which is what the Ranger path already did.
Fixes the SQL cache never hitting for anyone with a row filter or a masked
column: the cache asks "did the policies change?" by comparing what it
recorded at plan time with what evaluates now, the Ranger payloads had no
equals, and each evaluation built a new instance, so the answer was always
"changed". The new payloads are values with real equality, and their identity
folds in the policy version so an edited policy still compares unequal. The
repro test from the previous commit is green.
Two things this turned up, both now pinned by tests:
- The payload has to carry the merge type. The design sketch had the engine
always AND row filters, but built-in policies can be permissive, which ORs
them; without the field, two permissive policies would silently become an
AND and the user would see nothing. CheckRowPolicyTest now covers a
permissive pair and a restrictive pair - a single policy cannot tell the two
merge modes apart, which is why no existing test caught this.
- A built-in policy's predicate cannot be produced by rendering its parsed
expression: CompoundPredicate.toSql() emits the diagnostic form AND[a,b],
which does not parse back, so every policy combining two conditions would
have broken. The text is instead recovered from the CREATE statement the
policy already stores, captured at parse time - the text the administrator
actually wrote. RowPolicyFilterSqlTest runs 25 representative predicates
through create -> store -> hand to planner and asserts the predicate comes
back identical; 7 of them failed against the rendering approach.
An unparseable stored policy still fails the query with the same message as
before rather than disappearing, since a vanished row filter exposes the whole
table.
Verified: 175 tests across every suite touching the changed surface (cache,
policy, privilege, parser, Ranger), 0 failures, 0 skipped, build cache
disabled. Mutation check: forcing permissive to merge as restrictive turns
CheckRowPolicyTest red, and the assertion that fires is the new one.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../ranger/RangerAccessController.java | 34 +++---
.../hive/RangerHiveAccessController.java | 8 +-
.../common/cache/NereidsSqlCacheManager.java | 16 +--
.../privilege/AccessControllerManager.java | 10 +-
.../privilege/CatalogAccessController.java | 14 ++-
.../doris/mysql/privilege/DataMaskPolicy.java | 24 ----
.../privilege/InternalAccessController.java | 41 ++++++-
.../mysql/privilege/RangerDataMaskPolicy.java | 103 -----------------
.../privilege/RangerRowFilterPolicy.java | 95 ----------------
.../mysql/privilege/RowFilterPolicy.java | 32 ------
.../apache/doris/nereids/SqlCacheContext.java | 24 ++--
.../nereids/parser/LogicalPlanBuilder.java | 7 +-
.../plans/commands/CreatePolicyCommand.java | 9 +-
.../plans/logical/LogicalCheckPolicy.java | 27 ++---
.../org/apache/doris/policy/RowPolicy.java | 44 ++++++-
.../cache/NereidsSqlCacheDataPolicyTest.java | 33 +++---
.../CatalogAccessControllerTest.java | 6 +-
.../doris/mysql/privilege/RangerTest.java | 7 +-
.../privileges/TestCheckPrivileges.java | 57 +++-------
.../rules/analysis/CheckRowPolicyTest.java | 72 ++++++++++--
.../doris/policy/RowPolicyFilterSqlTest.java | 107 ++++++++++++++++++
21 files changed, 367 insertions(+), 403 deletions(-)
delete mode 100644 fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/DataMaskPolicy.java
delete mode 100644 fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RangerDataMaskPolicy.java
delete mode 100644 fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RangerRowFilterPolicy.java
delete mode 100644 fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RowFilterPolicy.java
create mode 100644 fe/fe-core/src/test/java/org/apache/doris/policy/RowPolicyFilterSqlTest.java
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/RangerAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/RangerAccessController.java
index 7a2779b43b1c6b..f26ea775081e10 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/RangerAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/RangerAccessController.java
@@ -18,13 +18,11 @@
package org.apache.doris.catalog.authorizer.ranger;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.catalog.authorizer.ranger.doris.DorisAccessType;
import org.apache.doris.common.AuthorizationException;
import org.apache.doris.mysql.privilege.CatalogAccessController;
-import org.apache.doris.mysql.privilege.DataMaskPolicy;
-import org.apache.doris.mysql.privilege.RangerDataMaskPolicy;
-import org.apache.doris.mysql.privilege.RangerRowFilterPolicy;
-import org.apache.doris.mysql.privilege.RowFilterPolicy;
import com.google.common.collect.Lists;
import org.apache.commons.lang3.StringUtils;
@@ -88,8 +86,17 @@ public static void checkRequestResults(Collection results, S
}
}
+ /**
+ * Identifies a Ranger policy for auditing and for the SQL cache's change detection. The version is part of
+ * it on purpose: an administrator editing a policy in place keeps its id, and without the version an
+ * updated policy would compare equal to the one the cached result was planned with.
+ */
+ private static String policyIdent(RangerAccessResult policy) {
+ return policy.getPolicyId() + ":" + policy.getPolicyVersion();
+ }
+
@Override
- public List extends RowFilterPolicy> evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db,
+ public List evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db,
String tbl) {
RangerAccessResourceImpl resource = createResource(ctl, db, tbl);
RangerAccessRequestImpl request = createRequest(currentUser);
@@ -103,7 +110,7 @@ public List extends RowFilterPolicy> evalRowFilterPolicies(UserIdentity curren
if (LOG.isDebugEnabled()) {
LOG.debug("ranger request: {}", request);
}
- List res = Lists.newArrayList();
+ List res = Lists.newArrayList();
RangerAccessResult policy = getPlugin().evalRowFilterPolicies(request, getAccessResultProcessor());
if (LOG.isDebugEnabled()) {
LOG.debug("ranger response: {}", policy);
@@ -115,13 +122,13 @@ public List extends RowFilterPolicy> evalRowFilterPolicies(UserIdentity curren
if (StringUtils.isEmpty(filterExpr)) {
return res;
}
- res.add(new RangerRowFilterPolicy(currentUser, ctl, db, tbl, policy.getPolicyId(), policy.getPolicyVersion(),
- filterExpr));
+ // Ranger row filters are always restrictive: it returns at most one expression and the row must match it.
+ res.add(RowFilterSpec.restrictive(policyIdent(policy), filterExpr));
return res;
}
@Override
- public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
+ public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
String col) {
RangerAccessResourceImpl resource = createResource(ctl, db, tbl, col);
RangerAccessRequestImpl request = createRequest(currentUser);
@@ -144,8 +151,7 @@ public Optional evalDataMaskPolicy(UserIdentity currentUser, Str
}
switch (maskType) {
case "MASK_NULL":
- return Optional.of(new RangerDataMaskPolicy(currentUser, ctl, db, tbl, col, policy.getPolicyId(),
- policy.getPolicyVersion(), maskType, "NULL"));
+ return Optional.of(new DataMaskSpec(policyIdent(policy), "NULL"));
case "MASK_NONE":
return Optional.empty();
case "CUSTOM":
@@ -153,15 +159,13 @@ public Optional evalDataMaskPolicy(UserIdentity currentUser, Str
if (StringUtils.isEmpty(maskedValue)) {
return Optional.empty();
}
- return Optional.of(new RangerDataMaskPolicy(currentUser, ctl, db, tbl, col, policy.getPolicyId(),
- policy.getPolicyVersion(), maskType, maskedValue.replace("{col}", col)));
+ return Optional.of(new DataMaskSpec(policyIdent(policy), maskedValue.replace("{col}", col)));
default:
String transformer = policy.getMaskTypeDef().getTransformer();
if (StringUtils.isEmpty(transformer)) {
return Optional.empty();
}
- return Optional.of(new RangerDataMaskPolicy(currentUser, ctl, db, tbl, col, policy.getPolicyId(),
- policy.getPolicyVersion(), maskType, transformer.replace("{col}", col)));
+ return Optional.of(new DataMaskSpec(policyIdent(policy), transformer.replace("{col}", col)));
}
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java
index 6f862ceade1f1f..11cbdf20f1b943 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java
@@ -19,14 +19,14 @@
import org.apache.doris.analysis.ResourceTypeEnum;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.authorizer.ranger.RangerAccessController;
import org.apache.doris.common.AuthorizationException;
import org.apache.doris.common.ThreadPoolManager;
import org.apache.doris.datasource.InternalCatalog;
-import org.apache.doris.mysql.privilege.DataMaskPolicy;
import org.apache.doris.mysql.privilege.PrivPredicate;
-import org.apache.doris.mysql.privilege.RowFilterPolicy;
import com.google.common.collect.Maps;
import org.apache.logging.log4j.LogManager;
@@ -259,7 +259,7 @@ public boolean checkWorkloadGroupPriv(UserIdentity currentUser, String workloadG
}
@Override
- public List extends RowFilterPolicy> evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db,
+ public List evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db,
String tbl) {
lifecycleLock.readLock().lock();
try {
@@ -270,7 +270,7 @@ public List extends RowFilterPolicy> evalRowFilterPolicies(UserIdentity curren
}
@Override
- public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
+ public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
String col) {
lifecycleLock.readLock().lock();
try {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java b/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java
index bee844a9349bb9..4e38efab2781c1 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java
@@ -18,6 +18,8 @@
package org.apache.doris.common.cache;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.catalog.DatabaseIf;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.MTMV;
@@ -34,8 +36,6 @@
import org.apache.doris.datasource.CatalogIf;
import org.apache.doris.datasource.CatalogMgr;
import org.apache.doris.mtmv.MTMVRelatedTableIf;
-import org.apache.doris.mysql.privilege.DataMaskPolicy;
-import org.apache.doris.mysql.privilege.RowFilterPolicy;
import org.apache.doris.nereids.CascadesContext;
import org.apache.doris.nereids.SqlCacheContext;
import org.apache.doris.nereids.SqlCacheContext.CacheKeyType;
@@ -535,11 +535,11 @@ private IsChanged viewsChanged(Env env, SqlCacheContext sqlCacheContext) {
}
private boolean rowPoliciesChanged(UserIdentity currentUserIdentity, Env env, SqlCacheContext sqlCacheContext) {
- for (Entry> kv : sqlCacheContext.getRowPolicies().entrySet()) {
+ for (Entry> kv : sqlCacheContext.getRowPolicies().entrySet()) {
FullTableName qualifiedTable = kv.getKey();
- List extends RowFilterPolicy> cachedPolicies = kv.getValue();
+ List cachedPolicies = kv.getValue();
- List extends RowFilterPolicy> rowPolicies = env.getAccessManager().evalRowFilterPolicies(
+ List rowPolicies = env.getAccessManager().evalRowFilterPolicies(
currentUserIdentity, qualifiedTable.catalog, qualifiedTable.db, qualifiedTable.table);
if (!CollectionUtils.isEqualCollection(cachedPolicies, rowPolicies)) {
return true;
@@ -550,11 +550,11 @@ private boolean rowPoliciesChanged(UserIdentity currentUserIdentity, Env env, Sq
private boolean dataMaskPoliciesChanged(
UserIdentity currentUserIdentity, Env env, SqlCacheContext sqlCacheContext) {
- for (Entry> kv : sqlCacheContext.getDataMaskPolicies().entrySet()) {
+ for (Entry> kv : sqlCacheContext.getDataMaskPolicies().entrySet()) {
FullColumnName qualifiedColumn = kv.getKey();
- Optional cachedPolicy = kv.getValue();
+ Optional cachedPolicy = kv.getValue();
- Optional dataMaskPolicy = env.getAccessManager()
+ Optional dataMaskPolicy = env.getAccessManager()
.evalDataMaskPolicy(currentUserIdentity, qualifiedColumn.catalog,
qualifiedColumn.db, qualifiedColumn.table, qualifiedColumn.column);
if (!Objects.equals(cachedPolicy, dataMaskPolicy)) {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
index 1268341600db45..07ff5b46854393 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
@@ -19,6 +19,8 @@
import org.apache.doris.analysis.ResourceTypeEnum;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.catalog.AuthorizationInfo;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.info.TableNameInfo;
@@ -450,16 +452,16 @@ public boolean checkPrivByAuthInfo(ConnectContext ctx, AuthorizationInfo authInf
return true;
}
- public Map> evalDataMaskPolicies(UserIdentity currentUser, String
+ public Map> evalDataMaskPolicies(UserIdentity currentUser, String
ctl, String db, String tbl, Set cols) {
- Map> res = Maps.newHashMap();
+ Map> res = Maps.newHashMap();
for (String col : cols) {
res.put(col, evalDataMaskPolicy(currentUser, ctl, db, tbl, col));
}
return res;
}
- public Optional evalDataMaskPolicy(UserIdentity currentUser, String
+ public Optional evalDataMaskPolicy(UserIdentity currentUser, String
ctl, String db, String tbl, String col) {
Objects.requireNonNull(currentUser, "require currentUser object");
Objects.requireNonNull(ctl, "require ctl object");
@@ -469,7 +471,7 @@ public Optional evalDataMaskPolicy(UserIdentity currentUser, Str
return getAccessControllerOrDefault(ctl).evalDataMaskPolicy(currentUser, ctl, db, tbl, col.toLowerCase());
}
- public List extends RowFilterPolicy> evalRowFilterPolicies(UserIdentity currentUser, String
+ public List evalRowFilterPolicies(UserIdentity currentUser, String
ctl, String db, String tbl) {
Objects.requireNonNull(currentUser, "require currentUser object");
Objects.requireNonNull(ctl, "require ctl object");
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/CatalogAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/CatalogAccessController.java
index 8e548b7463f297..deb144722b2c91 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/CatalogAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/CatalogAccessController.java
@@ -19,6 +19,8 @@
import org.apache.doris.analysis.ResourceTypeEnum;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.common.AuthorizationException;
import java.util.List;
@@ -88,8 +90,16 @@ void checkColsPriv(UserIdentity currentUser, String ctl, String db, String tbl,
boolean checkStorageVaultPriv(UserIdentity currentUser, String storageVaultName, PrivPredicate wanted);
- Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
+ /**
+ * How {@code col} must be rewritten before {@code currentUser} may read it, or empty when it is not masked.
+ * The returned payload carries a SQL expression, never a parsed one: see {@link DataMaskSpec}.
+ */
+ Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
String col);
- List extends RowFilterPolicy> evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db, String tbl);
+ /**
+ * The row-level filters that apply to {@code tbl} for {@code currentUser}, empty when there are none.
+ * The engine combines them per {@link org.apache.doris.authorization.RowFilterMergeType}.
+ */
+ List evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db, String tbl);
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/DataMaskPolicy.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/DataMaskPolicy.java
deleted file mode 100644
index ca22129628e445..00000000000000
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/DataMaskPolicy.java
+++ /dev/null
@@ -1,24 +0,0 @@
-// 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.doris.mysql.privilege;
-
-public interface DataMaskPolicy {
- String getMaskTypeDef();
-
- String getPolicyIdent();
-}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAccessController.java
index 65a40ae136199c..51c6ae9f1203e9 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAccessController.java
@@ -19,8 +19,16 @@
import org.apache.doris.analysis.ResourceTypeEnum;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterMergeType;
+import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.catalog.Env;
+import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.AuthorizationException;
+import org.apache.doris.policy.FilterType;
+import org.apache.doris.policy.RowPolicy;
+
+import com.google.common.collect.ImmutableList;
import java.util.List;
import java.util.Optional;
@@ -81,14 +89,41 @@ public boolean checkStorageVaultPriv(UserIdentity currentUser, String storageVau
}
@Override
- public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
+ public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
String col) {
+ // The built-in privilege model has no column masking: there is no DDL to define one.
return Optional.empty();
}
@Override
- public List extends RowFilterPolicy> evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db,
+ public List evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db,
String tbl) {
- return Env.getCurrentEnv().getPolicyMgr().getUserPolicies(ctl, db, tbl, currentUser);
+ List policies = Env.getCurrentEnv().getPolicyMgr().getUserPolicies(ctl, db, tbl, currentUser);
+ ImmutableList.Builder specs = ImmutableList.builderWithExpectedSize(policies.size());
+ for (RowPolicy policy : policies) {
+ try {
+ specs.add(new RowFilterSpec(policy.getPolicyIdent(), policy.getFilterSql(),
+ mergeTypeOf(policy.getFilterType())));
+ } catch (AnalysisException e) {
+ // A policy whose statement no longer parses cannot be turned into a filter, and dropping it
+ // would silently widen access to the whole table. Fail the query with the same message the
+ // planner used to raise when it asked the policy for its expression.
+ throw new org.apache.doris.nereids.exceptions.AnalysisException(e.getMessage(), e);
+ }
+ }
+ return specs.build();
+ }
+
+ private static RowFilterMergeType mergeTypeOf(FilterType filterType) {
+ switch (filterType) {
+ case PERMISSIVE:
+ return RowFilterMergeType.PERMISSIVE;
+ case RESTRICTIVE:
+ return RowFilterMergeType.RESTRICTIVE;
+ default:
+ // Same shape as the planner's merge switch used to have: an unmapped filter type is a bug,
+ // and guessing either way would change which rows the user sees.
+ throw new IllegalStateException("Invalid operator");
+ }
}
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RangerDataMaskPolicy.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RangerDataMaskPolicy.java
deleted file mode 100644
index 91010f80cb378f..00000000000000
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RangerDataMaskPolicy.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// 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.doris.mysql.privilege;
-
-import org.apache.doris.analysis.UserIdentity;
-
-public class RangerDataMaskPolicy implements DataMaskPolicy {
- private UserIdentity userIdentity;
- private String ctl;
- private String db;
- private String tbl;
- private String col;
- private long policyId;
- private long policyVersion;
- private String maskType;
- private String maskTypeDef;
-
- public RangerDataMaskPolicy(UserIdentity userIdentity, String ctl, String db, String tbl, String col,
- long policyId,
- long policyVersion, String maskType, String maskTypeDef) {
- this.userIdentity = userIdentity;
- this.ctl = ctl;
- this.db = db;
- this.tbl = tbl;
- this.col = col;
- this.policyId = policyId;
- this.policyVersion = policyVersion;
- this.maskType = maskType;
- this.maskTypeDef = maskTypeDef;
- }
-
- public UserIdentity getUserIdentity() {
- return userIdentity;
- }
-
- public String getCtl() {
- return ctl;
- }
-
- public String getDb() {
- return db;
- }
-
- public String getTbl() {
- return tbl;
- }
-
- public String getCol() {
- return col;
- }
-
- public long getPolicyId() {
- return policyId;
- }
-
- public long getPolicyVersion() {
- return policyVersion;
- }
-
- public String getMaskType() {
- return maskType;
- }
-
- @Override
- public String getMaskTypeDef() {
- return maskTypeDef;
- }
-
- @Override
- public String getPolicyIdent() {
- return getPolicyId() + ":" + getPolicyVersion();
- }
-
- @Override
- public String toString() {
- return "RangerDataMaskPolicy{"
- + "userIdentity=" + userIdentity
- + ", ctl='" + ctl + '\''
- + ", db='" + db + '\''
- + ", tbl='" + tbl + '\''
- + ", col='" + col + '\''
- + ", policyId=" + policyId
- + ", policyVersion=" + policyVersion
- + ", maskType='" + maskType + '\''
- + ", maskTypeDef='" + maskTypeDef + '\''
- + '}';
- }
-}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RangerRowFilterPolicy.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RangerRowFilterPolicy.java
deleted file mode 100644
index 661efcf8a4a852..00000000000000
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RangerRowFilterPolicy.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// 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.doris.mysql.privilege;
-
-import org.apache.doris.analysis.UserIdentity;
-import org.apache.doris.nereids.parser.NereidsParser;
-import org.apache.doris.nereids.trees.expressions.Expression;
-
-public class RangerRowFilterPolicy implements RowFilterPolicy {
- private UserIdentity userIdentity;
- private String ctl;
- private String db;
- private String tbl;
- private long policyId;
- private long policyVersion;
- private String filterExpr;
-
- public RangerRowFilterPolicy(UserIdentity userIdentity, String ctl, String db, String tbl, long policyId,
- long policyVersion, String filterExpr) {
- this.userIdentity = userIdentity;
- this.ctl = ctl;
- this.db = db;
- this.tbl = tbl;
- this.policyId = policyId;
- this.policyVersion = policyVersion;
- this.filterExpr = filterExpr;
- }
-
- public UserIdentity getUserIdentity() {
- return userIdentity;
- }
-
- public String getCtl() {
- return ctl;
- }
-
- public String getDb() {
- return db;
- }
-
- public String getTbl() {
- return tbl;
- }
-
- public long getPolicyId() {
- return policyId;
- }
-
- public long getPolicyVersion() {
- return policyVersion;
- }
-
- public String getFilterExpr() {
- return filterExpr;
- }
-
- @Override
- public Expression getFilterExpression() {
- NereidsParser nereidsParser = new NereidsParser();
- return nereidsParser.parseExpression(filterExpr);
- }
-
- @Override
- public String getPolicyIdent() {
- return getPolicyId() + ":" + getPolicyVersion();
- }
-
- @Override
- public String toString() {
- return "RangerRowFilterPolicy{"
- + "userIdentity=" + userIdentity
- + ", ctl='" + ctl + '\''
- + ", db='" + db + '\''
- + ", tbl='" + tbl + '\''
- + ", policyId=" + policyId
- + ", policyVersion=" + policyVersion
- + ", filterExpr='" + filterExpr + '\''
- + '}';
- }
-}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RowFilterPolicy.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RowFilterPolicy.java
deleted file mode 100644
index 678a1927e243dc..00000000000000
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RowFilterPolicy.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// 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.doris.mysql.privilege;
-
-import org.apache.doris.common.AnalysisException;
-import org.apache.doris.nereids.trees.expressions.Expression;
-import org.apache.doris.policy.FilterType;
-
-public interface RowFilterPolicy {
- default FilterType getFilterType() {
- return FilterType.RESTRICTIVE;
- }
-
- Expression getFilterExpression() throws AnalysisException;
-
- String getPolicyIdent();
-}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java
index aeb58b771c6bbf..028c5b7535c42d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java
@@ -19,6 +19,8 @@
import org.apache.doris.analysis.Expr;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.catalog.DatabaseIf;
import org.apache.doris.catalog.MTMV;
import org.apache.doris.catalog.OlapTable;
@@ -29,8 +31,6 @@
import org.apache.doris.mtmv.MTMVRelatedTableIf;
import org.apache.doris.mysql.FieldInfo;
import org.apache.doris.mysql.privilege.Auth;
-import org.apache.doris.mysql.privilege.DataMaskPolicy;
-import org.apache.doris.mysql.privilege.RowFilterPolicy;
import org.apache.doris.nereids.parser.NereidsParser;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.Variable;
@@ -79,8 +79,8 @@ public class SqlCacheContext {
private final Map usedViews = Maps.newLinkedHashMap();
// value: usedColumns
private final Map> checkPrivilegeTablesOrViews = Maps.newLinkedHashMap();
- private final Map> rowPolicies = Maps.newLinkedHashMap();
- private final Map> dataMaskPolicies = Maps.newLinkedHashMap();
+ private final Map> rowPolicies = Maps.newLinkedHashMap();
+ private final Map> dataMaskPolicies = Maps.newLinkedHashMap();
private final Set usedVariables = Sets.newLinkedHashSet();
// key: the expression which **contains** nondeterministic function, e.g. date_add(date_column, date(now()))
// value: the expression which already try to fold nondeterministic function,
@@ -275,22 +275,22 @@ public synchronized void addCheckPrivilegeTablesOrViews(TableIf tableIf, Set rowFilterPolicy) {
+ String catalog, String db, String table, List rowFilterPolicy) {
rowPolicies.put(new FullTableName(catalog, db, table), Utils.fastToImmutableList(rowFilterPolicy));
}
- public synchronized Map> getRowFilterPolicies() {
+ public synchronized Map> getRowFilterPolicies() {
return ImmutableMap.copyOf(rowPolicies);
}
public synchronized void addDataMaskPolicy(
- String catalog, String db, String table, String columnName, Optional dataMaskPolicy) {
+ String catalog, String db, String table, String columnName, Optional dataMaskPolicy) {
dataMaskPolicies.put(
new FullColumnName(catalog, db, table, columnName.toLowerCase(Locale.ROOT)), dataMaskPolicy
);
}
- public synchronized Map> getDataMaskPolicies() {
+ public synchronized Map> getDataMaskPolicies() {
return ImmutableMap.copyOf(dataMaskPolicies);
}
@@ -378,7 +378,7 @@ public synchronized Map> getCheckPrivilegeTablesOrVie
return ImmutableMap.copyOf(checkPrivilegeTablesOrViews);
}
- public synchronized Map> getRowPolicies() {
+ public synchronized Map> getRowPolicies() {
return ImmutableMap.copyOf(rowPolicies);
}
@@ -480,8 +480,8 @@ public synchronized PUniqueId doComputeCacheKeyMd5(
.append("=")
.append(pair.value().toSql());
}
- for (Entry> entry : rowPolicies.entrySet()) {
- List policy = entry.getValue();
+ for (Entry> entry : rowPolicies.entrySet()) {
+ List policy = entry.getValue();
if (policy.isEmpty()) {
continue;
}
@@ -490,7 +490,7 @@ public synchronized PUniqueId doComputeCacheKeyMd5(
.append("=")
.append(policy);
}
- for (Entry> entry : dataMaskPolicies.entrySet()) {
+ for (Entry> entry : dataMaskPolicies.entrySet()) {
if (!entry.getValue().isPresent()) {
continue;
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
index 05336a7771e784..ec0e275c69f810 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java
@@ -2538,7 +2538,10 @@ public Command visitCreateRowPolicy(CreateRowPolicyContext ctx) {
ctx.EXISTS() != null, new TableNameInfo(nameParts), Optional.of(filterType),
ctx.user == null ? null : visitUserIdentify(ctx.user),
ctx.roleName == null ? null : ctx.roleName.getText(),
- Optional.of(getExpression(ctx.booleanExpression())), ImmutableMap.of());
+ Optional.of(getExpression(ctx.booleanExpression())),
+ // The predicate is kept as the user wrote it, because that text - not a rendering of the
+ // parsed tree - is what the authorization layer hands back to the planner.
+ getOriginSql(ctx.booleanExpression()), ImmutableMap.of());
}
@Override
@@ -2548,7 +2551,7 @@ public Command visitCreateStoragePolicy(CreateStoragePolicyContext ctx) {
: Maps.newHashMap();
return new CreatePolicyCommand(PolicyTypeEnum.STORAGE, ctx.name.getText(),
ctx.EXISTS() != null, null, Optional.empty(),
- null, null, Optional.empty(), properties);
+ null, null, Optional.empty(), null, properties);
}
@Override
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/CreatePolicyCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/CreatePolicyCommand.java
index 8dca6d83a12e44..16aa21ec91bf9e 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/CreatePolicyCommand.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/CreatePolicyCommand.java
@@ -61,6 +61,8 @@ public class CreatePolicyCommand extends Command implements ForwardWithSync {
private final UserIdentity user;
private final String roleName;
private final Optional wherePredicate;
+ // The row predicate exactly as written, null for a storage policy.
+ private final String wherePredicateSql;
private final Map properties;
/**
@@ -68,7 +70,7 @@ public class CreatePolicyCommand extends Command implements ForwardWithSync {
*/
public CreatePolicyCommand(PolicyTypeEnum policyType, String policyName, boolean ifNotExists,
TableNameInfo tableNameInfo, Optional filterType, UserIdentity user, String roleName,
- Optional wherePredicate, Map properties) {
+ Optional wherePredicate, String wherePredicateSql, Map properties) {
super(PlanType.CREATE_POLICY_COMMAND);
this.policyType = policyType;
this.policyName = policyName;
@@ -78,6 +80,7 @@ public CreatePolicyCommand(PolicyTypeEnum policyType, String policyName, boolean
this.user = user;
this.roleName = roleName;
this.wherePredicate = wherePredicate;
+ this.wherePredicateSql = wherePredicateSql;
this.properties = properties;
}
@@ -85,6 +88,10 @@ public Optional getWherePredicate() {
return wherePredicate;
}
+ public String getWherePredicateSql() {
+ return wherePredicateSql;
+ }
+
public Map getProperties() {
return properties;
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalCheckPolicy.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalCheckPolicy.java
index 6d204304cb18c0..a40127df7bfe85 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalCheckPolicy.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalCheckPolicy.java
@@ -18,17 +18,16 @@
package org.apache.doris.nereids.trees.plans.logical;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.catalog.DatabaseIf;
import org.apache.doris.catalog.TableIf;
import org.apache.doris.datasource.CatalogIf;
import org.apache.doris.mysql.privilege.AccessControllerManager;
-import org.apache.doris.mysql.privilege.DataMaskPolicy;
-import org.apache.doris.mysql.privilege.RowFilterPolicy;
import org.apache.doris.nereids.CascadesContext;
import org.apache.doris.nereids.SqlCacheContext;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.analyzer.UnboundAlias;
-import org.apache.doris.nereids.exceptions.AnalysisException;
import org.apache.doris.nereids.memo.GroupExpression;
import org.apache.doris.nereids.parser.NereidsParser;
import org.apache.doris.nereids.properties.LogicalProperties;
@@ -180,10 +179,10 @@ public RelatedPolicy findPolicy(LogicalPlan logicalPlan, CascadesContext cascade
Optional sqlCacheContext = statementContext.getSqlCacheContext();
boolean hasDataMask = false;
for (Slot slot : logicalPlan.getOutput()) {
- Optional dataMaskPolicy = accessManager.evalDataMaskPolicy(
+ Optional dataMaskPolicy = accessManager.evalDataMaskPolicy(
currentUserIdentity, ctlName, dbName, tableName, slot.getName());
if (dataMaskPolicy.isPresent()) {
- Expression unboundExpr = nereidsParser.parseExpression(dataMaskPolicy.get().getMaskTypeDef());
+ Expression unboundExpr = nereidsParser.parseExpression(dataMaskPolicy.get().getMaskSql());
Expression childOfAlias
= unboundExpr instanceof UnboundAlias ? unboundExpr.child(0) : unboundExpr;
Alias alias = new Alias(
@@ -201,7 +200,7 @@ public RelatedPolicy findPolicy(LogicalPlan logicalPlan, CascadesContext cascade
}
}
- List extends RowFilterPolicy> rowPolicies = accessManager.evalRowFilterPolicies(
+ List rowPolicies = accessManager.evalRowFilterPolicies(
currentUserIdentity, ctlName, dbName, tableName);
if (sqlCacheContext.isPresent()) {
sqlCacheContext.get().setRowFilterPolicy(ctlName, dbName, tableName, rowPolicies);
@@ -223,17 +222,15 @@ private RelatedPolicy findPolicyByMvRefresh(Map> mvRefr
return RelatedPolicy.NO_POLICY;
}
- private Expression mergeRowPolicy(List extends RowFilterPolicy> policies) {
+ private Expression mergeRowPolicy(List policies) {
List orList = new ArrayList<>();
List andList = new ArrayList<>();
- for (RowFilterPolicy policy : policies) {
- Expression wherePredicate = null;
- try {
- wherePredicate = policy.getFilterExpression();
- } catch (org.apache.doris.common.AnalysisException e) {
- throw new AnalysisException(e.getMessage(), e);
- }
- switch (policy.getFilterType()) {
+ NereidsParser nereidsParser = new NereidsParser();
+ for (RowFilterSpec policy : policies) {
+ // The authorization source hands us the predicate as SQL text - the form both a Ranger policy and
+ // a CREATE ROW POLICY statement natively have - and parsing it is the engine's job.
+ Expression wherePredicate = nereidsParser.parseExpression(policy.getFilterSql());
+ switch (policy.getMergeType()) {
case PERMISSIVE:
orList.add(wherePredicate);
break;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/policy/RowPolicy.java b/fe/fe-core/src/main/java/org/apache/doris/policy/RowPolicy.java
index e83a1191c0b47d..c25dd713758759 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/policy/RowPolicy.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/policy/RowPolicy.java
@@ -21,7 +21,6 @@
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.ScalarType;
import org.apache.doris.common.AnalysisException;
-import org.apache.doris.mysql.privilege.RowFilterPolicy;
import org.apache.doris.nereids.parser.NereidsParser;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.plans.commands.CreatePolicyCommand;
@@ -30,6 +29,8 @@
import com.google.common.collect.Lists;
import com.google.gson.annotations.SerializedName;
import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.ToString;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -43,7 +44,7 @@
* Save policy for filtering data.
**/
@Data
-public class RowPolicy extends Policy implements RowFilterPolicy {
+public class RowPolicy extends Policy {
public static final ShowResultSetMetaData ROW_META_DATA =
ShowResultSetMetaData.builder()
@@ -101,6 +102,13 @@ public class RowPolicy extends Policy implements RowFilterPolicy {
private Expression wherePredicate = null;
+ // Derived from originStmt on first use, never persisted: see getFilterSql(). Excluded from equality and
+ // toString because it is a lazily filled cache - two policies created from the same statement must not
+ // compare differently depending on whether a query has already asked for the predicate text.
+ @EqualsAndHashCode.Exclude
+ @ToString.Exclude
+ private volatile String wherePredicateSql = null;
+
public RowPolicy() {
super(PolicyTypeEnum.ROW);
}
@@ -221,15 +229,39 @@ public boolean isInvalid() {
return (wherePredicate == null);
}
- @Override
- public Expression getFilterExpression() throws AnalysisException {
+ /**
+ * The predicate as SQL text, which is the form the authorization layer hands to the planner.
+ *
+ *
It is the text the administrator wrote, recovered from the stored statement - not a rendering of
+ * the parsed predicate. Rendering would not survive the round trip: {@code toSql()} on a compound
+ * predicate produces the diagnostic form {@code AND[a,b]}, which does not parse back, so any policy
+ * combining two conditions would break.
+ */
+ public String getFilterSql() throws AnalysisException {
if (wherePredicate == null) {
throw new AnalysisException("Invalid row policy [" + getPolicyIdent() + "], " + getOriginStmt());
}
- return wherePredicate;
+ if (wherePredicateSql == null) {
+ wherePredicateSql = parseWherePredicateSql();
+ }
+ return wherePredicateSql;
+ }
+
+ private String parseWherePredicateSql() throws AnalysisException {
+ try {
+ CreatePolicyCommand command = (CreatePolicyCommand) new NereidsParser().parseSingle(getOriginStmt());
+ if (!StringUtils.isEmpty(command.getWherePredicateSql())) {
+ return command.getWherePredicateSql();
+ }
+ } catch (Exception e) {
+ LOG.warn("failed to recover the predicate text of row policy [{}]", getPolicyIdent(), e);
+ }
+ // The statement parsed once already (that is where wherePredicate came from), so reaching here means
+ // the stored statement no longer matches what the parser produces. Refuse the query rather than let
+ // the table be read unfiltered.
+ throw new AnalysisException("Invalid row policy [" + getPolicyIdent() + "], " + getOriginStmt());
}
- @Override
public String getPolicyIdent() {
return getPolicyName();
}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheDataPolicyTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheDataPolicyTest.java
index 3aba8399659211..23b80781635bdf 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheDataPolicyTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/common/cache/NereidsSqlCacheDataPolicyTest.java
@@ -18,13 +18,11 @@
package org.apache.doris.common.cache;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.catalog.Env;
import org.apache.doris.common.jmockit.Deencapsulation;
import org.apache.doris.mysql.privilege.AccessControllerManager;
-import org.apache.doris.mysql.privilege.DataMaskPolicy;
-import org.apache.doris.mysql.privilege.RangerDataMaskPolicy;
-import org.apache.doris.mysql.privilege.RangerRowFilterPolicy;
-import org.apache.doris.mysql.privilege.RowFilterPolicy;
import org.apache.doris.nereids.SqlCacheContext;
import com.google.common.collect.ImmutableList;
@@ -45,9 +43,9 @@
*
*
An authorization source builds its policy objects fresh on every evaluation (it is answering a request,
* not handing out a cached object), so value equality is the source's obligation, and the "unchanged" tests
- * below are what enforce it. They are RED for the Ranger policy types, which have no equals: the comparison
- * degrades to identity, every lookup reads as a policy change, and a user with any row filter or column mask
- * configured never gets a cache hit at all.
+ * below are what enforce it. They were RED before the payload rework: the Ranger policy types carried no
+ * equals, so the comparison degraded to identity, every lookup read as a policy change, and a user with any
+ * row filter or column mask configured never got a cache hit at all.
*
*
The "changed" tests pin the other half of the contract, so a fix cannot buy cache hits by weakening the
* comparison: an edited policy must still invalidate, or the cache keeps serving rows the user may no longer
@@ -61,12 +59,13 @@ public class NereidsSqlCacheDataPolicyTest {
private static final String COL = "phone";
private static final UserIdentity USER = UserIdentity.ROOT;
- private RowFilterPolicy rowFilter(long policyVersion, String filterExpr) {
- return new RangerRowFilterPolicy(USER, CTL, DB, TBL, 7L, policyVersion, filterExpr);
+ /** Shaped like what a Ranger controller returns: a fresh object per evaluation, identified by id:version. */
+ private RowFilterSpec rowFilter(long policyVersion, String filterExpr) {
+ return RowFilterSpec.restrictive("7:" + policyVersion, filterExpr);
}
- private DataMaskPolicy dataMask(long policyVersion, String maskTypeDef) {
- return new RangerDataMaskPolicy(USER, CTL, DB, TBL, COL, 7L, policyVersion, "CUSTOM", maskTypeDef);
+ private DataMaskSpec dataMask(long policyVersion, String maskSql) {
+ return new DataMaskSpec("7:" + policyVersion, maskSql);
}
/**
@@ -74,11 +73,11 @@ private DataMaskPolicy dataMask(long policyVersion, String maskTypeDef) {
* invoked per call so every evaluation yields a distinct object, exactly as a live authorization source
* behaves.
*/
- private Env mockEnvEvaluating(java.util.function.Supplier rowFilterSupplier,
- java.util.function.Supplier> dataMaskSupplier) {
+ private Env mockEnvEvaluating(java.util.function.Supplier rowFilterSupplier,
+ java.util.function.Supplier> dataMaskSupplier) {
AccessControllerManager accessManager = Mockito.mock(AccessControllerManager.class);
Mockito.doAnswer(invocation -> {
- RowFilterPolicy evaluated = rowFilterSupplier.get();
+ RowFilterSpec evaluated = rowFilterSupplier.get();
return evaluated == null ? ImmutableList.of() : ImmutableList.of(evaluated);
})
.when(accessManager).evalRowFilterPolicies(ArgumentMatchers.any(UserIdentity.class),
@@ -101,14 +100,14 @@ private boolean dataMaskPoliciesChanged(Env env, SqlCacheContext context) {
return Deencapsulation.invoke(new NereidsSqlCacheManager(), "dataMaskPoliciesChanged", USER, env, context);
}
- private SqlCacheContext contextWithRowFilter(RowFilterPolicy policy) {
+ private SqlCacheContext contextWithRowFilter(RowFilterSpec policy) {
SqlCacheContext context = new SqlCacheContext(USER);
- List policies = policy == null ? ImmutableList.of() : ImmutableList.of(policy);
+ List policies = policy == null ? ImmutableList.of() : ImmutableList.of(policy);
context.setRowFilterPolicy(CTL, DB, TBL, policies);
return context;
}
- private SqlCacheContext contextWithDataMask(Optional policy) {
+ private SqlCacheContext contextWithDataMask(Optional policy) {
SqlCacheContext context = new SqlCacheContext(USER);
context.addDataMaskPolicy(CTL, DB, TBL, COL, policy);
return context;
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/CatalogAccessControllerTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/CatalogAccessControllerTest.java
index 163518b33a594c..58a18566f405cd 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/CatalogAccessControllerTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/CatalogAccessControllerTest.java
@@ -19,6 +19,8 @@
import org.apache.doris.analysis.ResourceTypeEnum;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.common.AuthorizationException;
import com.google.common.collect.ImmutableList;
@@ -115,13 +117,13 @@ public boolean checkStorageVaultPriv(UserIdentity currentUser, String storageVau
}
@Override
- public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db,
+ public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db,
String tbl, String col) {
return Optional.empty();
}
@Override
- public List extends RowFilterPolicy> evalRowFilterPolicies(UserIdentity currentUser, String ctl,
+ public List evalRowFilterPolicies(UserIdentity currentUser, String ctl,
String db, String tbl) {
return ImmutableList.of();
}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/RangerTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/RangerTest.java
index e0e1dd36b2a4d6..9a3f1af03b447c 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/RangerTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/RangerTest.java
@@ -19,6 +19,7 @@
import org.apache.doris.analysis.ResourceTypeEnum;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
import org.apache.doris.catalog.authorizer.ranger.doris.RangerDorisAccessController;
import org.apache.doris.catalog.authorizer.ranger.doris.RangerDorisResource;
import org.apache.doris.common.AuthorizationException;
@@ -222,14 +223,14 @@ public void testDataMask() {
RangerDorisAccessController ac = new RangerDorisAccessController(plugin);
UserIdentity ui = UserIdentity.createAnalyzedUserIdentWithIp("user1", "%");
// MASK_NULL
- Optional policy = ac.evalDataMaskPolicy(ui, "ctl1", "db1", "tbl1", "col1");
- Assertions.assertEquals("NULL", policy.get().getMaskTypeDef());
+ Optional policy = ac.evalDataMaskPolicy(ui, "ctl1", "db1", "tbl1", "col1");
+ Assertions.assertEquals("NULL", policy.get().getMaskSql());
// MASK_NONE
policy = ac.evalDataMaskPolicy(ui, "ctl1", "db1", "tbl1", "col2");
Assertions.assertTrue(!policy.isPresent());
// CUSTOM
policy = ac.evalDataMaskPolicy(ui, "ctl1", "db1", "tbl1", "col3");
- Assertions.assertEquals("hex(col3)", policy.get().getMaskTypeDef());
+ Assertions.assertEquals("hex(col3)", policy.get().getMaskSql());
// Others
policy = ac.evalDataMaskPolicy(ui, "ctl1", "db1", "tbl1", "col4");
Assertions.assertTrue(!policy.isPresent());
diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/privileges/TestCheckPrivileges.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/privileges/TestCheckPrivileges.java
index 0473bce3250b13..76c7d3d91eb11f 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/nereids/privileges/TestCheckPrivileges.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/privileges/TestCheckPrivileges.java
@@ -19,6 +19,8 @@
import org.apache.doris.analysis.ResourceTypeEnum;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.PrimitiveType;
@@ -30,21 +32,16 @@
import org.apache.doris.datasource.test.TestExternalCatalog.TestCatalogProvider;
import org.apache.doris.mysql.privilege.AccessControllerManager;
import org.apache.doris.mysql.privilege.CatalogAccessController;
-import org.apache.doris.mysql.privilege.DataMaskPolicy;
import org.apache.doris.mysql.privilege.PrivPredicate;
-import org.apache.doris.mysql.privilege.RowFilterPolicy;
import org.apache.doris.nereids.exceptions.AnalysisException;
-import org.apache.doris.nereids.parser.NereidsParser;
import org.apache.doris.nereids.pattern.GeneratedMemoPatterns;
import org.apache.doris.nereids.rules.RulePromise;
import org.apache.doris.nereids.trees.expressions.Alias;
import org.apache.doris.nereids.trees.expressions.EqualTo;
-import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.NamedExpression;
import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
import org.apache.doris.nereids.util.PlanChecker;
-import org.apache.doris.policy.FilterType;
import org.apache.doris.utframe.TestWithFeService;
import com.google.common.collect.ImmutableList;
@@ -388,7 +385,7 @@ public boolean checkStorageVaultPriv(UserIdentity currentUser, String storageVau
}
@Override
- public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
+ public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
String col) {
List dataMaskingPolicies = dataMaskings.get();
if (dataMaskingPolicies == null) {
@@ -397,32 +394,21 @@ public Optional evalDataMaskPolicy(UserIdentity currentUser, Str
for (CustomDataMaskingPolicy dataMaskingPolicy : dataMaskingPolicies) {
if (dataMaskingPolicy.column.equalsIgnoreCase(col)) {
- return Optional.of(dataMaskingPolicy);
+ return Optional.of(dataMaskingPolicy.toSpec());
}
}
return Optional.empty();
}
@Override
- public List extends RowFilterPolicy> evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db,
+ public List evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db,
String tbl) {
List customRowPolicies = rowPolicies.get();
if (customRowPolicies == null) {
return ImmutableList.of();
}
- NereidsParser nereidsParser = new NereidsParser();
return customRowPolicies.stream()
- .map(p -> new RowFilterPolicy() {
- @Override
- public Expression getFilterExpression() {
- return nereidsParser.parseExpression(p.filter);
- }
-
- @Override
- public String getPolicyIdent() {
- return "custom policy: " + p.filter;
- }
- })
+ .map(CustomRowPolicy::toSpec)
.collect(Collectors.toList());
}
}
@@ -529,7 +515,7 @@ public boolean isSameTable(String catalog, String db, String tbl) {
}
}
- private static class CustomRowPolicy implements RowFilterPolicy {
+ private static class CustomRowPolicy {
private final String user;
private final String filter;
@@ -542,23 +528,14 @@ public String getUser() {
return user;
}
- @Override
- public Expression getFilterExpression() {
- return new NereidsParser().parseExpression(filter);
- }
-
- @Override
- public String getPolicyIdent() {
- return "custom policy: " + filter;
- }
-
- @Override
- public FilterType getFilterType() {
- return FilterType.PERMISSIVE;
+ // Restrictive, which is what this fixture has always produced: the policy object the controller used
+ // to return took the interface default and never carried the PERMISSIVE the fixture declared.
+ public RowFilterSpec toSpec() {
+ return RowFilterSpec.restrictive("custom policy: " + filter, filter);
}
}
- private static class CustomDataMaskingPolicy implements DataMaskPolicy {
+ private static class CustomDataMaskingPolicy {
private final String user;
private final String column;
private final String project;
@@ -573,14 +550,8 @@ public String getUser() {
return user;
}
- @Override
- public String getMaskTypeDef() {
- return project;
- }
-
- @Override
- public String getPolicyIdent() {
- return "custom policy: " + project;
+ public DataMaskSpec toSpec() {
+ return new DataMaskSpec("custom policy: " + project, project);
}
}
}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/CheckRowPolicyTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/CheckRowPolicyTest.java
index a67de805825c06..5b71ef543fe459 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/CheckRowPolicyTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/CheckRowPolicyTest.java
@@ -20,6 +20,7 @@
import org.apache.doris.analysis.TablePattern;
import org.apache.doris.analysis.UserDesc;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
import org.apache.doris.catalog.AccessPrivilege;
import org.apache.doris.catalog.AccessPrivilegeWithCols;
import org.apache.doris.catalog.Database;
@@ -28,11 +29,11 @@
import org.apache.doris.common.FeConstants;
import org.apache.doris.common.jmockit.Deencapsulation;
import org.apache.doris.mysql.privilege.AccessControllerManager;
-import org.apache.doris.mysql.privilege.DataMaskPolicy;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.analyzer.UnboundRelation;
import org.apache.doris.nereids.exceptions.AnalysisException;
import org.apache.doris.nereids.trees.expressions.EqualTo;
+import org.apache.doris.nereids.trees.expressions.Or;
import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator;
import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.commands.CreateUserCommand;
@@ -105,17 +106,9 @@ protected void runBeforeAll() throws Exception {
String tbl = invocation.getArgument(3);
String col = invocation.getArgument(4);
return tbl.equalsIgnoreCase(tableNameRanddomDist)
- ? Optional.of(new DataMaskPolicy() {
- @Override
- public String getMaskTypeDef() {
- return String.format("concat(%s, '_****_', %s)", col, col);
- }
-
- @Override
- public String getPolicyIdent() {
- return String.format("custom policy: concat(%s, '_****_', %s)", col, col);
- }
- })
+ ? Optional.of(new DataMaskSpec(
+ String.format("custom policy: concat(%s, '_****_', %s)", col, col),
+ String.format("concat(%s, '_****_', %s)", col, col)))
: Optional.empty();
}).when(spyAcm).evalDataMaskPolicy(
Mockito.any(UserIdentity.class), Mockito.anyString(),
@@ -205,6 +198,61 @@ public void checkOnePolicy() throws Exception {
+ tableName);
}
+ /**
+ * Two permissive policies widen each other: the user sees rows matching either one, so they must be
+ * ORed into a single conjunct. A single policy cannot tell OR from AND, so this is what actually pins
+ * the merge type the authorization layer carries alongside each filter - drop it and the two predicates
+ * become an AND, which lets the user see nothing at all.
+ */
+ @Test
+ public void checkTwoPermissivePoliciesAreOred() throws Exception {
+ useUser(userName);
+ LogicalRelation relation = new LogicalOlapScan(StatementScopeIdGenerator.newRelationId(), olapTable,
+ Arrays.asList(fullDbName));
+ LogicalCheckPolicy checkPolicy = new LogicalCheckPolicy<>(relation);
+ createPolicy("CREATE ROW POLICY " + policyName + " ON " + tableName
+ + " AS PERMISSIVE TO " + userName + " USING (k1 = 1)");
+ createPolicy("CREATE ROW POLICY " + policyName + "_second ON " + tableName
+ + " AS PERMISSIVE TO " + userName + " USING (k1 = 2)");
+ try {
+ Plan plan = PlanRewriter.bottomUpRewrite(checkPolicy, connectContext, new CheckPolicy());
+
+ LogicalFilter> filter = (LogicalFilter>) plan;
+ Assertions.assertEquals(1, filter.getConjuncts().size(),
+ "permissive policies must merge into one disjunction, not into separate conjuncts");
+ Assertions.assertTrue(ImmutableList.copyOf(filter.getConjuncts()).get(0) instanceof Or,
+ "the user must see rows matching either permissive policy");
+ } finally {
+ // A leaked policy would change what every later test in this class plans.
+ dropPolicy("DROP ROW POLICY " + policyName + " ON " + tableName);
+ dropPolicy("DROP ROW POLICY " + policyName + "_second ON " + tableName);
+ }
+ }
+
+ /** Restrictive policies narrow each other, so they stay separate conjuncts (ANDed). */
+ @Test
+ public void checkTwoRestrictivePoliciesAreAnded() throws Exception {
+ useUser(userName);
+ LogicalRelation relation = new LogicalOlapScan(StatementScopeIdGenerator.newRelationId(), olapTable,
+ Arrays.asList(fullDbName));
+ LogicalCheckPolicy checkPolicy = new LogicalCheckPolicy<>(relation);
+ createPolicy("CREATE ROW POLICY " + policyName + " ON " + tableName
+ + " AS RESTRICTIVE TO " + userName + " USING (k1 = 1)");
+ createPolicy("CREATE ROW POLICY " + policyName + "_second ON " + tableName
+ + " AS RESTRICTIVE TO " + userName + " USING (k2 = 2)");
+ try {
+ Plan plan = PlanRewriter.bottomUpRewrite(checkPolicy, connectContext, new CheckPolicy());
+
+ LogicalFilter> filter = (LogicalFilter>) plan;
+ Assertions.assertEquals(2, filter.getConjuncts().size(),
+ "restrictive policies must all hold, so each stays its own conjunct");
+ filter.getConjuncts().forEach(conjunct -> Assertions.assertTrue(conjunct instanceof EqualTo));
+ } finally {
+ dropPolicy("DROP ROW POLICY " + policyName + " ON " + tableName);
+ dropPolicy("DROP ROW POLICY " + policyName + "_second ON " + tableName);
+ }
+ }
+
@Test
public void checkOnePolicyRandomDist() throws Exception {
useUser(userName);
diff --git a/fe/fe-core/src/test/java/org/apache/doris/policy/RowPolicyFilterSqlTest.java b/fe/fe-core/src/test/java/org/apache/doris/policy/RowPolicyFilterSqlTest.java
new file mode 100644
index 00000000000000..79dbf419c50d98
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/policy/RowPolicyFilterSqlTest.java
@@ -0,0 +1,107 @@
+// 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.doris.policy;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.nereids.parser.NereidsParser;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.plans.commands.CreatePolicyCommand;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * A built-in row policy is stored as a statement but handed to the planner as the SQL text of its predicate.
+ * The recovered text has to be faithful: whatever the planner parses back must be the same predicate the
+ * administrator wrote, or the policy silently filters different rows than it says it does.
+ *
+ *
The corpus below is what row policies actually contain - comparisons, set membership, pattern matches,
+ * null tests, boolean structure with mixed precedence, function calls and the literal shapes that are easiest
+ * to mangle (quotes inside strings, negative numbers, dates). Boolean structure is the case that matters
+ * most: {@code toSql()} on a compound predicate yields the diagnostic form {@code AND[a,b]}, so recovering
+ * the text from the stored statement rather than re-rendering the tree is what keeps these working.
+ */
+public class RowPolicyFilterSqlTest {
+
+ private static final NereidsParser PARSER = new NereidsParser();
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "k1 = 1",
+ "k1 <> 1",
+ "k1 >= 1 and k2 < 10",
+ "k1 = 1 or k2 = 2",
+ "not (k1 = 1)",
+ "k1 = 1 and (k2 = 2 or k2 = 3)",
+ "(k1 = 1 or k2 = 2) and k3 = 3",
+ "k1 in (1, 2, 3)",
+ "k1 not in (1, 2)",
+ "region = 'cn'",
+ "region = 'it''s'",
+ "name like 'a%'",
+ "name not like '%b'",
+ "k1 is null",
+ "k1 is not null",
+ "k1 between 1 and 10",
+ "k1 = -1",
+ "amount > 1.5",
+ "dt = date '2024-01-01'",
+ "dt > '2024-01-01 10:00:00'",
+ "upper(region) = 'CN'",
+ "concat(a, b) = 'ab'",
+ "substr(phone, 1, 3) = '138'",
+ "k1 + 1 > 2",
+ "k1 = 1 and region in ('cn', 'us') and name like 'x%'"
+ })
+ public void testPredicateSurvivesTheRoundTripToThePlanner(String original) throws AnalysisException {
+ RowPolicy policy = policyOver(original);
+
+ Expression asHandedToThePlanner = PARSER.parseExpression(policy.getFilterSql());
+
+ Assertions.assertEquals(PARSER.parseExpression(original), asHandedToThePlanner,
+ "the predicate the planner receives is not the one the policy was created with: " + original
+ + " became " + policy.getFilterSql());
+ }
+
+ /**
+ * A policy whose statement no longer parses (an upgrade dropped a function, say) has no predicate to
+ * render. It must fail the query, never quietly disappear - a vanished row filter exposes the whole
+ * table.
+ */
+ @Test
+ public void testUnparseablePolicyFailsInsteadOfVanishing() {
+ RowPolicy broken = new RowPolicy(1L, "p1", "internal", "db1", "t1", UserIdentity.ROOT, null,
+ "CREATE ROW POLICY p1 ON db1.t1 AS RESTRICTIVE TO root USING (gone_function(k1))", 0,
+ FilterType.RESTRICTIVE, null);
+
+ AnalysisException thrown = Assertions.assertThrows(AnalysisException.class, broken::getFilterSql);
+ Assertions.assertTrue(thrown.getMessage().contains("Invalid row policy"),
+ "the error must name the broken policy so an operator can find it: " + thrown.getMessage());
+ }
+
+ /** Builds the policy the way CREATE ROW POLICY does: statement text plus the predicate parsed from it. */
+ private RowPolicy policyOver(String predicate) {
+ String statement = "CREATE ROW POLICY p1 ON db1.t1 AS RESTRICTIVE TO root USING (" + predicate + ")";
+ CreatePolicyCommand command = (CreatePolicyCommand) PARSER.parseSingle(statement);
+ return new RowPolicy(1L, "p1", "internal", "db1", "t1", UserIdentity.ROOT, null,
+ statement, 0, FilterType.RESTRICTIVE, command.getWherePredicate().get());
+ }
+}
From bdcd7648aa888dfc612fa5bfc96150f8610aa176 Mon Sep 17 00:00:00 2001
From: morningman
Date: Wed, 12 Aug 2026 09:18:38 +0800
Subject: [PATCH 04/22] [test](authorization) freeze today's access-control
decisions in a golden matrix
Two reworks are queued behind this: dropping the hasGlobal argument from the
controller interface, and collapsing AccessControllerManager into pure routing
with no cross-plugin OR. Both are meant to change structure and nothing else,
and "nothing else" is only checkable against a recording made beforehand.
The matrix crosses the default controller (built-in / Ranger) with the catalog
kind (internal, unbound external, Ranger-governed external), five callers whose
privilege shapes each pin one invariant, every PrivPredicate constant found by
reflection, and every check the manager exposes. The built-in half runs against
a real FE with real grants and a real row policy; only Ranger's policy engine is
stubbed, and it denies the built-in admin everywhere so that wherever that user
still passes on a Ranger-governed resource, the verdict provably came from the
engine's OR rather than from Ranger.
Each row lists the actions that were allowed, so a flipped decision shows up as
one action name appearing or disappearing on one line.
Recording it surfaced three things worth writing down:
- checkCloudPriv cannot be probed at all on a non-cloud FE: the built-in path
casts the system info service to its cloud subclass unconditionally, so every
caller that does not already hold a global privilege gets a ClassCastException.
That probe is excluded rather than frozen.
- "the default workload group is always allowed" is implemented twice, in Auth
and again in Role, so removing either one alone changes nothing.
- a column check rejects any privilege carrying neither SELECT nor LOAD with an
IllegalStateException, and whether a caller hits it depends on whether the
manager's global short circuit answered first - which is exactly the ordering
the rework moves, so the baseline records that outcome as its own column.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../AccessControlBehaviorBaselineTest.java | 405 ++++++++++++++++++
.../StubRangerAccessControllerFactory.java | 38 ++
.../privilege/StubRangerPolicyEngine.java | 172 ++++++++
...is.mysql.privilege.AccessControllerFactory | 3 +-
.../access-control-behavior-baseline.txt | 323 ++++++++++++++
5 files changed, 940 insertions(+), 1 deletion(-)
create mode 100644 fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControlBehaviorBaselineTest.java
create mode 100644 fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/StubRangerAccessControllerFactory.java
create mode 100644 fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/StubRangerPolicyEngine.java
create mode 100644 fe/fe-core/src/test/resources/access-control-behavior-baseline.txt
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControlBehaviorBaselineTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControlBehaviorBaselineTest.java
new file mode 100644
index 00000000000000..4abeee55ee7c75
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControlBehaviorBaselineTest.java
@@ -0,0 +1,405 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.PrimitiveType;
+import org.apache.doris.catalog.authorizer.ranger.doris.RangerDorisAccessController;
+import org.apache.doris.common.Config;
+import org.apache.doris.common.FeConstants;
+import org.apache.doris.common.UserException;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.datasource.CatalogMgr;
+import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.datasource.test.TestExternalCatalog.TestCatalogProvider;
+import org.apache.doris.nereids.parser.NereidsParser;
+import org.apache.doris.nereids.trees.plans.commands.GrantResourcePrivilegeCommand;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.qe.StmtExecutor;
+import org.apache.doris.resource.workloadgroup.WorkloadGroupMgr;
+import org.apache.doris.utframe.TestWithFeService;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.TreeMap;
+import java.util.function.BooleanSupplier;
+import java.util.stream.Collectors;
+
+/**
+ * Records, in one golden file, every decision {@link AccessControllerManager} makes over the matrix
+ * (resource kind x action x built-in/Ranger x privilege level of the caller).
+ *
+ *
Why this exists. Two reworks are queued behind it: dropping the {@code hasGlobal} argument from
+ * the controller interface (moving "global first, then fine grained" inside the built-in implementation), and
+ * collapsing the manager into pure routing with no cross-plugin OR. Both are supposed to change structure and
+ * nothing else. "Nothing else" is only checkable against a recording made before the change, so this file is
+ * that recording: after either rework, {@code git diff} on the baseline must be empty.
+ *
+ *
What is real and what is faked. The built-in half is entirely real - a real FE, real users, real
+ * {@code GRANT} statements, a real row policy - because a baseline built on hand-poked internal state would
+ * freeze this test's assumptions rather than the product's behaviour. The Ranger half runs the production
+ * {@link org.apache.doris.catalog.authorizer.ranger.doris.RangerDorisAccessController} over the deterministic
+ * {@link StubRangerPolicyEngine}; only the policy engine is a stub.
+ *
+ *
Reading a row. {@code | | | | }. For the
+ * check probes the verdict lists exactly the actions that were allowed, so a single flipped cell shows up as
+ * one action name appearing or disappearing on one line. {@code -} means "nothing allowed" / "no policy".
+ * Scope {@code -} marks the system-level resources, which are always answered by the default controller.
+ *
+ *
Deliberately frozen oddities. Some rows record behaviour that looks wrong and is kept anyway,
+ * because Phase 0's contract is structure-only:
+ *
+ *
{@code node_user} holds only global NODE_PRIV, and {@link Auth} refuses NODE privileges at
+ * catalog/database/table level - yet the manager's global short circuit lets OPERATOR through there;
+ *
{@code admin_user} is denied by Ranger everywhere, yet passes on the Ranger-governed catalog: that is
+ * the cross-plugin OR the rework must reproduce from inside the plugin;
+ *
a built-in per-table GRANT on a Ranger-governed catalog is ignored ({@code local_user} on
+ * {@code ext_ranger}), because the OR only exists at the global level;
+ *
the workload group named {@code normal} is allowed unconditionally, in both implementations;
+ *
with {@code skip_catalog_priv_check} on, a catalog bound to an external plugin answers SELECT/SHOW
+ * with a flat yes.
+ *
+ *
+ *
Regenerating. Run this test; on mismatch it writes the full current matrix next to the build
+ * output and names the path. Copy it over {@code src/test/resources/access-control-behavior-baseline.txt}
+ * only together with a justification for every changed line - each one is a user-visible privilege change.
+ */
+public class AccessControlBehaviorBaselineTest extends TestWithFeService {
+ private static final String BASELINE_RESOURCE = "/access-control-behavior-baseline.txt";
+ private static final Path ACTUAL_DUMP = Paths.get("target", "access-control-behavior-baseline.actual.txt");
+
+ private static final String DB = StubRangerPolicyEngine.ALLOWED_DB;
+ private static final String TBL = StubRangerPolicyEngine.ALLOWED_TABLE;
+ /** Ranger grants this column to {@code ranger_user}; the built-in grants are table wide. */
+ private static final String COL_ALLOWED = StubRangerPolicyEngine.ALLOWED_COLUMN;
+ private static final String COL_DENIED = "pcol2";
+
+ private static final String CTL_PLAIN = "ext_plain";
+ private static final String CTL_RANGER = "ext_ranger";
+ private static final List CATALOGS =
+ ImmutableList.of(InternalCatalog.INTERNAL_CATALOG_NAME, CTL_PLAIN, CTL_RANGER);
+
+ private static final String ADMIN_USER = "admin_user";
+ private static final String NODE_USER = "node_user";
+ private static final String LOCAL_USER = "local_user";
+ private static final String RANGER_USER = StubRangerPolicyEngine.ALLOWED_USER;
+ private static final String NOBODY = "nobody";
+ private static final List USERS =
+ ImmutableList.of(ADMIN_USER, NODE_USER, LOCAL_USER, RANGER_USER, NOBODY);
+
+ /** Every {@link PrivPredicate} constant, by name. A new constant lands here on its own and turns red. */
+ private static final Map ACTIONS = allPrivPredicates();
+
+ /** Metadata for the two external catalogs: only the names matter, nothing reads a row. */
+ public static class BaselineCatalogProvider implements TestCatalogProvider {
+ @Override
+ public Map>> getMetadata() {
+ return ImmutableMap.of(DB, ImmutableMap.of(TBL, ImmutableList.of(
+ new Column(COL_ALLOWED, PrimitiveType.INT),
+ new Column(COL_DENIED, PrimitiveType.INT))));
+ }
+ }
+
+ @Override
+ protected void runBeforeAll() throws Exception {
+ FeConstants.runningUnitTest = true;
+
+ createDatabase(DB);
+ useDatabase(DB);
+ createTable("create table " + TBL + " (" + COL_ALLOWED + " int, " + COL_DENIED + " int)"
+ + " distributed by hash(" + COL_ALLOWED + ") buckets 1"
+ + " properties(\"replication_num\" = \"1\");");
+
+ String provider = BaselineCatalogProvider.class.getName();
+ createCatalog("create catalog " + CTL_PLAIN + " properties("
+ + "\"type\"=\"test\", \"catalog_provider.class\"=\"" + provider + "\")");
+ createCatalog("create catalog " + CTL_RANGER + " properties("
+ + "\"type\"=\"test\", \"catalog_provider.class\"=\"" + provider + "\","
+ + "\"" + CatalogMgr.ACCESS_CONTROLLER_CLASS_PROP + "\"=\""
+ + StubRangerAccessControllerFactory.class.getName() + "\")");
+
+ for (String user : USERS) {
+ addUser(user, true);
+ }
+ grantPriv("GRANT ADMIN_PRIV ON *.*.* TO '" + ADMIN_USER + "'@'%'");
+ grantPriv("GRANT NODE_PRIV ON *.*.* TO '" + NODE_USER + "'@'%'");
+ // The same table-level grant on all three catalogs: on the Ranger-governed one it is ignored today,
+ // and that asymmetry is one of the things the baseline is here to hold still.
+ for (String catalog : CATALOGS) {
+ grantPriv("GRANT SELECT_PRIV ON " + catalog + "." + DB + "." + TBL
+ + " TO '" + LOCAL_USER + "'@'%'");
+ }
+ grantResourcePriv("GRANT USAGE_PRIV ON RESOURCE '" + StubRangerPolicyEngine.ALLOWED_RESOURCE
+ + "' TO '" + LOCAL_USER + "'@'%'");
+ grantResourcePriv("GRANT USAGE_PRIV ON WORKLOAD GROUP '"
+ + StubRangerPolicyEngine.ALLOWED_WORKLOAD_GROUP + "' TO '" + LOCAL_USER + "'@'%'");
+
+ createPolicy("CREATE ROW POLICY builtin_row_policy ON " + DB + "." + TBL
+ + " AS RESTRICTIVE TO " + LOCAL_USER + " USING (" + COL_ALLOWED + " = 1)");
+ }
+
+ @Test
+ public void accessDecisionsMatchRecordedBaseline() throws Exception {
+ List actual = renderSnapshot();
+ List expected = readBaseline();
+
+ if (!actual.equals(expected)) {
+ Files.createDirectories(ACTUAL_DUMP.getParent());
+ Files.write(ACTUAL_DUMP, actual, StandardCharsets.UTF_8);
+ }
+ Assertions.assertEquals(expected.size(), actual.size(),
+ "The access-control behaviour matrix changed shape.\n" + regenerationHint());
+ for (int i = 0; i < expected.size(); i++) {
+ Assertions.assertEquals(expected.get(i), actual.get(i),
+ "Access-control behaviour changed at line " + (i + 1) + ".\n" + regenerationHint());
+ }
+ }
+
+ private String regenerationHint() {
+ return "Every differing line is a privilege decision that flipped for some user - it is a behaviour"
+ + " change, not a test artifact. The current matrix was written to " + ACTUAL_DUMP.toAbsolutePath()
+ + "; only copy it over src/test/resources" + BASELINE_RESOURCE + " once each changed line is"
+ + " explained and intended.";
+ }
+
+ // ------------------------------------------------------------------------------------------------
+ // rendering
+ // ------------------------------------------------------------------------------------------------
+
+ private List renderSnapshot() {
+ List lines = new ArrayList<>();
+ lines.add("ACTIONS: " + String.join(",", ACTIONS.keySet()));
+ lines.add("USERS: " + String.join(",", USERS));
+ lines.add("");
+ AccessControllerManager manager = Env.getCurrentEnv().getAccessManager();
+ CatalogAccessController builtin = Deencapsulation.getField(manager, "defaultAccessController");
+
+ renderWithDefaultController(lines, manager, "builtin", builtin);
+ renderWithDefaultController(lines, manager, "ranger",
+ new RangerDorisAccessController(new StubRangerPolicyEngine()));
+ return lines;
+ }
+
+ /**
+ * Renders the whole matrix under one default controller, i.e. under one value of
+ * {@code fe.conf: access_controller_type}.
+ */
+ private void renderWithDefaultController(List lines, AccessControllerManager manager,
+ String label, CatalogAccessController defaultController) {
+ CatalogAccessController original = Deencapsulation.getField(manager, "defaultAccessController");
+ Map routes = Deencapsulation.getField(manager, "ctlToCtlAccessController");
+ Map savedRoutes = new HashMap<>(routes);
+ try {
+ Deencapsulation.setField(manager, "defaultAccessController", defaultController);
+ // Catalogs that fall back to the default keep a cached reference to the previous one; drop those
+ // so they resolve against the controller under test. The bound catalog keeps its own plugin.
+ routes.keySet().removeIf(ctl -> !CTL_RANGER.equals(ctl));
+
+ renderSystemScope(lines, manager, label);
+ for (String catalog : CATALOGS) {
+ renderCatalogScope(lines, manager, label, catalog);
+ }
+ } finally {
+ Deencapsulation.setField(manager, "defaultAccessController", original);
+ routes.clear();
+ routes.putAll(savedRoutes);
+ }
+ }
+
+ /** Global / resource / workload group / storage vault / compute group: always the default controller. */
+ private void renderSystemScope(List lines, AccessControllerManager manager, String label) {
+ addRows(lines, label, "-", "global",
+ (user, action) -> outcome(manager.checkGlobalPriv(user, action)));
+ addRows(lines, label, "-", "resource:res1",
+ (user, action) -> outcome(manager.checkResourcePriv(user, "res1", action)));
+ addRows(lines, label, "-", "workloadgrp:wg1",
+ (user, action) -> outcome(manager.checkWorkloadGroupPriv(user, "wg1", action)));
+ addRows(lines, label, "-", "workloadgrp:default",
+ (user, action) -> outcome(
+ manager.checkWorkloadGroupPriv(user, WorkloadGroupMgr.DEFAULT_GROUP_NAME, action)));
+ addRows(lines, label, "-", "vault:sv1",
+ (user, action) -> outcome(manager.checkStorageVaultPriv(user, "sv1", action)));
+ // checkCloudPriv is deliberately absent. Its built-in implementation reaches
+ // Role.checkCloudVirtualComputeGroup, which casts the system info service to the cloud subclass
+ // unconditionally, so on a non-cloud FE it throws for every caller that does not already hold a
+ // global privilege. Recording that would freeze a crash, not a decision.
+ }
+
+ private void renderCatalogScope(List lines, AccessControllerManager manager, String label,
+ String catalog) {
+ addRows(lines, label, catalog, "catalog",
+ (user, action) -> outcome(manager.checkCtlPriv(user, catalog, action)));
+ addRows(lines, label, catalog, "catalog:skipchk",
+ (user, action) -> outcome(
+ withSkipCatalogPrivCheck(() -> manager.checkCtlPriv(user, catalog, action))));
+ addRows(lines, label, catalog, "database",
+ (user, action) -> outcome(manager.checkDbPriv(user, catalog, DB, action)));
+ addRows(lines, label, catalog, "table",
+ (user, action) -> outcome(manager.checkTblPriv(user, catalog, DB, TBL, action)));
+ addRows(lines, label, catalog, "column:" + COL_ALLOWED,
+ (user, action) -> checkColumn(manager, user, catalog, COL_ALLOWED, action));
+ addRows(lines, label, catalog, "column:" + COL_DENIED,
+ (user, action) -> checkColumn(manager, user, catalog, COL_DENIED, action));
+
+ for (String user : USERS) {
+ List filters = manager.evalRowFilterPolicies(identity(user), catalog, DB, TBL);
+ lines.add(row(label, catalog, user, "rowfilter", filters.isEmpty() ? "-"
+ : filters.stream()
+ .map(spec -> "[" + spec.getPolicyIdent() + " | " + spec.getFilterSql()
+ + " | " + spec.getMergeType() + "]")
+ .collect(Collectors.joining(","))));
+ }
+ for (String column : ImmutableList.of(COL_ALLOWED, COL_DENIED)) {
+ for (String user : USERS) {
+ Optional mask =
+ manager.evalDataMaskPolicy(identity(user), catalog, DB, TBL, column);
+ lines.add(row(label, catalog, user, "mask:" + column,
+ mask.map(spec -> spec.getPolicyIdent() + " | " + spec.getMaskSql()).orElse("-")));
+ }
+ }
+ }
+
+ private void addRows(List lines, String label, String scope, String probe, Probe decision) {
+ for (String user : USERS) {
+ lines.add(row(label, scope, user, probe, verdict(identity(user), decision)));
+ }
+ }
+
+ /**
+ * {@code }, plus {@code / unsupported=} when the check refused to answer at all.
+ * The third outcome is not decoration: a column check only accepts privileges that carry SELECT or LOAD,
+ * so whether a caller gets a verdict or an IllegalStateException today depends on whether the manager's
+ * global short circuit answered before the built-in column check was ever reached.
+ */
+ private String verdict(UserIdentity user, Probe probe) {
+ List allowed = new ArrayList<>();
+ List unsupported = new ArrayList<>();
+ for (Map.Entry action : ACTIONS.entrySet()) {
+ switch (probe.evaluate(user, action.getValue())) {
+ case ALLOW:
+ allowed.add(action.getKey());
+ break;
+ case UNSUPPORTED:
+ unsupported.add(action.getKey());
+ break;
+ default:
+ break;
+ }
+ }
+ String verdict = allowed.isEmpty() ? "-" : String.join(",", allowed);
+ return unsupported.isEmpty() ? verdict : verdict + " / unsupported=" + String.join(",", unsupported);
+ }
+
+ private static String row(String label, String scope, String user, String probe, String verdict) {
+ return String.format("%-7s | %-10s | %-11s | %-17s | %s", label, scope, user, probe, verdict);
+ }
+
+ private enum Outcome { ALLOW, DENY, UNSUPPORTED }
+
+ private interface Probe {
+ Outcome evaluate(UserIdentity user, PrivPredicate action);
+ }
+
+ private static Outcome outcome(boolean allowed) {
+ return allowed ? Outcome.ALLOW : Outcome.DENY;
+ }
+
+ private Outcome checkColumn(AccessControllerManager manager, UserIdentity user, String catalog, String column,
+ PrivPredicate action) {
+ try {
+ manager.checkColumnsPriv(user, catalog, DB, TBL, ImmutableSet.of(column), action);
+ return Outcome.ALLOW;
+ } catch (UserException e) {
+ return Outcome.DENY;
+ } catch (IllegalStateException e) {
+ // Auth.checkColPriv rejects any predicate that carries neither SELECT nor LOAD.
+ return Outcome.UNSUPPORTED;
+ }
+ }
+
+ private boolean withSkipCatalogPrivCheck(BooleanSupplier body) {
+ boolean original = Config.skip_catalog_priv_check;
+ Config.skip_catalog_priv_check = true;
+ try {
+ return body.getAsBoolean();
+ } finally {
+ Config.skip_catalog_priv_check = original;
+ }
+ }
+
+ private static UserIdentity identity(String user) {
+ return UserIdentity.createAnalyzedUserIdentWithIp(user, "%");
+ }
+
+ private static Map allPrivPredicates() {
+ Map sorted = new TreeMap<>();
+ for (Field field : PrivPredicate.class.getDeclaredFields()) {
+ if (Modifier.isStatic(field.getModifiers()) && field.getType() == PrivPredicate.class) {
+ try {
+ sorted.put(field.getName(), (PrivPredicate) field.get(null));
+ } catch (IllegalAccessException e) {
+ throw new IllegalStateException("cannot read PrivPredicate." + field.getName(), e);
+ }
+ }
+ }
+ return new LinkedHashMap<>(sorted);
+ }
+
+ /** {@code GRANT ... ON RESOURCE/WORKLOAD GROUP}, which the shared helper does not run. */
+ private void grantResourcePriv(String sql) throws Exception {
+ LogicalPlan parsed = new NereidsParser().parseSingle(sql);
+ ((GrantResourcePrivilegeCommand) parsed).run(connectContext, new StmtExecutor(connectContext, sql));
+ }
+
+ private List readBaseline() throws IOException {
+ List lines = new ArrayList<>();
+ try (InputStream in = getClass().getResourceAsStream(BASELINE_RESOURCE)) {
+ Assertions.assertNotNull(in, "missing baseline resource " + BASELINE_RESOURCE);
+ BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ lines.add(line);
+ }
+ }
+ return lines;
+ }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/StubRangerAccessControllerFactory.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/StubRangerAccessControllerFactory.java
new file mode 100644
index 00000000000000..20ad286b412d93
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/StubRangerAccessControllerFactory.java
@@ -0,0 +1,38 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.catalog.authorizer.ranger.doris.RangerDorisAccessController;
+
+import java.util.Map;
+
+/**
+ * Binds a catalog to the production Ranger controller driven by {@link StubRangerPolicyEngine}, so a test can
+ * create a Ranger-governed catalog with {@code "access_controller.class"} without a Ranger server.
+ */
+public class StubRangerAccessControllerFactory implements AccessControllerFactory {
+ @Override
+ public String factoryIdentifier() {
+ return "stub-ranger-doris";
+ }
+
+ @Override
+ public CatalogAccessController createAccessController(Map prop) {
+ return new RangerDorisAccessController(new StubRangerPolicyEngine());
+ }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/StubRangerPolicyEngine.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/StubRangerPolicyEngine.java
new file mode 100644
index 00000000000000..52549ee09be31e
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/StubRangerPolicyEngine.java
@@ -0,0 +1,172 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.catalog.authorizer.ranger.doris.RangerDorisResource;
+
+import com.google.common.collect.Lists;
+import org.apache.ranger.plugin.policyengine.RangerAccessRequest;
+import org.apache.ranger.plugin.policyengine.RangerAccessRequest.ResourceMatchingScope;
+import org.apache.ranger.plugin.policyengine.RangerAccessResource;
+import org.apache.ranger.plugin.policyengine.RangerAccessResult;
+import org.apache.ranger.plugin.policyengine.RangerAccessResultProcessor;
+import org.apache.ranger.plugin.service.RangerBasePlugin;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * A Ranger policy engine whose answers are a pure function of (user, resource, access type), so that a
+ * behaviour baseline recorded against it stays byte-identical across runs and machines.
+ *
+ *
Only the policy engine is faked. The controller above it is the production
+ * {@link org.apache.doris.catalog.authorizer.ranger.doris.RangerDorisAccessController}, so its own
+ * global-then-catalog-then-database-then-table cascade, its SHOW-as-subtree probe and its privilege-bit
+ * decomposition are all exercised for real.
+ *
+ *
The whole policy set, and nothing else, is:
+ *
+ *
{@code ranger_user} may SELECT {@code .pdb.ptbl} and its column {@code pcol1};
+ *
{@code ranger_user} may USAGE the resource {@code res1}, workload group {@code wg1},
+ * storage vault {@code sv1} and compute group {@code cg1};
+ *
a subtree probe (the request Ranger sends for SHOW: no access type, SELF_OR_DESCENDANTS) succeeds for
+ * {@code ranger_user} on any ancestor of {@code pdb.ptbl};
+ *
{@code ranger_user} reading {@code pdb.ptbl} gets the row filter {@code pcol1 = 1} and a MASK_NULL
+ * mask on {@code pcol1};
+ *
every other user - including the ones holding built-in ADMIN_PRIV and NODE_PRIV - is denied
+ * everywhere, and gets no filter and no mask.
Global scope is not a Ranger catalog: it belongs to whichever controller {@code access_controller_type}
+ * installs, so that is who gets asked. With the built-in controller there this reproduces "an administrator
+ * of the cluster can reach a Ranger-governed catalog"; with Ranger installed globally, Ranger decides its
+ * own exemptions and the built-in grants stay out of it. Deciding this here rather than in the engine is
+ * what lets a third-party controller refuse the exemption outright.
+ *
+ *
Returns false without asking when this controller is itself the global-scope authority: the caller's
+ * own global check answers the same question one line later.
+ */
+ protected boolean grantedByGlobalScopeAuthority(UserIdentity currentUser, PrivPredicate wanted) {
+ CatalogAccessController authority = Env.getCurrentEnv().getAccessManager()
+ .getAccessControllerOrDefault(InternalCatalog.INTERNAL_CATALOG_NAME);
+ return authority != this && authority.checkGlobalPriv(currentUser, wanted);
+ }
+
protected static boolean checkRequestResult(RangerAccessRequestImpl request,
RangerAccessResult result, String name) {
if (result == null) {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/doris/RangerDorisAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/doris/RangerDorisAccessController.java
index 02420d2341cc3c..d02c9c7f2eb1a9 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/doris/RangerDorisAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/doris/RangerDorisAccessController.java
@@ -160,6 +160,9 @@ private boolean checkAnyPrivWithinCtl(UserIdentity currentUser, String ctl) {
@Override
public boolean checkDbPriv(UserIdentity currentUser, String ctl, String db, PrivPredicate wanted) {
+ if (grantedByGlobalScopeAuthority(currentUser, wanted)) {
+ return true;
+ }
PrivBitSet checkedPrivs = PrivBitSet.of();
if (checkGlobalPrivInternal(currentUser, wanted, checkedPrivs)
|| checkCtlPrivInternal(currentUser, ctl, wanted, checkedPrivs)
@@ -187,6 +190,9 @@ private boolean checkAnyPrivWithinDb(UserIdentity currentUser, String ctl, Strin
@Override
public boolean checkTblPriv(UserIdentity currentUser, String ctl, String db, String tbl, PrivPredicate wanted) {
+ if (grantedByGlobalScopeAuthority(currentUser, wanted)) {
+ return true;
+ }
PrivBitSet checkedPrivs = PrivBitSet.of();
if (checkGlobalPrivInternal(currentUser, wanted, checkedPrivs)
|| checkCtlPrivInternal(currentUser, ctl, wanted, checkedPrivs)
@@ -216,6 +222,9 @@ private boolean checkAnyPrivWithinTbl(UserIdentity currentUser, String ctl, Stri
@Override
public void checkColsPriv(UserIdentity currentUser, String ctl, String db, String tbl, Set cols,
PrivPredicate wanted) throws AuthorizationException {
+ if (grantedByGlobalScopeAuthority(currentUser, wanted)) {
+ return;
+ }
PrivBitSet checkedPrivs = PrivBitSet.of();
boolean hasTablePriv = checkGlobalPrivInternal(currentUser, wanted, checkedPrivs)
|| checkCtlPrivInternal(currentUser, ctl, wanted, checkedPrivs)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java
index 11cbdf20f1b943..3f2973ba678ad4 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java
@@ -210,6 +210,9 @@ public boolean checkCtlPriv(UserIdentity currentUser, String ctl, PrivPredicate
@Override
public boolean checkDbPriv(UserIdentity currentUser, String ctl, String db, PrivPredicate wanted) {
+ if (grantedByGlobalScopeAuthority(currentUser, wanted)) {
+ return true;
+ }
RangerHiveResource resource = new RangerHiveResource(HiveObjectType.DATABASE,
db);
return checkPrivilege(currentUser, convertToAccessType(wanted), resource);
@@ -217,6 +220,9 @@ public boolean checkDbPriv(UserIdentity currentUser, String ctl, String db, Priv
@Override
public boolean checkTblPriv(UserIdentity currentUser, String ctl, String db, String tbl, PrivPredicate wanted) {
+ if (grantedByGlobalScopeAuthority(currentUser, wanted)) {
+ return true;
+ }
RangerHiveResource resource = new RangerHiveResource(HiveObjectType.TABLE,
db, tbl);
return checkPrivilege(currentUser, convertToAccessType(wanted), resource);
@@ -225,6 +231,9 @@ public boolean checkTblPriv(UserIdentity currentUser, String ctl, String db, Str
@Override
public void checkColsPriv(UserIdentity currentUser, String ctl, String db, String tbl, Set cols,
PrivPredicate wanted) throws AuthorizationException {
+ if (grantedByGlobalScopeAuthority(currentUser, wanted)) {
+ return;
+ }
List resources = new ArrayList<>();
for (String col : cols) {
RangerHiveResource resource = new RangerHiveResource(HiveObjectType.COLUMN,
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
index 07ff5b46854393..347b91cf314be5 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
@@ -56,6 +56,11 @@
* SystemAccessController: for global level priv, resource priv and other Doris internal priv checking
* CatalogAccessController: for specified catalog's priv checking, can be customized.
* And using InternalCatalogAccessController as default.
+ *
+ *
It routes and nothing more: each check goes to the single controller that governs the resource, and that
+ * controller's answer is the answer. The manager establishes no privilege of its own beforehand and never
+ * combines two controllers' verdicts, so which policies apply to a resource is readable from which controller
+ * the catalog is bound to.
*/
public class AccessControllerManager {
private static final Logger LOG = LogManager.getLogger(AccessControllerManager.class);
@@ -320,14 +325,13 @@ private boolean shouldSkipCatalogPrivCheck(PrivPredicate wanted) {
}
public boolean checkCtlPriv(UserIdentity currentUser, String ctl, PrivPredicate wanted) {
- boolean hasGlobal = checkGlobalPriv(currentUser, wanted);
if (shouldSkipCatalogPrivCheck(wanted)) {
CatalogIf catalog = Env.getCurrentEnv().getCatalogMgr().getCatalog(ctl);
if (catalog == null) {
return false;
}
if (catalog.isInternalCatalog()) {
- return defaultAccessController.checkCtlPriv(hasGlobal, currentUser, ctl, wanted);
+ return defaultAccessController.checkCtlPriv(currentUser, ctl, wanted);
}
// If catalog not set access controller, use internal access controller
// otherwise, skip catalog priv check
@@ -335,13 +339,13 @@ public boolean checkCtlPriv(UserIdentity currentUser, String ctl, PrivPredicate
"");
if (Strings.isNullOrEmpty(className)) {
// not set access controller, use internal access controller
- return defaultAccessController.checkCtlPriv(hasGlobal, currentUser, ctl, wanted);
+ return defaultAccessController.checkCtlPriv(currentUser, ctl, wanted);
}
return true;
}
// for checking catalog priv, always use InternalAccessController.
// because catalog priv is only saved in InternalAccessController.
- return defaultAccessController.checkCtlPriv(hasGlobal, currentUser, ctl, wanted);
+ return defaultAccessController.checkCtlPriv(currentUser, ctl, wanted);
}
// ==== Database ====
@@ -350,8 +354,7 @@ public boolean checkDbPriv(ConnectContext ctx, String ctl, String db, PrivPredic
}
public boolean checkDbPriv(UserIdentity currentUser, String ctl, String db, PrivPredicate wanted) {
- boolean hasGlobal = checkGlobalPriv(currentUser, wanted);
- return getAccessControllerOrDefault(ctl).checkDbPriv(hasGlobal, currentUser, ctl, db, wanted);
+ return getAccessControllerOrDefault(ctl).checkDbPriv(currentUser, ctl, db, wanted);
}
// ==== Table ====
@@ -369,8 +372,7 @@ public boolean checkTblPriv(ConnectContext ctx, String qualifiedCtl,
}
public boolean checkTblPriv(UserIdentity currentUser, String ctl, String db, String tbl, PrivPredicate wanted) {
- boolean hasGlobal = checkGlobalPriv(currentUser, wanted);
- return getAccessControllerOrDefault(ctl).checkTblPriv(hasGlobal, currentUser, ctl, db, tbl, wanted);
+ return getAccessControllerOrDefault(ctl).checkTblPriv(currentUser, ctl, db, tbl, wanted);
}
// ==== Column ====
@@ -386,11 +388,9 @@ public void checkColumnsPriv(ConnectContext ctx, String ctl, String qualifiedDb,
public void checkColumnsPriv(UserIdentity currentUser, String
ctl, String qualifiedDb, String tbl, Set cols,
PrivPredicate wanted) throws UserException {
- boolean hasGlobal = checkGlobalPriv(currentUser, wanted);
CatalogAccessController accessController = getAccessControllerOrDefault(ctl);
long start = System.currentTimeMillis();
- accessController.checkColsPriv(hasGlobal, currentUser, ctl, qualifiedDb,
- tbl, cols, wanted);
+ accessController.checkColsPriv(currentUser, ctl, qualifiedDb, tbl, cols, wanted);
if (LOG.isDebugEnabled()) {
LOG.debug("checkColumnsPriv use {} mills, user: {}, ctl: {}, db: {}, table: {}, cols: {}",
System.currentTimeMillis() - start, currentUser, ctl, qualifiedDb, tbl, cols);
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/CatalogAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/CatalogAccessController.java
index deb144722b2c91..00ce013746e0ca 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/CatalogAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/CatalogAccessController.java
@@ -27,18 +27,20 @@
import java.util.Optional;
import java.util.Set;
+/**
+ * Decides access to the resources of one catalog.
+ *
+ *
A controller is asked only about the resources it governs, and its answer is final: nothing outside it
+ * grants first. In particular the engine no longer establishes a global privilege before routing, so an
+ * implementation that wants "holding the privilege globally is enough" has to say so itself - see
+ * {@link InternalAccessController}, which checks global privileges ahead of the fine grained ones, and
+ * {@link org.apache.doris.catalog.authorizer.ranger.RangerAccessController}, which defers to whichever
+ * controller owns global scope.
+ */
public interface CatalogAccessController {
default void close() {
}
- // ==== Catalog ====
- default boolean checkCtlPriv(boolean hasGlobal, UserIdentity currentUser, String ctl, PrivPredicate wanted) {
- if (hasGlobal) {
- return true;
- }
- return checkCtlPriv(currentUser, ctl, wanted);
- }
-
// ==== Global ====
boolean checkGlobalPriv(UserIdentity currentUser, PrivPredicate wanted);
@@ -46,36 +48,11 @@ default boolean checkCtlPriv(boolean hasGlobal, UserIdentity currentUser, String
boolean checkCtlPriv(UserIdentity currentUser, String ctl, PrivPredicate wanted);
// ==== Database ====
- default boolean checkDbPriv(boolean hasGlobal, UserIdentity currentUser, String ctl, String db,
- PrivPredicate wanted) {
- if (hasGlobal) {
- return true;
- }
- return checkDbPriv(currentUser, ctl, db, wanted);
- }
-
boolean checkDbPriv(UserIdentity currentUser, String ctl, String db, PrivPredicate wanted);
// ==== Table ====
- default boolean checkTblPriv(boolean hasGlobal, UserIdentity currentUser, String ctl, String db, String tbl,
- PrivPredicate wanted) {
- if (hasGlobal) {
- return true;
- }
- return checkTblPriv(currentUser, ctl, db, tbl, wanted);
- }
-
boolean checkTblPriv(UserIdentity currentUser, String ctl, String db, String tbl, PrivPredicate wanted);
- // ==== Column ====
- default void checkColsPriv(boolean hasGlobal, UserIdentity currentUser, String ctl, String db, String tbl,
- Set cols, PrivPredicate wanted) throws AuthorizationException {
- if (hasGlobal) {
- return;
- }
- checkColsPriv(currentUser, ctl, db, tbl, cols, wanted);
- }
-
// ==== Resource ====
boolean checkResourcePriv(UserIdentity currentUser, String resourceName, PrivPredicate wanted);
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAccessController.java
index 51c6ae9f1203e9..333cb8e6983fb3 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAccessController.java
@@ -34,6 +34,14 @@
import java.util.Optional;
import java.util.Set;
+/**
+ * The privilege model Doris ships with: users, roles and {@code GRANT} statements.
+ *
+ *
Every scoped check answers "globally, then at this scope". That order is this implementation's own
+ * decision, not something the engine arranges for it, and it is not redundant with the checks {@link Auth}
+ * runs per role: {@link Auth} refuses NODE privileges below global level, so a caller holding only global
+ * NODE_PRIV is granted by the global check here and by nothing else.
+ */
public class InternalAccessController implements CatalogAccessController {
private Auth auth;
@@ -48,22 +56,25 @@ public boolean checkGlobalPriv(UserIdentity currentUser, PrivPredicate wanted) {
@Override
public boolean checkCtlPriv(UserIdentity currentUser, String ctl, PrivPredicate wanted) {
- return auth.checkCtlPriv(currentUser, ctl, wanted);
+ return checkGlobalPriv(currentUser, wanted) || auth.checkCtlPriv(currentUser, ctl, wanted);
}
@Override
public boolean checkDbPriv(UserIdentity currentUser, String ctl, String db, PrivPredicate wanted) {
- return auth.checkDbPriv(currentUser, ctl, db, wanted);
+ return checkGlobalPriv(currentUser, wanted) || auth.checkDbPriv(currentUser, ctl, db, wanted);
}
@Override
public boolean checkTblPriv(UserIdentity currentUser, String ctl, String db, String tbl, PrivPredicate wanted) {
- return auth.checkTblPriv(currentUser, ctl, db, tbl, wanted);
+ return checkGlobalPriv(currentUser, wanted) || auth.checkTblPriv(currentUser, ctl, db, tbl, wanted);
}
@Override
public void checkColsPriv(UserIdentity currentUser, String ctl, String db, String tbl, Set cols,
PrivPredicate wanted) throws AuthorizationException {
+ if (checkGlobalPriv(currentUser, wanted)) {
+ return;
+ }
auth.checkColsPriv(currentUser, ctl, db, tbl, cols, wanted);
}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/RangerGlobalScopeDeferenceTest.java b/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/RangerGlobalScopeDeferenceTest.java
new file mode 100644
index 00000000000000..a47e38e8d40372
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/RangerGlobalScopeDeferenceTest.java
@@ -0,0 +1,126 @@
+// 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.doris.catalog.authorizer.ranger;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.authorizer.ranger.doris.RangerDorisAccessController;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.mysql.privilege.AccessControllerManager;
+import org.apache.doris.mysql.privilege.Auth;
+import org.apache.doris.mysql.privilege.CatalogAccessController;
+import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.mysql.privilege.StubRangerPolicyEngine;
+
+import org.apache.ranger.plugin.policyengine.RangerAccessRequest;
+import org.apache.ranger.plugin.policyengine.RangerAccessResult;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BooleanSupplier;
+
+/**
+ * A catalog bound to Ranger still lets through whoever holds the privilege at global scope.
+ *
+ *
Global scope is not something a Ranger service knows about; it belongs to the controller named by
+ * {@code access_controller_type}. Honouring it is why a cluster administrator can still reach a Ranger-governed
+ * catalog after the engine stopped establishing privileges on a plugin's behalf. It is the plugin's own choice,
+ * so it is tested on the plugin, and a plugin that declines it stays a legal plugin.
+ */
+public class RangerGlobalScopeDeferenceTest {
+ private static final UserIdentity ADMIN = UserIdentity.createAnalyzedUserIdentWithIp("admin_user", "%");
+ private static final UserIdentity RANGER_USER =
+ UserIdentity.createAnalyzedUserIdentWithIp(StubRangerPolicyEngine.ALLOWED_USER, "%");
+
+ /** Counts what actually reached the policy engine, so "answered without asking Ranger" is observable. */
+ private static final class CountingPolicyEngine extends StubRangerPolicyEngine {
+ private final AtomicInteger requests = new AtomicInteger();
+
+ @Override
+ public RangerAccessResult isAccessAllowed(RangerAccessRequest request) {
+ requests.incrementAndGet();
+ return super.isAccessAllowed(request);
+ }
+ }
+
+ private final CountingPolicyEngine engine = new CountingPolicyEngine();
+ private final RangerDorisAccessController controller = new RangerDorisAccessController(engine);
+
+ @Test
+ public void testGlobalScopeAuthorityGrantsWithoutConsultingRanger() {
+ CatalogAccessController authority = Mockito.mock(CatalogAccessController.class);
+ Mockito.when(authority.checkGlobalPriv(ADMIN, PrivPredicate.SELECT)).thenReturn(true);
+
+ boolean allowed = withGlobalScopeAuthority(authority,
+ () -> controller.checkTblPriv(ADMIN, "ctl", StubRangerPolicyEngine.ALLOWED_DB,
+ StubRangerPolicyEngine.ALLOWED_TABLE, PrivPredicate.SELECT));
+
+ Assert.assertTrue(allowed);
+ // Not merely an optimisation: the Ranger policy set denies this user everywhere, so had the request
+ // reached the engine the answer would have been the opposite one.
+ Assert.assertEquals(0, engine.requests.get());
+ }
+
+ @Test
+ public void testRangerDecidesWhenTheAuthorityGrantsNothingGlobally() {
+ CatalogAccessController authority = Mockito.mock(CatalogAccessController.class);
+
+ Assert.assertTrue(withGlobalScopeAuthority(authority,
+ () -> controller.checkTblPriv(RANGER_USER, "ctl", StubRangerPolicyEngine.ALLOWED_DB,
+ StubRangerPolicyEngine.ALLOWED_TABLE, PrivPredicate.SELECT)));
+ Assert.assertFalse(withGlobalScopeAuthority(authority,
+ () -> controller.checkTblPriv(RANGER_USER, "ctl", StubRangerPolicyEngine.ALLOWED_DB,
+ "other_tbl", PrivPredicate.SELECT)));
+ }
+
+ /**
+ * With Ranger installed globally, the controller is itself the authority and its own global check already
+ * answers the question - asking through the manager would evaluate the same Ranger policies a second time
+ * on every database, table and column check.
+ */
+ @Test
+ public void testBeingTheAuthorityCostsNoExtraPolicyEvaluation() {
+ int asPlugin = requestsWhile(Mockito.mock(CatalogAccessController.class),
+ () -> controller.checkDbPriv(RANGER_USER, "ctl", "other_db", PrivPredicate.SELECT));
+ int asAuthority = requestsWhile(controller,
+ () -> controller.checkDbPriv(RANGER_USER, "ctl", "other_db", PrivPredicate.SELECT));
+
+ Assert.assertEquals(asPlugin, asAuthority);
+ }
+
+ private int requestsWhile(CatalogAccessController authority, BooleanSupplier check) {
+ engine.requests.set(0);
+ withGlobalScopeAuthority(authority, check);
+ return engine.requests.get();
+ }
+
+ /** Runs {@code check} against an FE whose {@code access_controller_type} resolves to {@code authority}. */
+ private boolean withGlobalScopeAuthority(CatalogAccessController authority, BooleanSupplier check) {
+ AccessControllerManager manager = new AccessControllerManager(new Auth());
+ Deencapsulation.setField(manager, "defaultAccessController", authority);
+ try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) {
+ Env env = Mockito.mock(Env.class);
+ mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+ Mockito.when(env.getAccessManager()).thenReturn(manager);
+ return check.getAsBoolean();
+ }
+ }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControlBehaviorBaselineTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControlBehaviorBaselineTest.java
index 4abeee55ee7c75..618cd120fbfbda 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControlBehaviorBaselineTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControlBehaviorBaselineTest.java
@@ -68,11 +68,12 @@
* Records, in one golden file, every decision {@link AccessControllerManager} makes over the matrix
* (resource kind x action x built-in/Ranger x privilege level of the caller).
*
- *
Why this exists. Two reworks are queued behind it: dropping the {@code hasGlobal} argument from
- * the controller interface (moving "global first, then fine grained" inside the built-in implementation), and
- * collapsing the manager into pure routing with no cross-plugin OR. Both are supposed to change structure and
- * nothing else. "Nothing else" is only checkable against a recording made before the change, so this file is
- * that recording: after either rework, {@code git diff} on the baseline must be empty.
+ *
Why this exists. It was recorded ahead of two reworks that were meant to change structure and
+ * nothing else - dropping the {@code hasGlobal} argument from the controller interface, and collapsing the
+ * manager into pure routing with no cross-plugin OR - because "nothing else" is only checkable against a
+ * recording made beforehand. Both have since landed against it unchanged. The rest of the plugin work moves
+ * the same decisions around further, so the rule stands: after a structural change, {@code git diff} on the
+ * baseline must be empty.
*
*
What is real and what is faked. The built-in half is entirely real - a real FE, real users, real
* {@code GRANT} statements, a real row policy - because a baseline built on hand-poked internal state would
@@ -89,11 +90,12 @@
* because Phase 0's contract is structure-only:
*
*
{@code node_user} holds only global NODE_PRIV, and {@link Auth} refuses NODE privileges at
- * catalog/database/table level - yet the manager's global short circuit lets OPERATOR through there;
- *
{@code admin_user} is denied by Ranger everywhere, yet passes on the Ranger-governed catalog: that is
- * the cross-plugin OR the rework must reproduce from inside the plugin;
+ * catalog/database/table level - yet the built-in controller's global check lets OPERATOR through
+ * there;
+ *
{@code admin_user} is denied by Ranger everywhere, yet passes on the Ranger-governed catalog, because
+ * the Ranger controller defers to whichever controller owns global scope;
*
a built-in per-table GRANT on a Ranger-governed catalog is ignored ({@code local_user} on
- * {@code ext_ranger}), because the OR only exists at the global level;
+ * {@code ext_ranger}), because that deference exists only at the global level;
*
the workload group named {@code normal} is allowed unconditionally, in both implementations;
*
with {@code skip_catalog_priv_check} on, a catalog bound to an external plugin answers SELECT/SHOW
* with a flat yes.
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControllerManagerTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControllerManagerTest.java
index a6b2514b43aa45..06d8ca6d56653d 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControllerManagerTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControllerManagerTest.java
@@ -62,7 +62,6 @@ public void testCheckCtlPrivSkipCatalogPrivCheckWithCustomAccessControllerForSel
UserIdentity userIdentity = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
Config.skip_catalog_priv_check = true;
- Mockito.when(defaultAccessController.checkGlobalPriv(Mockito.any(), Mockito.any())).thenReturn(false);
mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
Mockito.when(catalogMgr.getCatalog("custom_catalog")).thenReturn(catalog);
@@ -87,7 +86,6 @@ public void testCheckCtlPrivSkipCatalogPrivCheckWithCustomAccessControllerForSho
UserIdentity userIdentity = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
Config.skip_catalog_priv_check = true;
- Mockito.when(defaultAccessController.checkGlobalPriv(Mockito.any(), Mockito.any())).thenReturn(false);
mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
Mockito.when(catalogMgr.getCatalog("custom_catalog")).thenReturn(catalog);
@@ -112,9 +110,8 @@ public void testCheckCtlPrivSkipCatalogPrivCheckWithoutCustomAccessController()
UserIdentity userIdentity = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
Config.skip_catalog_priv_check = true;
- Mockito.when(defaultAccessController.checkGlobalPriv(Mockito.any(), Mockito.any())).thenReturn(false);
Mockito.when(defaultAccessController.checkCtlPriv(
- Mockito.anyBoolean(), Mockito.any(), Mockito.anyString(), Mockito.any())).thenReturn(false);
+ Mockito.any(), Mockito.anyString(), Mockito.any())).thenReturn(false);
mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
Mockito.when(catalogMgr.getCatalog("custom_catalog")).thenReturn(catalog);
@@ -137,9 +134,8 @@ public void testCheckCtlPrivCreateMustCheckDefaultAccessController() {
UserIdentity userIdentity = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
Config.skip_catalog_priv_check = true;
- Mockito.when(defaultAccessController.checkGlobalPriv(Mockito.any(), Mockito.any())).thenReturn(false);
Mockito.when(defaultAccessController.checkCtlPriv(
- Mockito.anyBoolean(), Mockito.any(), Mockito.anyString(), Mockito.any())).thenReturn(true);
+ Mockito.any(), Mockito.anyString(), Mockito.any())).thenReturn(true);
mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
Mockito.when(catalogMgr.getCatalog("not_exist_catalog")).thenReturn(null);
@@ -161,9 +157,8 @@ public void testCheckCtlPrivLoadMustCheckDefaultAccessController() {
UserIdentity userIdentity = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
Config.skip_catalog_priv_check = true;
- Mockito.when(defaultAccessController.checkGlobalPriv(Mockito.any(), Mockito.any())).thenReturn(false);
Mockito.when(defaultAccessController.checkCtlPriv(
- Mockito.anyBoolean(), Mockito.any(), Mockito.anyString(), Mockito.any())).thenReturn(false);
+ Mockito.any(), Mockito.anyString(), Mockito.any())).thenReturn(false);
mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
Mockito.when(catalogMgr.getCatalog("custom_catalog")).thenReturn(catalog);
@@ -187,7 +182,6 @@ public void testCheckCtlPrivSkipCatalogPrivCheckWhenCatalogNotExist() {
UserIdentity userIdentity = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
Config.skip_catalog_priv_check = true;
- Mockito.when(defaultAccessController.checkGlobalPriv(Mockito.any(), Mockito.any())).thenReturn(false);
mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
Mockito.when(catalogMgr.getCatalog("not_exist_catalog")).thenReturn(null);
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/CatalogAccessControllerTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/CatalogAccessControllerTest.java
deleted file mode 100644
index 58a18566f405cd..00000000000000
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/CatalogAccessControllerTest.java
+++ /dev/null
@@ -1,260 +0,0 @@
-// 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.doris.mysql.privilege;
-
-import org.apache.doris.analysis.ResourceTypeEnum;
-import org.apache.doris.analysis.UserIdentity;
-import org.apache.doris.authorization.DataMaskSpec;
-import org.apache.doris.authorization.RowFilterSpec;
-import org.apache.doris.common.AuthorizationException;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import org.junit.Assert;
-import org.junit.Test;
-
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-public class CatalogAccessControllerTest {
-
- private static class StubAccessController implements CatalogAccessController {
- final AtomicBoolean ctlPrivCalled = new AtomicBoolean(false);
- final AtomicBoolean dbPrivCalled = new AtomicBoolean(false);
- final AtomicBoolean tblPrivCalled = new AtomicBoolean(false);
- final AtomicBoolean colsPrivCalled = new AtomicBoolean(false);
-
- private final boolean ctlResult;
- private final boolean dbResult;
- private final boolean tblResult;
- private final boolean colsResult;
-
- StubAccessController() {
- this(false, false, false, false);
- }
-
- StubAccessController(boolean ctlResult, boolean dbResult, boolean tblResult) {
- this(ctlResult, dbResult, tblResult, false);
- }
-
- StubAccessController(boolean ctlResult, boolean dbResult, boolean tblResult, boolean colsResult) {
- this.ctlResult = ctlResult;
- this.dbResult = dbResult;
- this.tblResult = tblResult;
- this.colsResult = colsResult;
- }
-
- @Override
- public boolean checkGlobalPriv(UserIdentity currentUser, PrivPredicate wanted) {
- return false;
- }
-
- @Override
- public boolean checkCtlPriv(UserIdentity currentUser, String ctl, PrivPredicate wanted) {
- ctlPrivCalled.set(true);
- return ctlResult;
- }
-
- @Override
- public boolean checkDbPriv(UserIdentity currentUser, String ctl, String db, PrivPredicate wanted) {
- dbPrivCalled.set(true);
- return dbResult;
- }
-
- @Override
- public boolean checkTblPriv(UserIdentity currentUser, String ctl, String db, String tbl, PrivPredicate wanted) {
- tblPrivCalled.set(true);
- return tblResult;
- }
-
- @Override
- public void checkColsPriv(UserIdentity currentUser, String ctl, String db, String tbl,
- Set cols, PrivPredicate wanted) throws AuthorizationException {
- colsPrivCalled.set(true);
- if (!colsResult) {
- throw new AuthorizationException("denied");
- }
- }
-
- @Override
- public boolean checkResourcePriv(UserIdentity currentUser, String resourceName, PrivPredicate wanted) {
- return false;
- }
-
- @Override
- public boolean checkWorkloadGroupPriv(UserIdentity currentUser, String workloadGroupName,
- PrivPredicate wanted) {
- return false;
- }
-
- @Override
- public boolean checkCloudPriv(UserIdentity currentUser, String cloudName,
- PrivPredicate wanted, ResourceTypeEnum type) {
- return false;
- }
-
- @Override
- public boolean checkStorageVaultPriv(UserIdentity currentUser, String storageVaultName,
- PrivPredicate wanted) {
- return false;
- }
-
- @Override
- public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db,
- String tbl, String col) {
- return Optional.empty();
- }
-
- @Override
- public List evalRowFilterPolicies(UserIdentity currentUser, String ctl,
- String db, String tbl) {
- return ImmutableList.of();
- }
- }
-
- @Test
- public void testCheckCtlPrivShortCircuitOnHasGlobal() {
- StubAccessController controller = new StubAccessController();
- UserIdentity user = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- boolean result = controller.checkCtlPriv(true, user, "ctl", PrivPredicate.SELECT);
-
- Assert.assertTrue(result);
- Assert.assertFalse(controller.ctlPrivCalled.get());
- }
-
- @Test
- public void testCheckCtlPrivFallsThroughWithoutHasGlobal() {
- StubAccessController controller = new StubAccessController(true, false, false);
- UserIdentity user = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- boolean result = controller.checkCtlPriv(false, user, "ctl", PrivPredicate.SELECT);
-
- Assert.assertTrue(result);
- Assert.assertTrue(controller.ctlPrivCalled.get());
- }
-
- @Test
- public void testCheckCtlPrivFallsThroughAndReturnsFalse() {
- StubAccessController controller = new StubAccessController(false, false, false);
- UserIdentity user = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- boolean result = controller.checkCtlPriv(false, user, "ctl", PrivPredicate.SELECT);
-
- Assert.assertFalse(result);
- Assert.assertTrue(controller.ctlPrivCalled.get());
- }
-
- @Test
- public void testCheckDbPrivShortCircuitOnHasGlobal() {
- StubAccessController controller = new StubAccessController();
- UserIdentity user = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- boolean result = controller.checkDbPriv(true, user, "ctl", "db", PrivPredicate.SELECT);
-
- Assert.assertTrue(result);
- Assert.assertFalse(controller.dbPrivCalled.get());
- }
-
- @Test
- public void testCheckDbPrivFallsThroughWithoutHasGlobal() {
- StubAccessController controller = new StubAccessController(false, true, false);
- UserIdentity user = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- boolean result = controller.checkDbPriv(false, user, "ctl", "db", PrivPredicate.SELECT);
-
- Assert.assertTrue(result);
- Assert.assertTrue(controller.dbPrivCalled.get());
- }
-
- @Test
- public void testCheckDbPrivFallsThroughAndReturnsFalse() {
- StubAccessController controller = new StubAccessController(false, false, false);
- UserIdentity user = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- boolean result = controller.checkDbPriv(false, user, "ctl", "db", PrivPredicate.SELECT);
-
- Assert.assertFalse(result);
- Assert.assertTrue(controller.dbPrivCalled.get());
- }
-
- @Test
- public void testCheckTblPrivShortCircuitOnHasGlobal() {
- StubAccessController controller = new StubAccessController();
- UserIdentity user = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- boolean result = controller.checkTblPriv(true, user, "ctl", "db", "tbl", PrivPredicate.SELECT);
-
- Assert.assertTrue(result);
- Assert.assertFalse(controller.tblPrivCalled.get());
- }
-
- @Test
- public void testCheckTblPrivFallsThroughWithoutHasGlobal() {
- StubAccessController controller = new StubAccessController(false, false, true);
- UserIdentity user = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- boolean result = controller.checkTblPriv(false, user, "ctl", "db", "tbl", PrivPredicate.SELECT);
-
- Assert.assertTrue(result);
- Assert.assertTrue(controller.tblPrivCalled.get());
- }
-
- @Test
- public void testCheckTblPrivFallsThroughAndReturnsFalse() {
- StubAccessController controller = new StubAccessController(false, false, false);
- UserIdentity user = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- boolean result = controller.checkTblPriv(false, user, "ctl", "db", "tbl", PrivPredicate.SELECT);
-
- Assert.assertFalse(result);
- Assert.assertTrue(controller.tblPrivCalled.get());
- }
-
- @Test
- public void testCheckColsPrivShortCircuitOnHasGlobal() throws AuthorizationException {
- StubAccessController controller = new StubAccessController();
- UserIdentity user = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- controller.checkColsPriv(true, user, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT);
-
- Assert.assertFalse(controller.colsPrivCalled.get());
- }
-
- @Test
- public void testCheckColsPrivFallsThroughWithoutHasGlobal() throws AuthorizationException {
- StubAccessController controller = new StubAccessController(false, false, false, true);
- UserIdentity user = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- controller.checkColsPriv(false, user, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT);
-
- Assert.assertTrue(controller.colsPrivCalled.get());
- }
-
- @Test
- public void testCheckColsPrivFallsThroughAndThrows() {
- StubAccessController controller = new StubAccessController(false, false, false, false);
- UserIdentity user = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- Assert.assertThrows(AuthorizationException.class, () ->
- controller.checkColsPriv(false, user, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT));
- Assert.assertTrue(controller.colsPrivCalled.get());
- }
-}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/InternalAccessControllerTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/InternalAccessControllerTest.java
new file mode 100644
index 00000000000000..49d0d19f7368d5
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/InternalAccessControllerTest.java
@@ -0,0 +1,139 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.common.AuthorizationException;
+
+import com.google.common.collect.ImmutableSet;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+/**
+ * The built-in controller answers "globally, then at this scope".
+ *
+ *
Both halves of that sentence are load bearing. Answering globally first is what lets an administrator
+ * reach a resource no grant names, and it must happen before the scoped lookup rather than instead of
+ * it - the scoped lookups are the expensive ones, and one of them refuses to answer at all for privileges that
+ * only exist globally. The engine used to arrange this order on every controller's behalf; now each controller
+ * owns it, so these tests watch the built-in one keep it.
+ */
+public class InternalAccessControllerTest {
+ private static final UserIdentity USER = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
+
+ private final Auth auth = Mockito.mock(Auth.class);
+ private final InternalAccessController controller = new InternalAccessController(auth);
+
+ private void holdsGlobally(boolean granted) {
+ Mockito.when(auth.checkGlobalPriv(USER, PrivPredicate.SELECT)).thenReturn(granted);
+ }
+
+ @Test
+ public void testCatalogCheckIsSkippedWhenPrivilegeIsHeldGlobally() {
+ holdsGlobally(true);
+
+ Assert.assertTrue(controller.checkCtlPriv(USER, "ctl", PrivPredicate.SELECT));
+ Mockito.verify(auth, Mockito.never()).checkCtlPriv(Mockito.any(), Mockito.anyString(), Mockito.any());
+ }
+
+ @Test
+ public void testCatalogCheckDecidesWhenPrivilegeIsNotHeldGlobally() {
+ holdsGlobally(false);
+ Mockito.when(auth.checkCtlPriv(USER, "ctl", PrivPredicate.SELECT)).thenReturn(true);
+
+ Assert.assertTrue(controller.checkCtlPriv(USER, "ctl", PrivPredicate.SELECT));
+ Assert.assertFalse(controller.checkCtlPriv(USER, "other_ctl", PrivPredicate.SELECT));
+ }
+
+ @Test
+ public void testDatabaseCheckIsSkippedWhenPrivilegeIsHeldGlobally() {
+ holdsGlobally(true);
+
+ Assert.assertTrue(controller.checkDbPriv(USER, "ctl", "db", PrivPredicate.SELECT));
+ Mockito.verify(auth, Mockito.never())
+ .checkDbPriv(Mockito.any(), Mockito.anyString(), Mockito.anyString(), Mockito.any());
+ }
+
+ @Test
+ public void testDatabaseCheckDecidesWhenPrivilegeIsNotHeldGlobally() {
+ holdsGlobally(false);
+ Mockito.when(auth.checkDbPriv(USER, "ctl", "db", PrivPredicate.SELECT)).thenReturn(true);
+
+ Assert.assertTrue(controller.checkDbPriv(USER, "ctl", "db", PrivPredicate.SELECT));
+ Assert.assertFalse(controller.checkDbPriv(USER, "ctl", "other_db", PrivPredicate.SELECT));
+ }
+
+ @Test
+ public void testTableCheckIsSkippedWhenPrivilegeIsHeldGlobally() {
+ holdsGlobally(true);
+
+ Assert.assertTrue(controller.checkTblPriv(USER, "ctl", "db", "tbl", PrivPredicate.SELECT));
+ Mockito.verify(auth, Mockito.never()).checkTblPriv(
+ Mockito.any(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.any());
+ }
+
+ @Test
+ public void testTableCheckDecidesWhenPrivilegeIsNotHeldGlobally() {
+ holdsGlobally(false);
+ Mockito.when(auth.checkTblPriv(USER, "ctl", "db", "tbl", PrivPredicate.SELECT)).thenReturn(true);
+
+ Assert.assertTrue(controller.checkTblPriv(USER, "ctl", "db", "tbl", PrivPredicate.SELECT));
+ Assert.assertFalse(controller.checkTblPriv(USER, "ctl", "db", "other_tbl", PrivPredicate.SELECT));
+ }
+
+ @Test
+ public void testColumnCheckIsSkippedWhenPrivilegeIsHeldGlobally() throws Exception {
+ holdsGlobally(true);
+
+ controller.checkColsPriv(USER, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT);
+ Mockito.verify(auth, Mockito.never()).checkColsPriv(Mockito.any(), Mockito.anyString(),
+ Mockito.anyString(), Mockito.anyString(), Mockito.any(), Mockito.any());
+ }
+
+ @Test
+ public void testColumnCheckDecidesWhenPrivilegeIsNotHeldGlobally() throws Exception {
+ holdsGlobally(false);
+
+ controller.checkColsPriv(USER, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT);
+ Mockito.verify(auth).checkColsPriv(USER, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT);
+ }
+
+ @Test
+ public void testColumnDenialIsReportedWhenPrivilegeIsNotHeldGlobally() throws Exception {
+ holdsGlobally(false);
+ Mockito.doThrow(new AuthorizationException("denied")).when(auth).checkColsPriv(
+ USER, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT);
+
+ Assert.assertThrows(AuthorizationException.class, () -> controller.checkColsPriv(
+ USER, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT));
+ }
+
+ /**
+ * A caller holding only global NODE_PRIV is why the global check has to run first instead of being folded
+ * into the scoped one: {@link Auth} refuses NODE privileges below global level, so the scoped lookup would
+ * turn the administrator away.
+ */
+ @Test
+ public void testGloballyHeldNodePrivilegeIsNotRefusedByTheScopedCheck() {
+ Mockito.when(auth.checkGlobalPriv(USER, PrivPredicate.OPERATOR)).thenReturn(true);
+ Mockito.when(auth.checkDbPriv(USER, "ctl", "db", PrivPredicate.OPERATOR)).thenReturn(false);
+
+ Assert.assertTrue(controller.checkDbPriv(USER, "ctl", "db", PrivPredicate.OPERATOR));
+ }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/StubRangerPolicyEngine.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/StubRangerPolicyEngine.java
index 52549ee09be31e..57b64c2e1d6ca6 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/StubRangerPolicyEngine.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/StubRangerPolicyEngine.java
@@ -52,9 +52,10 @@
* everywhere, and gets no filter and no mask.
*
*
- *
Denying the built-in admin is the point: wherever the baseline still records ALLOW for that user on a
- * Ranger-governed resource, the decision provably came from the engine's cross-plugin OR rather than from
- * Ranger, which is exactly the behaviour the routing rework has to reproduce.
+ *
Denying the built-in admin is the point: wherever the baseline records ALLOW for that user on a
+ * Ranger-governed resource, the decision provably did not come from Ranger. It comes from the controller
+ * deferring to whoever owns global scope, and pinning it here is what keeps that deference from being dropped
+ * or widened unnoticed.
*/
public class StubRangerPolicyEngine extends RangerBasePlugin {
public static final String ALLOWED_USER = "ranger_user";
From fdd035ca47126a28f5b26456464b6158a741e20b Mon Sep 17 00:00:00 2001
From: morningman
Date: Wed, 12 Aug 2026 16:04:31 +0800
Subject: [PATCH 06/22] [improvement](authorization) route every access check
through one decision
The manager offered nine ways to ask the same question, each naming its own
resource in loose strings and each picking a controller on its own. Which
controller answers for what was therefore spread across nine method bodies,
and the resource was described differently in every one of them.
There is one routing point now. decide(subject, resource, requirement) picks
the controller from the resource alone and returns its answer; the nine
entry points build a resource and call it. Adding a chain of sources later,
or an audit record of what was asked, is a change to that one function.
The resource and the requirement are neutral types, in fe-authorization-api,
so they can later cross into a plugin that must not see fe-core. Three things
about their shape were decided by what the code actually does:
- an action stands one-to-one with a privilege Doris grants. Folding cluster
usage and stage usage into plain usage would leave the built-in model - one
of the sources being asked - unable to tell which of its three privilege
bits the caller meant.
- a requirement carries a set of actions and whether one or all are needed,
rather than a single action. PrivPredicate is not a closed set of constants:
a GRANT statement builds one on the spot out of the privileges it names, and
those are the "all of" ones. Asking one action at a time would also undo the
Ranger controller's habit of remembering which privileges an outer resource
level already granted, turning one walk of the hierarchy into several.
- translating back returns the very constant the requirement came from. The
engine tells questions apart by comparing predicates against those constants
with ==, in Role and in both Ranger controllers, so an equal-but-different
object silently drops "may see this catalog" for every user who had no
explicit grant on it. The behaviour baseline caught exactly that, twelve
cells of it, before this was rewritten to canonicalize.
Columns keep an entry point of their own. Their answer has a different shape -
which column was refused, carried by an exception - and merging it into a
yes-or-no now would mean swallowing that.
Verified: behaviour baseline unchanged; 67 tests in 10 classes, no failures;
checkstyle clean. Mutation: making a GRANT statement's "all of these
privileges" read as "any of them" leaves the baseline green - it only
enumerates the OR-shaped constants - and turns the round trip tests red, which
is why they exist.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../doris/authorization/AccessAction.java | 58 +++
.../authorization/AccessRequirement.java | 131 +++++++
.../doris/authorization/ActionMatch.java | 32 ++
.../authorization/AuthorizedResource.java | 329 ++++++++++++++++++
.../doris/authorization/ResourceKind.java | 50 +++
.../authorization/AccessRequirementTest.java | 83 +++++
.../authorization/AuthorizedResourceTest.java | 108 ++++++
.../privilege/AccessControllerManager.java | 126 +++++--
.../mysql/privilege/AccessTranslation.java | 210 +++++++++++
.../privilege/AccessTranslationTest.java | 227 ++++++++++++
10 files changed, 1331 insertions(+), 23 deletions(-)
create mode 100644 fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessAction.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessRequirement.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/ActionMatch.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AuthorizedResource.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/ResourceKind.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AccessRequirementTest.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AuthorizedResourceTest.java
create mode 100644 fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessTranslation.java
create mode 100644 fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessTranslationTest.java
diff --git a/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessAction.java b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessAction.java
new file mode 100644
index 00000000000000..f6e776b5b7048d
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessAction.java
@@ -0,0 +1,58 @@
+// 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.doris.authorization;
+
+/**
+ * One privilege an authorization source can grant on a resource.
+ *
+ *
The constants stand one-to-one with the privileges Doris itself grants, and that is a requirement
+ * rather than a coincidence: the built-in privilege model is one of the authorization sources, so any
+ * folding here - "cluster usage and stage usage are both just usage", say - would leave it unable to tell
+ * which privilege it was actually asked about, and it would answer a different question than the caller
+ * asked.
+ *
+ *
There is deliberately no {@code SHOW} action. "May this subject see the object at all" is not a
+ * privilege anyone grants; it is the question "does the subject hold any of the privileges that
+ * imply visibility", which {@link AccessRequirement#anyOf} expresses.
+ */
+public enum AccessAction {
+ /** Cluster node operations. Only ever granted globally. */
+ NODE,
+ /** Administration of the cluster. */
+ ADMIN,
+ /** Granting privileges to others. */
+ GRANT,
+ /** Reading data. */
+ SELECT,
+ /** Writing data. */
+ LOAD,
+ /** Altering an object's definition. */
+ ALTER,
+ /** Creating an object. */
+ CREATE,
+ /** Dropping an object. */
+ DROP,
+ /** Using a resource or a workload group. */
+ USAGE,
+ /** Using a compute group. Distinct from {@link #USAGE}: it is a privilege of its own. */
+ CLUSTER_USAGE,
+ /** Using a stage. Distinct from {@link #USAGE}: it is a privilege of its own. */
+ STAGE_USAGE,
+ /** Reading a view's definition. */
+ SHOW_VIEW
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessRequirement.java b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessRequirement.java
new file mode 100644
index 00000000000000..83a828f761e7e2
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessRequirement.java
@@ -0,0 +1,131 @@
+// 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.doris.authorization;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * What a caller needs on a resource: a set of actions and how many of them must be held.
+ *
+ *
A requirement is asked as a whole rather than one action at a time, because the two sources of
+ * authorization decisions answer it as a whole. The built-in model tests a bit set in a single pass; the
+ * Ranger plugin walks the resource hierarchy asking about one privilege per request and remembers which ones
+ * an outer level already granted, so an already-answered privilege is never asked about twice. Handing them
+ * one action at a time would replace both with N independent evaluations of the same policies - the same
+ * answer, several times the cost. A plugin that has no such structure can still be written one action at a
+ * time and let the SPI's default implementation take this apart.
+ *
+ *
The action set is never empty. An empty requirement is not a harmless edge case: read as {@link
+ * ActionMatch#ALL} it is satisfied by everyone, which is a silent grant of everything the caller was
+ * checking.
+ */
+public final class AccessRequirement {
+
+ private final Set actions;
+ private final ActionMatch match;
+
+ private AccessRequirement(Set actions, ActionMatch match) {
+ this.actions = actions;
+ this.match = match;
+ }
+
+ /** A requirement satisfied by holding at least one of {@code actions}. */
+ public static AccessRequirement anyOf(AccessAction... actions) {
+ return of(toSet(actions), ActionMatch.ANY);
+ }
+
+ /** A requirement satisfied only by holding every one of {@code actions}. */
+ public static AccessRequirement allOf(AccessAction... actions) {
+ return of(toSet(actions), ActionMatch.ALL);
+ }
+
+ /** A requirement for a single action; the match type is irrelevant for one action. */
+ public static AccessRequirement of(AccessAction action) {
+ return of(EnumSet.of(Objects.requireNonNull(action, "action is required")), ActionMatch.ANY);
+ }
+
+ /**
+ * @param actions the actions in question, at least one
+ * @param match whether one of them suffices or all of them are needed
+ */
+ public static AccessRequirement of(Collection actions, ActionMatch match) {
+ Objects.requireNonNull(actions, "actions is required");
+ Objects.requireNonNull(match, "match is required");
+ if (actions.isEmpty()) {
+ throw new IllegalArgumentException("an access requirement must name at least one action");
+ }
+ EnumSet copy = EnumSet.noneOf(AccessAction.class);
+ for (AccessAction action : actions) {
+ copy.add(Objects.requireNonNull(action, "actions must not contain null"));
+ }
+ return new AccessRequirement(Collections.unmodifiableSet(copy), match);
+ }
+
+ public Set getActions() {
+ return actions;
+ }
+
+ public ActionMatch getMatch() {
+ return match;
+ }
+
+ /**
+ * Whether {@code granted} - everything the subject holds on the resource - satisfies this requirement.
+ * Provided so that every implementation reads the match type the same way instead of writing its own
+ * {@code containsAll} versus {@code disjoint} by hand.
+ */
+ public boolean isSatisfiedBy(Set granted) {
+ Objects.requireNonNull(granted, "granted is required");
+ return match == ActionMatch.ALL ? granted.containsAll(actions) : !Collections.disjoint(granted, actions);
+ }
+
+ private static EnumSet toSet(AccessAction... actions) {
+ Objects.requireNonNull(actions, "actions is required");
+ EnumSet set = EnumSet.noneOf(AccessAction.class);
+ for (AccessAction action : actions) {
+ set.add(Objects.requireNonNull(action, "actions must not contain null"));
+ }
+ return set;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof AccessRequirement)) {
+ return false;
+ }
+ AccessRequirement that = (AccessRequirement) o;
+ return match == that.match && actions.equals(that.actions);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(actions, match);
+ }
+
+ @Override
+ public String toString() {
+ return match + actions.toString();
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/ActionMatch.java b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/ActionMatch.java
new file mode 100644
index 00000000000000..f12bafc87a988a
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/ActionMatch.java
@@ -0,0 +1,32 @@
+// 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.doris.authorization;
+
+/**
+ * How many of an {@link AccessRequirement}'s actions the subject must hold.
+ *
+ *
Both are in daily use and they are not interchangeable: reading a table requires
+ * {@link #ANY} of several privileges, whereas granting a privilege to someone else requires
+ * {@link #ALL} of "the privilege being granted" and "the right to grant".
+ */
+public enum ActionMatch {
+ /** The subject must hold at least one of the actions. */
+ ANY,
+ /** The subject must hold every one of the actions. */
+ ALL
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AuthorizedResource.java b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AuthorizedResource.java
new file mode 100644
index 00000000000000..f8e8f03657af18
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AuthorizedResource.java
@@ -0,0 +1,329 @@
+// 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.doris.authorization;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * The object an authorization decision is about.
+ *
+ *
Every variant is nested here and can only be built through the factory methods, so the set of things
+ * that can be asked about is closed: an implementation may switch on {@link #getKind()} and treat an
+ * unknown kind as a defect rather than as something to guess about. Names are carried as given - no
+ * lower-casing, no qualification - because whoever asks already resolved them and a second normalization
+ * here would decide access on a name nobody used.
+ */
+public abstract class AuthorizedResource {
+
+ private final ResourceKind kind;
+
+ private AuthorizedResource(ResourceKind kind) {
+ this.kind = kind;
+ }
+
+ public final ResourceKind getKind() {
+ return kind;
+ }
+
+ /** The cluster as a whole. */
+ public static Global global() {
+ return Global.INSTANCE;
+ }
+
+ public static Catalog catalog(String catalog) {
+ return new Catalog(catalog);
+ }
+
+ public static Database database(String catalog, String database) {
+ return new Database(catalog, database);
+ }
+
+ public static Table table(String catalog, String database, String table) {
+ return new Table(catalog, database, table);
+ }
+
+ public static Columns columns(String catalog, String database, String table, Set columns) {
+ return new Columns(catalog, database, table, columns);
+ }
+
+ public static Named resource(String name) {
+ return new Named(ResourceKind.RESOURCE, name);
+ }
+
+ public static Named workloadGroup(String name) {
+ return new Named(ResourceKind.WORKLOAD_GROUP, name);
+ }
+
+ public static Named storageVault(String name) {
+ return new Named(ResourceKind.STORAGE_VAULT, name);
+ }
+
+ /**
+ * A cloud object, reached through the cloud privilege path.
+ *
+ * @param kind one of the {@code CLOUD_*} kinds
+ */
+ public static Named cloud(ResourceKind kind, String name) {
+ Objects.requireNonNull(kind, "kind is required");
+ switch (kind) {
+ case CLOUD_GENERAL:
+ case CLOUD_COMPUTE_GROUP:
+ case CLOUD_STAGE:
+ case CLOUD_STORAGE_VAULT:
+ return new Named(kind, name);
+ default:
+ throw new IllegalArgumentException(kind + " is not a cloud resource kind");
+ }
+ }
+
+ /** The cluster as a whole; global privileges are the ones held on it. */
+ public static final class Global extends AuthorizedResource {
+ private static final Global INSTANCE = new Global();
+
+ private Global() {
+ super(ResourceKind.GLOBAL);
+ }
+
+ @Override
+ public String toString() {
+ return "global";
+ }
+ }
+
+ /** One catalog. */
+ public static final class Catalog extends AuthorizedResource {
+ private final String catalog;
+
+ private Catalog(String catalog) {
+ super(ResourceKind.CATALOG);
+ this.catalog = Objects.requireNonNull(catalog, "catalog is required");
+ }
+
+ public String getCatalog() {
+ return catalog;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Catalog)) {
+ return false;
+ }
+ return catalog.equals(((Catalog) o).catalog);
+ }
+
+ @Override
+ public int hashCode() {
+ return catalog.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return "catalog " + catalog;
+ }
+ }
+
+ /** One database of one catalog. */
+ public static final class Database extends AuthorizedResource {
+ private final String catalog;
+ private final String database;
+
+ private Database(String catalog, String database) {
+ super(ResourceKind.DATABASE);
+ this.catalog = Objects.requireNonNull(catalog, "catalog is required");
+ this.database = Objects.requireNonNull(database, "database is required");
+ }
+
+ public String getCatalog() {
+ return catalog;
+ }
+
+ public String getDatabase() {
+ return database;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Database)) {
+ return false;
+ }
+ Database that = (Database) o;
+ return catalog.equals(that.catalog) && database.equals(that.database);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(catalog, database);
+ }
+
+ @Override
+ public String toString() {
+ return "database " + catalog + "." + database;
+ }
+ }
+
+ /** One table, view or any other relation. */
+ public static final class Table extends AuthorizedResource {
+ private final String catalog;
+ private final String database;
+ private final String table;
+
+ private Table(String catalog, String database, String table) {
+ super(ResourceKind.TABLE);
+ this.catalog = Objects.requireNonNull(catalog, "catalog is required");
+ this.database = Objects.requireNonNull(database, "database is required");
+ this.table = Objects.requireNonNull(table, "table is required");
+ }
+
+ public String getCatalog() {
+ return catalog;
+ }
+
+ public String getDatabase() {
+ return database;
+ }
+
+ public String getTable() {
+ return table;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Table)) {
+ return false;
+ }
+ Table that = (Table) o;
+ return catalog.equals(that.catalog) && database.equals(that.database) && table.equals(that.table);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(catalog, database, table);
+ }
+
+ @Override
+ public String toString() {
+ return "table " + catalog + "." + database + "." + table;
+ }
+ }
+
+ /** Named columns of one table, asked about together. */
+ public static final class Columns extends AuthorizedResource {
+ private final String catalog;
+ private final String database;
+ private final String table;
+ private final Set columns;
+
+ private Columns(String catalog, String database, String table, Set columns) {
+ super(ResourceKind.COLUMNS);
+ this.catalog = Objects.requireNonNull(catalog, "catalog is required");
+ this.database = Objects.requireNonNull(database, "database is required");
+ this.table = Objects.requireNonNull(table, "table is required");
+ Objects.requireNonNull(columns, "columns is required");
+ // Insertion order is kept: a denial names the first column that failed, and a set that reorders
+ // would make which column is reported depend on hashing.
+ this.columns = Collections.unmodifiableSet(new LinkedHashSet<>(columns));
+ }
+
+ public String getCatalog() {
+ return catalog;
+ }
+
+ public String getDatabase() {
+ return database;
+ }
+
+ public String getTable() {
+ return table;
+ }
+
+ public Set getColumns() {
+ return columns;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Columns)) {
+ return false;
+ }
+ Columns that = (Columns) o;
+ return catalog.equals(that.catalog) && database.equals(that.database)
+ && table.equals(that.table) && columns.equals(that.columns);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(catalog, database, table, columns);
+ }
+
+ @Override
+ public String toString() {
+ return "columns " + columns + " of " + catalog + "." + database + "." + table;
+ }
+ }
+
+ /** A system-wide object addressed by a single name: a resource, a workload group, a vault, a stage. */
+ public static final class Named extends AuthorizedResource {
+ private final String name;
+
+ private Named(ResourceKind kind, String name) {
+ super(kind);
+ this.name = Objects.requireNonNull(name, "name is required");
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Named)) {
+ return false;
+ }
+ Named that = (Named) o;
+ return getKind() == that.getKind() && name.equals(that.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(getKind(), name);
+ }
+
+ @Override
+ public String toString() {
+ return getKind() + " " + name;
+ }
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/ResourceKind.java b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/ResourceKind.java
new file mode 100644
index 00000000000000..c93152a89a6551
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/ResourceKind.java
@@ -0,0 +1,50 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.authorization;
+
+/**
+ * The kinds of object an authorization decision can be about.
+ *
+ *
The list is exhaustive by construction: it has one constant per question the engine can ask, so an
+ * implementation that switches over it and rejects the default branch cannot be silently bypassed by a new
+ * kind of object appearing.
+ *
+ *
The cloud kinds are separate from {@link #RESOURCE} and {@link #STORAGE_VAULT} rather than folded into
+ * them because they are separate questions today, answered against different privilege tables and with an
+ * extra fallback of their own. Folding them would change who is allowed in.
+ */
+public enum ResourceKind {
+ /** The cluster as a whole. */
+ GLOBAL,
+ CATALOG,
+ DATABASE,
+ TABLE,
+ /** Named columns of one table. */
+ COLUMNS,
+ /** A resource, e.g. an ODBC or S3 resource. */
+ RESOURCE,
+ WORKLOAD_GROUP,
+ STORAGE_VAULT,
+ /** A cloud resource that is none of the kinds below. */
+ CLOUD_GENERAL,
+ /** A compute group, historically named "cluster". */
+ CLOUD_COMPUTE_GROUP,
+ CLOUD_STAGE,
+ /** A storage vault reached through the cloud privilege path, distinct from {@link #STORAGE_VAULT}. */
+ CLOUD_STORAGE_VAULT
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AccessRequirementTest.java b/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AccessRequirementTest.java
new file mode 100644
index 00000000000000..8296aeb18a668a
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AccessRequirementTest.java
@@ -0,0 +1,83 @@
+// 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.doris.authorization;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.EnumSet;
+
+public class AccessRequirementTest {
+
+ @Test
+ public void testAnyIsSatisfiedByOneOfTheActions() {
+ AccessRequirement requirement = AccessRequirement.anyOf(AccessAction.SELECT, AccessAction.LOAD);
+
+ Assertions.assertTrue(requirement.isSatisfiedBy(EnumSet.of(AccessAction.LOAD)));
+ Assertions.assertFalse(requirement.isSatisfiedBy(EnumSet.of(AccessAction.DROP)));
+ Assertions.assertFalse(requirement.isSatisfiedBy(EnumSet.noneOf(AccessAction.class)));
+ }
+
+ @Test
+ public void testAllNeedsEveryAction() {
+ // The shape of the check a GRANT statement makes: holding one of the two is not enough.
+ AccessRequirement requirement = AccessRequirement.allOf(AccessAction.SELECT, AccessAction.GRANT);
+
+ Assertions.assertTrue(requirement.isSatisfiedBy(EnumSet.of(AccessAction.SELECT, AccessAction.GRANT)));
+ Assertions.assertFalse(requirement.isSatisfiedBy(EnumSet.of(AccessAction.SELECT)));
+ Assertions.assertFalse(requirement.isSatisfiedBy(EnumSet.of(AccessAction.GRANT)));
+ }
+
+ @Test
+ public void testRequirementWithoutActionsIsRefused() {
+ // Read as "all of", an empty requirement is satisfied by holding nothing at all, so a caller that
+ // built one by accident would be granting whatever it was checking.
+ Assertions.assertThrows(IllegalArgumentException.class, AccessRequirement::anyOf);
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> AccessRequirement.of(Collections.emptySet(), ActionMatch.ALL));
+ }
+
+ @Test
+ public void testActionsCannotBeChangedAfterwards() {
+ AccessRequirement requirement = AccessRequirement.of(AccessAction.SELECT);
+
+ Assertions.assertThrows(UnsupportedOperationException.class,
+ () -> requirement.getActions().add(AccessAction.ADMIN));
+ }
+
+ @Test
+ public void testMatchIsPartOfIdentity() {
+ AccessRequirement any = AccessRequirement.anyOf(AccessAction.SELECT, AccessAction.GRANT);
+ AccessRequirement all = AccessRequirement.allOf(AccessAction.SELECT, AccessAction.GRANT);
+
+ Assertions.assertNotEquals(any, all);
+ Assertions.assertEquals(any, AccessRequirement.anyOf(AccessAction.GRANT, AccessAction.SELECT));
+ Assertions.assertEquals(any.hashCode(),
+ AccessRequirement.anyOf(AccessAction.GRANT, AccessAction.SELECT).hashCode());
+ }
+
+ @Test
+ public void testNullsAreRefused() {
+ Assertions.assertThrows(NullPointerException.class, () -> AccessRequirement.of((AccessAction) null));
+ Assertions.assertThrows(NullPointerException.class,
+ () -> AccessRequirement.of(Collections.singleton(AccessAction.SELECT), null));
+ Assertions.assertThrows(NullPointerException.class,
+ () -> AccessRequirement.of(AccessAction.SELECT).isSatisfiedBy(null));
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AuthorizedResourceTest.java b/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AuthorizedResourceTest.java
new file mode 100644
index 00000000000000..3e582bc68ff81f
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AuthorizedResourceTest.java
@@ -0,0 +1,108 @@
+// 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.doris.authorization;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+public class AuthorizedResourceTest {
+
+ @Test
+ public void testEachFactoryReportsItsKind() {
+ Assertions.assertEquals(ResourceKind.GLOBAL, AuthorizedResource.global().getKind());
+ Assertions.assertEquals(ResourceKind.CATALOG, AuthorizedResource.catalog("ctl").getKind());
+ Assertions.assertEquals(ResourceKind.DATABASE, AuthorizedResource.database("ctl", "db").getKind());
+ Assertions.assertEquals(ResourceKind.TABLE, AuthorizedResource.table("ctl", "db", "tbl").getKind());
+ Assertions.assertEquals(ResourceKind.COLUMNS,
+ AuthorizedResource.columns("ctl", "db", "tbl", Set.of("c")).getKind());
+ Assertions.assertEquals(ResourceKind.RESOURCE, AuthorizedResource.resource("r").getKind());
+ Assertions.assertEquals(ResourceKind.WORKLOAD_GROUP, AuthorizedResource.workloadGroup("wg").getKind());
+ Assertions.assertEquals(ResourceKind.STORAGE_VAULT, AuthorizedResource.storageVault("v").getKind());
+ Assertions.assertEquals(ResourceKind.CLOUD_STAGE,
+ AuthorizedResource.cloud(ResourceKind.CLOUD_STAGE, "s").getKind());
+ }
+
+ @Test
+ public void testNameAloneDoesNotIdentifyASystemObject() {
+ // A workload group and a resource may well share a name; treating them as the same object would
+ // answer a question about one of them with the policies of the other.
+ Assertions.assertNotEquals(AuthorizedResource.resource("shared"),
+ AuthorizedResource.workloadGroup("shared"));
+ Assertions.assertNotEquals(AuthorizedResource.storageVault("shared"),
+ AuthorizedResource.cloud(ResourceKind.CLOUD_STORAGE_VAULT, "shared"));
+ }
+
+ @Test
+ public void testOnlyCloudKindsAreAcceptedAsCloudObjects() {
+ for (ResourceKind kind : ResourceKind.values()) {
+ if (kind.name().startsWith("CLOUD_")) {
+ Assertions.assertEquals(kind, AuthorizedResource.cloud(kind, "name").getKind());
+ } else {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> AuthorizedResource.cloud(kind, "name"));
+ }
+ }
+ }
+
+ @Test
+ public void testColumnsKeepTheOrderTheyWereAskedIn() {
+ // A refusal names the first column that failed. If the set reordered them, which column the user is
+ // told about would depend on hashing rather than on the query.
+ List asked = Arrays.asList("z_col", "a_col", "m_col");
+
+ AuthorizedResource.Columns columns =
+ AuthorizedResource.columns("ctl", "db", "tbl", new LinkedHashSet<>(asked));
+
+ Assertions.assertEquals(asked, new ArrayList<>(columns.getColumns()));
+ }
+
+ @Test
+ public void testColumnsCannotBeChangedAfterwards() {
+ AuthorizedResource.Columns columns =
+ AuthorizedResource.columns("ctl", "db", "tbl", Set.of("c1"));
+
+ Assertions.assertThrows(UnsupportedOperationException.class, () -> columns.getColumns().add("c2"));
+ }
+
+ @Test
+ public void testResourcesAreCompared() {
+ Assertions.assertEquals(AuthorizedResource.table("c", "d", "t"), AuthorizedResource.table("c", "d", "t"));
+ Assertions.assertEquals(AuthorizedResource.table("c", "d", "t").hashCode(),
+ AuthorizedResource.table("c", "d", "t").hashCode());
+ Assertions.assertNotEquals(AuthorizedResource.table("c", "d", "t"),
+ AuthorizedResource.table("c", "d", "other"));
+ Assertions.assertNotEquals(AuthorizedResource.database("c", "d"), AuthorizedResource.catalog("c"));
+ Assertions.assertSame(AuthorizedResource.global(), AuthorizedResource.global());
+ }
+
+ @Test
+ public void testMissingNamesAreRefused() {
+ Assertions.assertThrows(NullPointerException.class, () -> AuthorizedResource.catalog(null));
+ Assertions.assertThrows(NullPointerException.class, () -> AuthorizedResource.database("c", null));
+ Assertions.assertThrows(NullPointerException.class, () -> AuthorizedResource.table("c", "d", null));
+ Assertions.assertThrows(NullPointerException.class,
+ () -> AuthorizedResource.columns("c", "d", "t", null));
+ Assertions.assertThrows(NullPointerException.class, () -> AuthorizedResource.resource(null));
+ }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
index 347b91cf314be5..5fe2450da2a1b9 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
@@ -19,11 +19,14 @@
import org.apache.doris.analysis.ResourceTypeEnum;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AuthorizedResource;
import org.apache.doris.authorization.DataMaskSpec;
import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.catalog.AuthorizationInfo;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.info.TableNameInfo;
+import org.apache.doris.common.AuthorizationException;
import org.apache.doris.common.Config;
import org.apache.doris.common.UserException;
import org.apache.doris.common.util.ClassLoaderUtils;
@@ -302,13 +305,92 @@ public Auth getAuth() {
return this.auth;
}
+ /**
+ * Answers whether {@code subject} may act on {@code resource} as {@code requirement} demands.
+ *
+ *
This is the one place a check is routed. Which controller is asked follows from the resource
+ * alone - system-wide objects and catalog-level grants go to the controller
+ * {@code access_controller_type} installs, everything inside a catalog goes to the controller that
+ * catalog is bound to - and whatever it answers is the answer. Combining two controllers, or granting
+ * anything before asking, would have to happen here, and deliberately does not.
+ *
+ *
Columns are not decided here: see {@link #decideColumns}.
+ */
+ public boolean decide(UserIdentity subject, AuthorizedResource resource, AccessRequirement requirement) {
+ PrivPredicate wanted = AccessTranslation.privPredicateOf(requirement);
+ switch (resource.getKind()) {
+ case GLOBAL:
+ return systemScopeController().checkGlobalPriv(subject, wanted);
+ case CATALOG:
+ // Catalog level grants are only ever stored by the system scope controller, so it answers
+ // for every catalog, including those bound to a controller of their own.
+ return systemScopeController().checkCtlPriv(subject,
+ ((AuthorizedResource.Catalog) resource).getCatalog(), wanted);
+ case DATABASE: {
+ AuthorizedResource.Database database = (AuthorizedResource.Database) resource;
+ return controllerOf(database.getCatalog())
+ .checkDbPriv(subject, database.getCatalog(), database.getDatabase(), wanted);
+ }
+ case TABLE: {
+ AuthorizedResource.Table table = (AuthorizedResource.Table) resource;
+ return controllerOf(table.getCatalog()).checkTblPriv(subject, table.getCatalog(),
+ table.getDatabase(), table.getTable(), wanted);
+ }
+ case RESOURCE:
+ return systemScopeController()
+ .checkResourcePriv(subject, ((AuthorizedResource.Named) resource).getName(), wanted);
+ case WORKLOAD_GROUP:
+ return systemScopeController()
+ .checkWorkloadGroupPriv(subject, ((AuthorizedResource.Named) resource).getName(), wanted);
+ case STORAGE_VAULT:
+ return systemScopeController()
+ .checkStorageVaultPriv(subject, ((AuthorizedResource.Named) resource).getName(), wanted);
+ case CLOUD_GENERAL:
+ case CLOUD_COMPUTE_GROUP:
+ case CLOUD_STAGE:
+ case CLOUD_STORAGE_VAULT:
+ return systemScopeController().checkCloudPriv(subject,
+ ((AuthorizedResource.Named) resource).getName(), wanted,
+ AccessTranslation.cloudTypeOf(resource.getKind()));
+ case COLUMNS:
+ throw new IllegalArgumentException("column access is decided by decideColumns(), which"
+ + " reports which column was refused instead of a yes or no");
+ default:
+ throw new IllegalStateException("no route for resource kind " + resource.getKind());
+ }
+ }
+
+ /**
+ * Checks access to named columns, reporting the column that was refused rather than a yes or no.
+ *
+ *
Kept apart from {@link #decide} because the answer has a different shape, not because the routing
+ * differs: it is the same controller the table itself would be asked about.
+ */
+ public void decideColumns(UserIdentity subject, AuthorizedResource.Columns columns,
+ AccessRequirement requirement) throws AuthorizationException {
+ controllerOf(columns.getCatalog()).checkColsPriv(subject, columns.getCatalog(), columns.getDatabase(),
+ columns.getTable(), columns.getColumns(), AccessTranslation.privPredicateOf(requirement));
+ }
+
+ /**
+ * The controller governing everything that is not inside a catalog: global privileges, resources,
+ * workload groups, cloud objects, storage vaults - and catalog level grants, which only it stores.
+ */
+ private CatalogAccessController systemScopeController() {
+ return defaultAccessController;
+ }
+
+ private CatalogAccessController controllerOf(String ctl) {
+ return getAccessControllerOrDefault(ctl);
+ }
+
// ==== Global ====
public boolean checkGlobalPriv(ConnectContext ctx, PrivPredicate wanted) {
return checkGlobalPriv(ctx.getCurrentUserIdentity(), wanted);
}
public boolean checkGlobalPriv(UserIdentity currentUser, PrivPredicate wanted) {
- return defaultAccessController.checkGlobalPriv(currentUser, wanted);
+ return decide(currentUser, AuthorizedResource.global(), AccessTranslation.requirementOf(wanted));
}
// ==== Catalog ====
@@ -330,22 +412,16 @@ public boolean checkCtlPriv(UserIdentity currentUser, String ctl, PrivPredicate
if (catalog == null) {
return false;
}
- if (catalog.isInternalCatalog()) {
- return defaultAccessController.checkCtlPriv(currentUser, ctl, wanted);
+ // An external catalog bound to a controller of its own keeps no catalog level grants anywhere,
+ // so with the check switched off there is nobody left to ask. Every other catalog still goes
+ // through the normal route below.
+ String className = catalog.isInternalCatalog() ? ""
+ : (String) catalog.getProperties().getOrDefault(CatalogMgr.ACCESS_CONTROLLER_CLASS_PROP, "");
+ if (!Strings.isNullOrEmpty(className)) {
+ return true;
}
- // If catalog not set access controller, use internal access controller
- // otherwise, skip catalog priv check
- String className = (String) catalog.getProperties().getOrDefault(CatalogMgr.ACCESS_CONTROLLER_CLASS_PROP,
- "");
- if (Strings.isNullOrEmpty(className)) {
- // not set access controller, use internal access controller
- return defaultAccessController.checkCtlPriv(currentUser, ctl, wanted);
- }
- return true;
}
- // for checking catalog priv, always use InternalAccessController.
- // because catalog priv is only saved in InternalAccessController.
- return defaultAccessController.checkCtlPriv(currentUser, ctl, wanted);
+ return decide(currentUser, AuthorizedResource.catalog(ctl), AccessTranslation.requirementOf(wanted));
}
// ==== Database ====
@@ -354,7 +430,7 @@ public boolean checkDbPriv(ConnectContext ctx, String ctl, String db, PrivPredic
}
public boolean checkDbPriv(UserIdentity currentUser, String ctl, String db, PrivPredicate wanted) {
- return getAccessControllerOrDefault(ctl).checkDbPriv(currentUser, ctl, db, wanted);
+ return decide(currentUser, AuthorizedResource.database(ctl, db), AccessTranslation.requirementOf(wanted));
}
// ==== Table ====
@@ -372,7 +448,7 @@ public boolean checkTblPriv(ConnectContext ctx, String qualifiedCtl,
}
public boolean checkTblPriv(UserIdentity currentUser, String ctl, String db, String tbl, PrivPredicate wanted) {
- return getAccessControllerOrDefault(ctl).checkTblPriv(currentUser, ctl, db, tbl, wanted);
+ return decide(currentUser, AuthorizedResource.table(ctl, db, tbl), AccessTranslation.requirementOf(wanted));
}
// ==== Column ====
@@ -388,9 +464,9 @@ public void checkColumnsPriv(ConnectContext ctx, String ctl, String qualifiedDb,
public void checkColumnsPriv(UserIdentity currentUser, String
ctl, String qualifiedDb, String tbl, Set cols,
PrivPredicate wanted) throws UserException {
- CatalogAccessController accessController = getAccessControllerOrDefault(ctl);
long start = System.currentTimeMillis();
- accessController.checkColsPriv(currentUser, ctl, qualifiedDb, tbl, cols, wanted);
+ decideColumns(currentUser, AuthorizedResource.columns(ctl, qualifiedDb, tbl, cols),
+ AccessTranslation.requirementOf(wanted));
if (LOG.isDebugEnabled()) {
LOG.debug("checkColumnsPriv use {} mills, user: {}, ctl: {}, db: {}, table: {}, cols: {}",
System.currentTimeMillis() - start, currentUser, ctl, qualifiedDb, tbl, cols);
@@ -403,7 +479,8 @@ public boolean checkResourcePriv(ConnectContext ctx, String resourceName, PrivPr
}
public boolean checkResourcePriv(UserIdentity currentUser, String resourceName, PrivPredicate wanted) {
- return defaultAccessController.checkResourcePriv(currentUser, resourceName, wanted);
+ return decide(currentUser, AuthorizedResource.resource(resourceName),
+ AccessTranslation.requirementOf(wanted));
}
// ==== Cloud ====
@@ -413,7 +490,8 @@ public boolean checkCloudPriv(ConnectContext ctx, String cloudName, PrivPredicat
public boolean checkCloudPriv(UserIdentity currentUser, String cloudName,
PrivPredicate wanted, ResourceTypeEnum type) {
- return defaultAccessController.checkCloudPriv(currentUser, cloudName, wanted, type);
+ return decide(currentUser, AuthorizedResource.cloud(AccessTranslation.cloudKindOf(type), cloudName),
+ AccessTranslation.requirementOf(wanted));
}
public boolean checkStorageVaultPriv(ConnectContext ctx, String storageVaultName, PrivPredicate wanted) {
@@ -421,7 +499,8 @@ public boolean checkStorageVaultPriv(ConnectContext ctx, String storageVaultName
}
public boolean checkStorageVaultPriv(UserIdentity currentUser, String storageVaultName, PrivPredicate wanted) {
- return defaultAccessController.checkStorageVaultPriv(currentUser, storageVaultName, wanted);
+ return decide(currentUser, AuthorizedResource.storageVault(storageVaultName),
+ AccessTranslation.requirementOf(wanted));
}
public boolean checkWorkloadGroupPriv(ConnectContext ctx, String workloadGroupName, PrivPredicate wanted) {
@@ -429,7 +508,8 @@ public boolean checkWorkloadGroupPriv(ConnectContext ctx, String workloadGroupNa
}
public boolean checkWorkloadGroupPriv(UserIdentity currentUser, String workloadGroupName, PrivPredicate wanted) {
- return defaultAccessController.checkWorkloadGroupPriv(currentUser, workloadGroupName, wanted);
+ return decide(currentUser, AuthorizedResource.workloadGroup(workloadGroupName),
+ AccessTranslation.requirementOf(wanted));
}
// ==== Other ====
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessTranslation.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessTranslation.java
new file mode 100644
index 00000000000000..214578961e293c
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessTranslation.java
@@ -0,0 +1,210 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.analysis.CompoundPredicate.Operator;
+import org.apache.doris.analysis.ResourceTypeEnum;
+import org.apache.doris.authorization.AccessAction;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.ActionMatch;
+import org.apache.doris.authorization.ResourceKind;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.EnumMap;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * Translates between the vocabulary Doris uses internally and the neutral one authorization sources speak.
+ *
+ *
The translation is lossless in both directions and has to stay that way, because the built-in model is
+ * itself one of those sources: a requirement that reaches it must name exactly the privileges the caller
+ * asked about. That is why {@link AccessAction} keeps cluster usage and stage usage apart from plain usage
+ * even though a given plugin may well treat all three alike - the folding is the plugin's to do, not the
+ * engine's.
+ *
+ *
Object identity is preserved as well, and that is not a nicety: parts of the engine ask which
+ * question is being asked by comparing the predicate against a constant with {@code ==} - {@code Role} grants
+ * "may see this catalog" when any object under it is reachable, the Ranger controllers translate the
+ * predicate into their own access types the same way. Handing those a freshly built predicate that merely
+ * equals {@code PrivPredicate.SHOW} loses every one of those branches, silently and only for the users who
+ * depended on them. So a requirement that came from a constant translates back to that same constant.
+ */
+public final class AccessTranslation {
+
+ private static final Map ACTION_OF_PRIVILEGE = new EnumMap<>(Privilege.class);
+ private static final Map PRIVILEGE_OF_ACTION = new EnumMap<>(AccessAction.class);
+ /**
+ * The predicate constant each requirement came from, discovered reflectively so that a constant added
+ * later is covered the day it is added rather than the day someone remembers this map.
+ */
+ private static final Map CANONICAL_PREDICATES = new HashMap<>();
+
+ static {
+ ACTION_OF_PRIVILEGE.put(Privilege.NODE_PRIV, AccessAction.NODE);
+ ACTION_OF_PRIVILEGE.put(Privilege.ADMIN_PRIV, AccessAction.ADMIN);
+ ACTION_OF_PRIVILEGE.put(Privilege.GRANT_PRIV, AccessAction.GRANT);
+ ACTION_OF_PRIVILEGE.put(Privilege.SELECT_PRIV, AccessAction.SELECT);
+ ACTION_OF_PRIVILEGE.put(Privilege.LOAD_PRIV, AccessAction.LOAD);
+ ACTION_OF_PRIVILEGE.put(Privilege.ALTER_PRIV, AccessAction.ALTER);
+ ACTION_OF_PRIVILEGE.put(Privilege.CREATE_PRIV, AccessAction.CREATE);
+ ACTION_OF_PRIVILEGE.put(Privilege.DROP_PRIV, AccessAction.DROP);
+ ACTION_OF_PRIVILEGE.put(Privilege.USAGE_PRIV, AccessAction.USAGE);
+ ACTION_OF_PRIVILEGE.put(Privilege.CLUSTER_USAGE_PRIV, AccessAction.CLUSTER_USAGE);
+ ACTION_OF_PRIVILEGE.put(Privilege.STAGE_USAGE_PRIV, AccessAction.STAGE_USAGE);
+ ACTION_OF_PRIVILEGE.put(Privilege.SHOW_VIEW_PRIV, AccessAction.SHOW_VIEW);
+ // The retired bit indices carry the same meaning as the ones that replaced them, so they translate
+ // to the same action - the same normalization Role.upgradeToNewPrivilege() applies to stored grants.
+ ACTION_OF_PRIVILEGE.put(Privilege.SHOW_VIEW_PRIV_DEPRECATED, AccessAction.SHOW_VIEW);
+ ACTION_OF_PRIVILEGE.put(Privilege.SHOW_VIEW_PRIV_CLOUD_DEPRECATED, AccessAction.SHOW_VIEW);
+ ACTION_OF_PRIVILEGE.put(Privilege.CLUSTER_USAGE_PRIV_DEPRECATED, AccessAction.CLUSTER_USAGE);
+ ACTION_OF_PRIVILEGE.put(Privilege.STAGE_USAGE_PRIV_DEPRECATED, AccessAction.STAGE_USAGE);
+
+ PRIVILEGE_OF_ACTION.put(AccessAction.NODE, Privilege.NODE_PRIV);
+ PRIVILEGE_OF_ACTION.put(AccessAction.ADMIN, Privilege.ADMIN_PRIV);
+ PRIVILEGE_OF_ACTION.put(AccessAction.GRANT, Privilege.GRANT_PRIV);
+ PRIVILEGE_OF_ACTION.put(AccessAction.SELECT, Privilege.SELECT_PRIV);
+ PRIVILEGE_OF_ACTION.put(AccessAction.LOAD, Privilege.LOAD_PRIV);
+ PRIVILEGE_OF_ACTION.put(AccessAction.ALTER, Privilege.ALTER_PRIV);
+ PRIVILEGE_OF_ACTION.put(AccessAction.CREATE, Privilege.CREATE_PRIV);
+ PRIVILEGE_OF_ACTION.put(AccessAction.DROP, Privilege.DROP_PRIV);
+ PRIVILEGE_OF_ACTION.put(AccessAction.USAGE, Privilege.USAGE_PRIV);
+ PRIVILEGE_OF_ACTION.put(AccessAction.CLUSTER_USAGE, Privilege.CLUSTER_USAGE_PRIV);
+ PRIVILEGE_OF_ACTION.put(AccessAction.STAGE_USAGE, Privilege.STAGE_USAGE_PRIV);
+ PRIVILEGE_OF_ACTION.put(AccessAction.SHOW_VIEW, Privilege.SHOW_VIEW_PRIV);
+
+ // Sorted by name so that two constants naming the same privileges with the same match - today
+ // SHOW_RESOURCES and SHOW_WORKLOAD_GROUP - always resolve to the same one of the pair. They ask the
+ // same question, so either answers it, but which one it is must not depend on reflection order.
+ for (Map.Entry constant : new TreeMap<>(declaredPredicates()).entrySet()) {
+ CANONICAL_PREDICATES.putIfAbsent(requirementOf(constant.getValue()), constant.getValue());
+ }
+ }
+
+ private AccessTranslation() {
+ }
+
+ private static Map declaredPredicates() {
+ Map constants = new HashMap<>();
+ for (Field field : PrivPredicate.class.getDeclaredFields()) {
+ if (Modifier.isStatic(field.getModifiers()) && field.getType() == PrivPredicate.class) {
+ try {
+ constants.put(field.getName(), (PrivPredicate) field.get(null));
+ } catch (IllegalAccessException e) {
+ throw new IllegalStateException("cannot read PrivPredicate." + field.getName(), e);
+ }
+ }
+ }
+ return constants;
+ }
+
+ /** The action {@code privilege} stands for. */
+ @VisibleForTesting
+ static AccessAction actionOf(Privilege privilege) {
+ AccessAction action = ACTION_OF_PRIVILEGE.get(privilege);
+ if (action == null) {
+ throw new IllegalStateException("privilege " + privilege + " has no access action; a new"
+ + " privilege must be given one before it can be checked");
+ }
+ return action;
+ }
+
+ /** The privilege {@code action} stands for. */
+ @VisibleForTesting
+ static Privilege privilegeOf(AccessAction action) {
+ Privilege privilege = PRIVILEGE_OF_ACTION.get(action);
+ if (privilege == null) {
+ throw new IllegalStateException("access action " + action + " has no privilege");
+ }
+ return privilege;
+ }
+
+ /**
+ * The neutral form of what {@code wanted} asks for.
+ *
+ * @throws IllegalArgumentException if the predicate names no privilege at all. Such a predicate is
+ * satisfied by everyone when combined with AND and by no one when combined with OR, so passing
+ * one on would decide access by accident; no caller builds one today.
+ */
+ public static AccessRequirement requirementOf(PrivPredicate wanted) {
+ List privileges = wanted.getPrivs().toPrivilegeList();
+ EnumSet actions = EnumSet.noneOf(AccessAction.class);
+ for (Privilege privilege : privileges) {
+ actions.add(actionOf(privilege));
+ }
+ if (actions.isEmpty()) {
+ throw new IllegalArgumentException("privilege predicate names no privilege: " + wanted);
+ }
+ return AccessRequirement.of(actions, wanted.getOp() == Operator.AND ? ActionMatch.ALL : ActionMatch.ANY);
+ }
+
+ /**
+ * The privilege predicate {@code requirement} stands for; the constant it came from when there is one,
+ * so that the {@code ==} comparisons the engine still makes against those constants keep working.
+ */
+ public static PrivPredicate privPredicateOf(AccessRequirement requirement) {
+ PrivPredicate canonical = CANONICAL_PREDICATES.get(requirement);
+ if (canonical != null) {
+ return canonical;
+ }
+ PrivBitSet privileges = PrivBitSet.of();
+ for (AccessAction action : requirement.getActions()) {
+ privileges.set(privilegeOf(action).getIdx());
+ }
+ return PrivPredicate.of(privileges,
+ requirement.getMatch() == ActionMatch.ALL ? Operator.AND : Operator.OR);
+ }
+
+ /** The resource kind standing for a cloud object of {@code type}. */
+ public static ResourceKind cloudKindOf(ResourceTypeEnum type) {
+ switch (type) {
+ case GENERAL:
+ return ResourceKind.CLOUD_GENERAL;
+ case CLUSTER:
+ return ResourceKind.CLOUD_COMPUTE_GROUP;
+ case STAGE:
+ return ResourceKind.CLOUD_STAGE;
+ case STORAGE_VAULT:
+ return ResourceKind.CLOUD_STORAGE_VAULT;
+ default:
+ throw new IllegalStateException("no resource kind for cloud resource type " + type);
+ }
+ }
+
+ /** The cloud resource type {@code kind} stands for; only the cloud kinds have one. */
+ public static ResourceTypeEnum cloudTypeOf(ResourceKind kind) {
+ switch (kind) {
+ case CLOUD_GENERAL:
+ return ResourceTypeEnum.GENERAL;
+ case CLOUD_COMPUTE_GROUP:
+ return ResourceTypeEnum.CLUSTER;
+ case CLOUD_STAGE:
+ return ResourceTypeEnum.STAGE;
+ case CLOUD_STORAGE_VAULT:
+ return ResourceTypeEnum.STORAGE_VAULT;
+ default:
+ throw new IllegalArgumentException(kind + " is not a cloud resource kind");
+ }
+ }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessTranslationTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessTranslationTest.java
new file mode 100644
index 00000000000000..ffb950521bac2e
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessTranslationTest.java
@@ -0,0 +1,227 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.analysis.CompoundPredicate.Operator;
+import org.apache.doris.analysis.ResourceTypeEnum;
+import org.apache.doris.authorization.AccessAction;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.ActionMatch;
+import org.apache.doris.authorization.ResourceKind;
+import org.apache.doris.common.jmockit.Deencapsulation;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+/**
+ * The neutral vocabulary must say exactly what Doris's own vocabulary says - no more, no less.
+ *
+ *
This matters because the built-in privilege model is one of the sources being asked. A translation that
+ * merges two privileges into one action, or that turns "all of these" into "any of these", would hand it a
+ * question different from the one the caller asked and it would answer that different question truthfully.
+ * The round trip is therefore tested as an identity on the bits, not as "every constant has an entry": most
+ * predicates are not constants at all - {@code GRANT SELECT ON t TO u} builds one on the spot out of the
+ * privileges named in the statement, and it is the only place the "all of these" form occurs.
+ */
+public class AccessTranslationTest {
+
+ /** The privileges a caller can actually ask for; the rest are retired bit indices kept for old metadata. */
+ private static final List LIVE_PRIVILEGES = Arrays.asList(
+ Privilege.NODE_PRIV, Privilege.ADMIN_PRIV, Privilege.GRANT_PRIV, Privilege.SELECT_PRIV,
+ Privilege.LOAD_PRIV, Privilege.ALTER_PRIV, Privilege.CREATE_PRIV, Privilege.DROP_PRIV,
+ Privilege.USAGE_PRIV, Privilege.CLUSTER_USAGE_PRIV, Privilege.STAGE_USAGE_PRIV,
+ Privilege.SHOW_VIEW_PRIV);
+
+ @Test
+ public void testEveryPrivilegeHasAnAction() {
+ for (Privilege privilege : Privilege.values()) {
+ // Retired privileges are included on purpose: a stored grant read back in the mode that owns
+ // its bit index comes out as one of them, and a missing entry would only fail at run time.
+ Assert.assertNotNull("privilege " + privilege + " has no access action",
+ AccessTranslation.actionOf(privilege));
+ }
+ }
+
+ @Test
+ public void testEveryActionHasAPrivilege() {
+ for (AccessAction action : AccessAction.values()) {
+ Assert.assertNotNull("action " + action + " has no privilege",
+ AccessTranslation.privilegeOf(action));
+ }
+ }
+
+ @Test
+ public void testEveryPredicateConstantSurvivesTheRoundTrip() {
+ for (Map.Entry constant : allPrivPredicates().entrySet()) {
+ assertRoundTrips("PrivPredicate." + constant.getKey(), constant.getValue());
+ }
+ }
+
+ @Test
+ public void testEveryPredicateConstantTranslatesBackToItself() {
+ // Value equality is not enough. Role grants "may see this catalog" when any object under it is
+ // reachable, and the Ranger controllers pick their access type, by comparing the predicate against
+ // a constant with ==. A translation that returns an equal but different object drops those branches
+ // for every user who depended on them, which is what the behaviour baseline caught the first time
+ // this was written.
+ Map constants = allPrivPredicates();
+ Set askTheSameQuestion = constantsSharingAQuestion(constants);
+
+ for (Map.Entry constant : constants.entrySet()) {
+ PrivPredicate translated =
+ AccessTranslation.privPredicateOf(AccessTranslation.requirementOf(constant.getValue()));
+ if (askTheSameQuestion.contains(constant.getKey())) {
+ // Two constants naming the same privileges with the same match are interchangeable.
+ Assert.assertEquals(bitsOf(constant.getValue()), bitsOf(translated));
+ } else {
+ Assert.assertSame("PrivPredicate." + constant.getKey() + " must translate back to itself",
+ constant.getValue(), translated);
+ }
+ }
+ }
+
+ @Test
+ public void testEveryCombinationOfPrivilegesSurvivesTheRoundTrip() {
+ int combinations = 1 << LIVE_PRIVILEGES.size();
+ for (int mask = 1; mask < combinations; mask++) {
+ List privileges = new ArrayList<>();
+ for (int i = 0; i < LIVE_PRIVILEGES.size(); i++) {
+ if ((mask & (1 << i)) != 0) {
+ privileges.add(LIVE_PRIVILEGES.get(i));
+ }
+ }
+ assertRoundTrips("any of " + privileges, PrivPredicate.of(PrivBitSet.of(privileges), Operator.OR));
+ assertRoundTrips("all of " + privileges, PrivPredicate.of(PrivBitSet.of(privileges), Operator.AND));
+ }
+ }
+
+ @Test
+ public void testGrantStatementPredicateKeepsItsAllSemantics() {
+ // What GrantTablePrivilegeCommand builds: the privilege being granted plus the right to grant it,
+ // and the caller must hold both. Read as "any of", holding either one would be enough to hand out
+ // the other, which is the difference between an authorization model and none.
+ PrivPredicate wanted = PrivPredicate.of(
+ PrivBitSet.of(Privilege.SELECT_PRIV, Privilege.GRANT_PRIV), Operator.AND);
+
+ AccessRequirement requirement = AccessTranslation.requirementOf(wanted);
+
+ Assert.assertEquals(ActionMatch.ALL, requirement.getMatch());
+ Assert.assertEquals(EnumSet.of(AccessAction.SELECT, AccessAction.GRANT), requirement.getActions());
+ Assert.assertEquals(Operator.AND, AccessTranslation.privPredicateOf(requirement).getOp());
+ }
+
+ @Test
+ public void testVisibilityBecomesAnyOfThePrivilegesThatImplyIt() {
+ // "May this subject see the object" is not a privilege anyone grants; it is a question about
+ // holding any of several. If it ever translated to a single action, every source would have to
+ // invent a privilege named SHOW that nobody can be granted.
+ AccessRequirement requirement = AccessTranslation.requirementOf(PrivPredicate.SHOW);
+
+ Assert.assertEquals(ActionMatch.ANY, requirement.getMatch());
+ Assert.assertEquals(EnumSet.of(AccessAction.ADMIN, AccessAction.SELECT, AccessAction.LOAD,
+ AccessAction.ALTER, AccessAction.CREATE, AccessAction.DROP, AccessAction.SHOW_VIEW),
+ requirement.getActions());
+ }
+
+ @Test
+ public void testUsageFlavoursStayApart() {
+ // Ranger folds all three into one access type, and that is Ranger's business. Folding them here
+ // would leave the built-in model unable to tell which of its three privilege bits was asked about.
+ AccessRequirement requirement = AccessTranslation.requirementOf(PrivPredicate.USAGE);
+
+ Assert.assertTrue(requirement.getActions().containsAll(EnumSet.of(
+ AccessAction.USAGE, AccessAction.CLUSTER_USAGE, AccessAction.STAGE_USAGE)));
+ assertRoundTrips("PrivPredicate.USAGE", PrivPredicate.USAGE);
+ }
+
+ @Test
+ public void testPredicateNamingNoPrivilegeIsRefused() {
+ // Such a predicate is satisfied by everyone under "all of" - nothing to hold - and by no one under
+ // "any of". Passing it on would decide access by accident, so it fails where it is built instead.
+ PrivPredicate empty = PrivPredicate.of(PrivBitSet.of(), Operator.AND);
+
+ Assert.assertThrows(IllegalArgumentException.class, () -> AccessTranslation.requirementOf(empty));
+ }
+
+ @Test
+ public void testCloudResourceKindsRoundTrip() {
+ for (ResourceTypeEnum type : ResourceTypeEnum.values()) {
+ ResourceKind kind = AccessTranslation.cloudKindOf(type);
+ Assert.assertEquals("cloud resource type " + type, type, AccessTranslation.cloudTypeOf(kind));
+ }
+ }
+
+ @Test
+ public void testOnlyCloudKindsHaveACloudResourceType() {
+ Assert.assertThrows(IllegalArgumentException.class,
+ () -> AccessTranslation.cloudTypeOf(ResourceKind.TABLE));
+ }
+
+ private void assertRoundTrips(String what, PrivPredicate wanted) {
+ PrivPredicate translated = AccessTranslation.privPredicateOf(AccessTranslation.requirementOf(wanted));
+ Assert.assertEquals(what + ": privileges changed", bitsOf(wanted), bitsOf(translated));
+ Assert.assertEquals(what + ": match changed", wanted.getOp(), translated.getOp());
+ }
+
+ private long bitsOf(PrivPredicate predicate) {
+ return Deencapsulation.getField(predicate.getPrivs(), "set");
+ }
+
+ /** The constants some other constant is indistinguishable from: same privileges, same match. */
+ private static Set constantsSharingAQuestion(Map constants) {
+ Map> byQuestion = new HashMap<>();
+ for (Map.Entry constant : constants.entrySet()) {
+ byQuestion.computeIfAbsent(AccessTranslation.requirementOf(constant.getValue()),
+ key -> new ArrayList<>()).add(constant.getKey());
+ }
+ Set shared = new HashSet<>();
+ for (List names : byQuestion.values()) {
+ if (names.size() > 1) {
+ shared.addAll(names);
+ }
+ }
+ return shared;
+ }
+
+ private static Map allPrivPredicates() {
+ Map sorted = new TreeMap<>();
+ for (Field field : PrivPredicate.class.getDeclaredFields()) {
+ if (Modifier.isStatic(field.getModifiers()) && field.getType() == PrivPredicate.class) {
+ try {
+ sorted.put(field.getName(), (PrivPredicate) field.get(null));
+ } catch (IllegalAccessException e) {
+ throw new IllegalStateException("cannot read PrivPredicate." + field.getName(), e);
+ }
+ }
+ }
+ Assert.assertFalse("no PrivPredicate constants found", sorted.isEmpty());
+ return sorted;
+ }
+}
From 8f27f53f85b69c16198faada616c96e5164ec161 Mon Sep 17 00:00:00 2001
From: morningman
Date: Wed, 12 Aug 2026 18:34:02 +0800
Subject: [PATCH 07/22] [feat](authorization) make deciding access a contract a
plugin can implement
Until now "who decides access" and "how the decision is made" were the same
code. The manager routed to a controller, and that controller was an fe-core
interface with a method per kind of object, each answering with a boolean.
Nothing outside this repository could be that thing without also being part
of it.
There is a contract now, in fe-authorization-spi, and the built-in privilege
model is the first source implementing it. The manager keeps only the
routing - a table from resource kind to the source that governs it, and one
conversion of a refusal back into the boolean its callers still expect. The
switch over nine controller methods now lives inside each source, which is
where the knowledge of how that source answers belongs.
The contract carries only what the engine asks today: check a requirement,
check one action, ask for row filters, ask for column masks, and a
lifecycle. Filtering a list of objects, "has any privilege below here",
enumerating grants and declaring capabilities are designed and deliberately
absent - each has its consumer in a later phase, and the filtering signature
in particular should follow from the 45 call sites that will use it rather
than from guessing at them. An interface freezes when a release ships it,
not when it is written, so growing it before then costs nothing.
A refusal is thrown rather than returned. There is no third answer for a
source to express and no boolean for a caller to forget, and the reason -
which every caller currently invents for itself - has somewhere to live for
the first time. The exception records no stack trace and composes its
message only if something reads it: listing what a user may see refuses most
of the objects that exist, so refusing is the common path here, not the
exceptional one.
Three things were settled by what the code does rather than by design:
- the neutral subject carries the account and nothing else, and that is
lossless. The built-in model has to turn it back into a UserIdentity to
look up grants and row policies, and UserIdentity's own equality is
exactly user, host, and whether the host is a domain; the certificate
fields take part in authentication, never in a decision about what an
account may do. It reads the account with getUser() rather than
getQualifiedUser() - the two return the same field, the second
additionally insists the identity went through analysis, and callers exist
that check access with one that has not.
- roles are not on the subject. Ranger needs them, the built-in model never
does, and filtering what a user may see asks thousands of questions per
statement, so they are a lazy lookup on AuthorizationContext instead.
- controllers written against the older interface keep working, wrapped.
That interface is what a catalog's access_controller.class names, so
implementations of it exist outside this repository.
Wrapping breaks one thing that had to be repaired. The Ranger controllers
ask "am I myself the one governing global scope?" by object identity, so as
not to ask themselves a question they are about to answer. Reached through a
wrapper, that comparison silently stops matching - the behaviour is
identical, the same Ranger policies are simply evaluated a second time on
every database, table and column check. The manager answers that question
now, and sees through the wrapper to do it.
Two defects this introduced were caught by the tests written for it, and
neither of them is visible to the behaviour baseline:
- a refused column check answers by naming the column that failed, and that
message leaves the privilege model as an AuthorizationException and
reaches the caller as one again. Carrying the rendered form across the
middle instead of the bare wording puts that class's error-code prefix in
twice, changing what every denied column query prints.
- checking access with an identity that never went through analysis used to
work. Asking it for its qualified name on every check turned those callers
into IllegalStateException.
Verified: behaviour baseline unchanged; 428 tests across the 79 classes that
touch the decision path, no failures, the four skips pre-existing; 34 tests
in the two new modules; checkstyle clean. Mutations: reading "all of these
actions" as "any of them" turns three contract tests red; dropping "the host
is a domain" from the subject turns the round trip red; routing a storage
vault through the resource check turns the adapter's routing test red and
moves one baseline line; and making the global-scope identity comparison
stop seeing through the wrapper leaves the baseline green while turning red
only the test that counts policy evaluations - which is precisely why that
test counts them.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../doris/authorization/AccessContext.java | 57 +++++
.../authorization/AccessDeniedException.java | 108 ++++++++++
.../authorization/AuthorizedSubject.java | 95 +++++++++
.../AccessDeniedExceptionTest.java | 80 +++++++
.../authorization/AuthorizedSubjectTest.java | 58 +++++
.../fe-authorization-spi/pom.xml | 67 ++++++
.../spi/AuthorizationContext.java | 73 +++++++
.../spi/AuthorizationPlugin.java | 151 +++++++++++++
.../spi/AuthorizationPluginFactory.java | 48 +++++
.../spi/AuthorizationPluginContractTest.java | 162 ++++++++++++++
fe/fe-authorization/pom.xml | 6 +-
fe/fe-core/pom.xml | 5 +
.../ranger/RangerAccessController.java | 7 +-
.../hive/RangerHiveAccessController.java | 6 +-
.../privilege/AccessControllerManager.java | 200 ++++++++++--------
.../mysql/privilege/AccessTranslation.java | 31 +++
.../privilege/CatalogAccessController.java | 11 +-
.../privilege/ConnectionAccessContext.java | 63 ++++++
.../privilege/InternalAccessController.java | 140 ------------
.../InternalAuthorizationPlugin.java | 194 +++++++++++++++++
.../LegacyAccessControllerPlugin.java | 174 +++++++++++++++
.../RangerGlobalScopeDeferenceTest.java | 12 +-
.../AccessControlBehaviorBaselineTest.java | 11 +-
.../AccessControllerManagerTest.java | 12 +-
.../privilege/AccessTranslationTest.java | 47 ++++
.../InternalAccessControllerTest.java | 139 ------------
.../InternalAuthorizationPluginTest.java | 196 +++++++++++++++++
.../LegacyAccessControllerPluginTest.java | 189 +++++++++++++++++
.../privileges/TestCheckPrivileges.java | 3 +-
29 files changed, 1959 insertions(+), 386 deletions(-)
create mode 100644 fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessContext.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessDeniedException.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AuthorizedSubject.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AccessDeniedExceptionTest.java
create mode 100644 fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AuthorizedSubjectTest.java
create mode 100644 fe/fe-authorization/fe-authorization-spi/pom.xml
create mode 100644 fe/fe-authorization/fe-authorization-spi/src/main/java/org/apache/doris/authorization/spi/AuthorizationContext.java
create mode 100644 fe/fe-authorization/fe-authorization-spi/src/main/java/org/apache/doris/authorization/spi/AuthorizationPlugin.java
create mode 100644 fe/fe-authorization/fe-authorization-spi/src/main/java/org/apache/doris/authorization/spi/AuthorizationPluginFactory.java
create mode 100644 fe/fe-authorization/fe-authorization-spi/src/test/java/org/apache/doris/authorization/spi/AuthorizationPluginContractTest.java
create mode 100644 fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/ConnectionAccessContext.java
delete mode 100644 fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAccessController.java
create mode 100644 fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAuthorizationPlugin.java
create mode 100644 fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/LegacyAccessControllerPlugin.java
delete mode 100644 fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/InternalAccessControllerTest.java
create mode 100644 fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/InternalAuthorizationPluginTest.java
create mode 100644 fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/LegacyAccessControllerPluginTest.java
diff --git a/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessContext.java b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessContext.java
new file mode 100644
index 00000000000000..5218977d015988
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessContext.java
@@ -0,0 +1,57 @@
+// 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.doris.authorization;
+
+import java.util.Optional;
+
+/**
+ * What surrounds one access decision: the circumstances of the statement asking for it, as opposed to who is
+ * asking ({@link AuthorizedSubject}) or about what ({@link AuthorizedResource}).
+ *
+ *
This is where a decision that depends on more than the subject and the resource - the address the client
+ * connected from, the time of day, which statement is running - gets its input. A plugin that decides purely
+ * from grants ignores it; the built-in model does.
+ *
+ *
Every value is optional because the checks Doris makes do not all originate from a client statement:
+ * background jobs and internal maintenance ask the same questions with no connection behind them. Absent
+ * means "not applicable here", never "denied" - a plugin that requires a signal it did not get must say so
+ * rather than treat the gap as a decision.
+ *
+ *
An implementation is expected to be lazy: the engine hands one to every check, so producing a query id
+ * that nobody reads would be paid for on each of them.
+ */
+public interface AccessContext {
+
+ /** No circumstances known - the check does not come from a client statement. */
+ AccessContext NONE = new AccessContext() {
+ @Override
+ public String toString() {
+ return "no access context";
+ }
+ };
+
+ /** The address the client connected from. */
+ default Optional getClientIp() {
+ return Optional.empty();
+ }
+
+ /** The id of the query being planned, in the form the engine prints it in logs and profiles. */
+ default Optional getQueryId() {
+ return Optional.empty();
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessDeniedException.java b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessDeniedException.java
new file mode 100644
index 00000000000000..cb8922c23f779e
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessDeniedException.java
@@ -0,0 +1,108 @@
+// 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.doris.authorization;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Refusal of an access request, carrying why it was refused and who refused it.
+ *
+ *
A refusal is an answer, not a failure: deciding that a user may not see a table is the normal outcome of
+ * asking about a table the user has no grant on, and listing what a user may see asks that question about
+ * every object there is. So this exception is built for the common case rather than the exceptional one - it
+ * records no stack trace, and its message is composed only if somebody reads it. What makes it an
+ * exception at all is that a refusal must not be silently discardable: a plugin that returns without throwing
+ * has allowed the access, and there is no third answer to forget to handle.
+ *
+ *
Who refused, and why, is the part that has nowhere else to live. The engine's own callers each phrase
+ * their own error message today and so lose it; carrying it here is what later lets an error message name the
+ * plugin that governs the object and lets an audit record say the same.
+ */
+public class AccessDeniedException extends Exception {
+
+ private final AuthorizedSubject subject;
+ private final AuthorizedResource resource;
+ private final AccessRequirement requirement;
+ private final String deniedBy;
+ private final String explicitMessage;
+
+ private AccessDeniedException(AuthorizedSubject subject, AuthorizedResource resource,
+ AccessRequirement requirement, String deniedBy, String explicitMessage) {
+ // No cause, no suppression, and above all no stack trace: filling one in costs more than the whole
+ // decision that produced it, and this is thrown once per object a user may not see.
+ super(null, null, false, false);
+ this.subject = subject;
+ this.resource = resource;
+ this.requirement = requirement;
+ this.deniedBy = deniedBy;
+ this.explicitMessage = explicitMessage;
+ }
+
+ /**
+ * The refusal of {@code requirement} on {@code resource}.
+ *
+ * @param deniedBy name of the authorization source that refused, or null when it does not identify itself
+ */
+ public static AccessDeniedException of(AuthorizedSubject subject, AuthorizedResource resource,
+ AccessRequirement requirement, String deniedBy) {
+ Objects.requireNonNull(subject, "subject is required");
+ Objects.requireNonNull(resource, "resource is required");
+ Objects.requireNonNull(requirement, "requirement is required");
+ return new AccessDeniedException(subject, resource, requirement, deniedBy, null);
+ }
+
+ /**
+ * A refusal that already has its wording, used where the message is itself the answer - a column check
+ * refuses by naming the column that failed, and rephrasing it here would lose which one it was.
+ */
+ public static AccessDeniedException withMessage(String message, AuthorizedResource resource,
+ String deniedBy) {
+ return new AccessDeniedException(null, resource, null,
+ deniedBy, Objects.requireNonNull(message, "message is required"));
+ }
+
+ /** What was being accessed. */
+ public AuthorizedResource getResource() {
+ return resource;
+ }
+
+ /** What was required on it, absent when the refusal came with its own wording. */
+ public Optional getRequirement() {
+ return Optional.ofNullable(requirement);
+ }
+
+ /** The authorization source that refused, when it identifies itself. */
+ public Optional getDeniedBy() {
+ return Optional.ofNullable(deniedBy);
+ }
+
+ @Override
+ public String getMessage() {
+ if (explicitMessage != null) {
+ return explicitMessage;
+ }
+ StringBuilder message = new StringBuilder("Permission denied: user [").append(subject)
+ .append("] does not have privilege for [").append(requirement)
+ .append("] on [").append(resource).append("]");
+ if (deniedBy != null) {
+ message.append(", denied by [").append(deniedBy).append("]");
+ }
+ return message.toString();
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AuthorizedSubject.java b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AuthorizedSubject.java
new file mode 100644
index 00000000000000..4f7eb33aac81fd
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AuthorizedSubject.java
@@ -0,0 +1,95 @@
+// 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.doris.authorization;
+
+import java.util.Objects;
+
+/**
+ * Whose access is being decided.
+ *
+ *
A subject is a MySQL style account: a user name together with the host it connects from, which may be
+ * written either as an address pattern or as a domain name. All three parts identify the account - two
+ * accounts differing only in host are different accounts with different grants - so all three are carried
+ * here, and nothing else is: everything the engine knows about a connection beyond its account (the
+ * certificate it presented, for one) takes no part in deciding access.
+ *
+ *
Roles are deliberately absent. They belong to {@code AuthorizationContext}, where a plugin asks
+ * for them and pays for them only if it needs them. Resolving them up front would cost every check the price
+ * of the one plugin that wants them - the built-in model never does, and filtering what a user may see walks
+ * thousands of objects per statement.
+ */
+public final class AuthorizedSubject {
+
+ private final String user;
+ private final String host;
+ private final boolean domain;
+
+ private AuthorizedSubject(String user, String host, boolean domain) {
+ this.user = Objects.requireNonNull(user, "user is required");
+ this.host = Objects.requireNonNull(host, "host is required");
+ this.domain = domain;
+ }
+
+ /** An account whose host is an address or address pattern. */
+ public static AuthorizedSubject of(String user, String host) {
+ return new AuthorizedSubject(user, host, false);
+ }
+
+ /**
+ * @param domain whether {@code host} names a domain to be resolved rather than an address pattern
+ */
+ public static AuthorizedSubject of(String user, String host, boolean domain) {
+ return new AuthorizedSubject(user, host, domain);
+ }
+
+ /** The account name, qualified as the engine qualifies it. */
+ public String getUser() {
+ return user;
+ }
+
+ /** The host part of the account: an address pattern, or a domain name when {@link #isDomain()}. */
+ public String getHost() {
+ return host;
+ }
+
+ public boolean isDomain() {
+ return domain;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof AuthorizedSubject)) {
+ return false;
+ }
+ AuthorizedSubject that = (AuthorizedSubject) o;
+ return domain == that.domain && user.equals(that.user) && host.equals(that.host);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(user, host, domain);
+ }
+
+ @Override
+ public String toString() {
+ return "'" + user + "'@'" + host + "'";
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AccessDeniedExceptionTest.java b/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AccessDeniedExceptionTest.java
new file mode 100644
index 00000000000000..465cde13bfdafa
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AccessDeniedExceptionTest.java
@@ -0,0 +1,80 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.authorization;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class AccessDeniedExceptionTest {
+
+ private static final AuthorizedSubject SUBJECT = AuthorizedSubject.of("someone", "%");
+ private static final AuthorizedResource TABLE = AuthorizedResource.table("ctl", "db", "tbl");
+
+ /**
+ * Refusal is the ordinary answer, not an error: listing what a user may see asks about every object there
+ * is and is refused for most of them. Recording a stack trace for each one would cost more than the
+ * decisions themselves, so this exception must not have one - and nothing in the class can quietly
+ * reintroduce it.
+ */
+ @Test
+ public void testARefusalCarriesNoStackTrace() {
+ AccessDeniedException denied = AccessDeniedException.of(SUBJECT, TABLE,
+ AccessRequirement.of(AccessAction.SELECT), "somewhere");
+
+ Assertions.assertEquals(0, denied.getStackTrace().length);
+ }
+
+ @Test
+ public void testARefusalSaysWhoWasRefusedWhatAndByWhom() {
+ AccessDeniedException denied = AccessDeniedException.of(SUBJECT, TABLE,
+ AccessRequirement.of(AccessAction.SELECT), "ranger-doris");
+
+ Assertions.assertSame(TABLE, denied.getResource());
+ Assertions.assertEquals(AccessRequirement.of(AccessAction.SELECT), denied.getRequirement().orElse(null));
+ Assertions.assertEquals("ranger-doris", denied.getDeniedBy().orElse(null));
+ String message = denied.getMessage();
+ Assertions.assertTrue(message.contains("'someone'@'%'"), message);
+ Assertions.assertTrue(message.contains("SELECT"), message);
+ Assertions.assertTrue(message.contains("ctl.db.tbl"), message);
+ Assertions.assertTrue(message.contains("ranger-doris"), message);
+ }
+
+ /**
+ * Some refusals are already worded, and the wording is the answer - a column check refuses by naming the
+ * column that failed. Recomposing the message from the requirement would reduce "this column" to "these
+ * columns".
+ */
+ @Test
+ public void testAWordedRefusalIsReportedAsWritten() {
+ AccessDeniedException denied = AccessDeniedException.withMessage(
+ "Permission denied on column [salary]", TABLE, "default");
+
+ Assertions.assertEquals("Permission denied on column [salary]", denied.getMessage());
+ Assertions.assertFalse(denied.getRequirement().isPresent());
+ }
+
+ /** A source that does not name itself still produces a usable message. */
+ @Test
+ public void testAnAnonymousRefusalStillReads() {
+ AccessDeniedException denied = AccessDeniedException.of(SUBJECT, AuthorizedResource.global(),
+ AccessRequirement.of(AccessAction.ADMIN), null);
+
+ Assertions.assertFalse(denied.getDeniedBy().isPresent());
+ Assertions.assertTrue(denied.getMessage().contains("global"), denied.getMessage());
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AuthorizedSubjectTest.java b/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AuthorizedSubjectTest.java
new file mode 100644
index 00000000000000..12a0b5b4315c92
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/test/java/org/apache/doris/authorization/AuthorizedSubjectTest.java
@@ -0,0 +1,58 @@
+// 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.doris.authorization;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class AuthorizedSubjectTest {
+
+ /**
+ * All three parts identify the account. Two accounts differing only in where they connect from are
+ * different accounts holding different grants, and a subject that compared equal across that difference
+ * would let a source answer for the wrong one.
+ */
+ @Test
+ public void testAnAccountIsItsNameItsHostAndHowTheHostIsRead() {
+ AuthorizedSubject anywhere = AuthorizedSubject.of("user", "%");
+
+ Assertions.assertEquals(AuthorizedSubject.of("user", "%"), anywhere);
+ Assertions.assertEquals(AuthorizedSubject.of("user", "%").hashCode(), anywhere.hashCode());
+ Assertions.assertNotEquals(AuthorizedSubject.of("other", "%"), anywhere);
+ Assertions.assertNotEquals(AuthorizedSubject.of("user", "192.168.%"), anywhere);
+ Assertions.assertNotEquals(AuthorizedSubject.of("user", "%", true), anywhere);
+ }
+
+ @Test
+ public void testAHostReadAsADomainSaysSo() {
+ Assertions.assertFalse(AuthorizedSubject.of("user", "doris.example.com").isDomain());
+ Assertions.assertTrue(AuthorizedSubject.of("user", "doris.example.com", true).isDomain());
+ }
+
+ /** Printed the way an account is written in Doris, because it ends up in refusal messages. */
+ @Test
+ public void testAnAccountPrintsAsAnAccount() {
+ Assertions.assertEquals("'user'@'192.168.%'", AuthorizedSubject.of("user", "192.168.%").toString());
+ }
+
+ @Test
+ public void testAnIncompleteAccountIsRefused() {
+ Assertions.assertThrows(NullPointerException.class, () -> AuthorizedSubject.of(null, "%"));
+ Assertions.assertThrows(NullPointerException.class, () -> AuthorizedSubject.of("user", null));
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-spi/pom.xml b/fe/fe-authorization/fe-authorization-spi/pom.xml
new file mode 100644
index 00000000000000..ea51ffe4149c15
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-spi/pom.xml
@@ -0,0 +1,67 @@
+
+
+
+ 4.0.0
+
+ org.apache.doris
+ ${revision}
+ fe-authorization
+ ../pom.xml
+
+ fe-authorization-spi
+ jar
+ Doris FE Authorization SPI
+
+
+
+ org.apache.doris
+ fe-authorization-api
+ ${project.version}
+
+
+ org.apache.doris
+ fe-extension-spi
+ ${project.version}
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ **/*Test.java
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 17
+ 17
+
+
+
+
+
diff --git a/fe/fe-authorization/fe-authorization-spi/src/main/java/org/apache/doris/authorization/spi/AuthorizationContext.java b/fe/fe-authorization/fe-authorization-spi/src/main/java/org/apache/doris/authorization/spi/AuthorizationContext.java
new file mode 100644
index 00000000000000..fd0fde17bfa36d
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-spi/src/main/java/org/apache/doris/authorization/spi/AuthorizationContext.java
@@ -0,0 +1,73 @@
+// 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.doris.authorization.spi;
+
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AuthorizedResource;
+import org.apache.doris.authorization.AuthorizedSubject;
+
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * What the engine will tell a plugin that asks, so that a plugin can decide things it has no data of its own
+ * about.
+ *
+ *
The engine implements this and each plugin gets its own instance; everything on it is answered on
+ * demand, so a plugin that decides purely from its own policies never pays for any of it. That is the point
+ * of the shape: the alternative - handing every check a subject with its roles already resolved - would make
+ * every check pay for the one plugin that wanted them.
For an external source that keeps its own roles this is the bridge between the two: policies written
+ * against Doris roles can be evaluated because the engine, not the source, knows who holds them.
+ */
+ Set rolesOf(AuthorizedSubject subject);
+
+ /**
+ * Whether {@code subject} already holds {@code requirement} at instance scope - as decided by whoever
+ * governs instance scope, which may or may not be the plugin asking.
+ *
+ *
This is how a plugin honours "an administrator of this instance may reach what I govern" without the
+ * engine imposing it. Asking is a choice: a plugin that refuses to recognise any authority but its own
+ * simply never calls this, and one that does call it gets an answer from the same source that would
+ * answer a global check, so the exemption it grants matches what the instance actually considers
+ * administrative rather than assuming that is the built-in model.
+ *
+ *
Answers false when the plugin asking is itself the instance-scope authority: it would otherwise be
+ * asking itself a question it is about to answer anyway, at the price of evaluating the same policies
+ * twice.
+ */
+ boolean grantedByGlobalScopeAuthority(AuthorizedSubject subject, AccessRequirement requirement);
+
+ /**
+ * Who owns {@code resource}, when the engine records an owner for it.
+ *
+ *
Ownership is metadata, not a privilege: the engine stores it, and what it entitles the owner to is
+ * each plugin's own rule - all rights over the object, only the right to grant it away, or nothing.
+ * Empty today for every resource, the channel being here so that adding an owner to a kind of object is a
+ * change in the engine rather than in this contract.
+ */
+ default Optional ownerOf(AuthorizedResource resource) {
+ return Optional.empty();
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-spi/src/main/java/org/apache/doris/authorization/spi/AuthorizationPlugin.java b/fe/fe-authorization/fe-authorization-spi/src/main/java/org/apache/doris/authorization/spi/AuthorizationPlugin.java
new file mode 100644
index 00000000000000..444f5f7dc04f03
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-spi/src/main/java/org/apache/doris/authorization/spi/AuthorizationPlugin.java
@@ -0,0 +1,151 @@
+// 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.doris.authorization.spi;
+
+import org.apache.doris.authorization.AccessAction;
+import org.apache.doris.authorization.AccessContext;
+import org.apache.doris.authorization.AccessDeniedException;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.ActionMatch;
+import org.apache.doris.authorization.AuthorizedResource;
+import org.apache.doris.authorization.AuthorizedSubject;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
+import org.apache.doris.extension.spi.Plugin;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * An authorization source: whatever decides, for the resources it governs, what a user may do with them.
+ *
+ *
Doris ships one of these for its own {@code GRANT} model, and one per external system it integrates
+ * with. Which plugin is asked follows from the resource alone - the plugin a catalog is bound to answers for
+ * everything inside that catalog, the plugin installed for the instance answers for everything else - and its
+ * answer is the whole answer: nothing grants access before it is asked and no second plugin is consulted
+ * after it. That is what makes the policies in force on an object readable from the configuration, and it is
+ * why exemptions that used to be the engine's ("an administrator may go anywhere") are each plugin's own to
+ * grant or refuse, with {@link AuthorizationContext} there to ask the questions such a decision needs.
+ *
+ *
Refusing
+ *
+ *
A check that returns has allowed the access; a check that refuses throws {@link
+ * AccessDeniedException}. There is deliberately no third outcome for a plugin to express and no boolean for a
+ * caller to ignore. Every check method here defaults to refusing, so a plugin gets access control for the
+ * questions it did not think about rather than a hole; the two data-policy methods are the exception, and
+ * their empty default means "this source defines no policy", which is not the same as allowing anything.
+ *
+ *
What is asked, and how often
+ *
+ *
The engine asks about a whole requirement - a set of actions plus whether one of them or all of them are
+ * needed - rather than one action at a time, because sources answer that way: the built-in model tests a bit
+ * set in one pass, and a policy engine walking a resource hierarchy remembers which actions an outer level
+ * already granted. A plugin with no such structure implements {@link #checkAction} alone and lets the default
+ * below take the requirement apart.
+ *
+ *
These methods are on the path of every statement, several times over: planning one query checks each
+ * table it reads, and listing what a user may see checks every object that exists. Whatever caching an
+ * external source needs belongs inside the plugin, where it can be invalidated on the source's own terms; the
+ * engine adds none of its own and cannot, since it does not know when a policy changed.
+ */
+public interface AuthorizationPlugin extends Plugin {
+
+ /** The name this source is configured and reported under, e.g. {@code "ranger-doris"}. */
+ String name();
+
+ /**
+ * Refuses unless {@code subject} may act on {@code resource} as {@code requirement} demands.
+ *
+ *
The default takes the requirement apart and asks {@link #checkAction} about one action at a time,
+ * which is correct for any source but costs one evaluation per action. Override when the source can
+ * answer about a set of actions in one go.
+ *
+ * @param resource what is being accessed; a plugin should treat a kind it does not recognise as a
+ * refusal rather than guess, the resource kinds being closed and known at compile time
+ * @param context circumstances of the statement asking, for a decision that depends on more than the
+ * subject and the resource; ignorable
+ * @throws AccessDeniedException if the access is not allowed
+ */
+ default void checkPrivilege(AuthorizedSubject subject, AuthorizedResource resource,
+ AccessRequirement requirement, AccessContext context) throws AccessDeniedException {
+ if (requirement.getMatch() == ActionMatch.ALL) {
+ for (AccessAction action : requirement.getActions()) {
+ checkAction(subject, resource, action, context);
+ }
+ return;
+ }
+ AccessDeniedException refused = null;
+ for (AccessAction action : requirement.getActions()) {
+ try {
+ checkAction(subject, resource, action, context);
+ return;
+ } catch (AccessDeniedException e) {
+ refused = e;
+ }
+ }
+ // A requirement always names at least one action, so one refusal was recorded; report that one
+ // rather than a summary, because it says which action was actually tested last.
+ throw refused != null ? refused
+ : AccessDeniedException.of(subject, resource, requirement, name());
+ }
+
+ /**
+ * Refuses unless {@code subject} may perform {@code action} on {@code resource}.
+ *
+ *
This is the hook the default {@link #checkPrivilege} takes a requirement apart into, and the engine
+ * reaches it only that way - it always asks about a whole requirement. So a plugin implements this one
+ * or {@link #checkPrivilege}, whichever matches how its source answers, and leaving the other at
+ * its default is not a hole: nothing calls a {@code checkAction} that a plugin answering whole
+ * requirements never meant to provide.
+ *
+ * @throws AccessDeniedException if the access is not allowed
+ */
+ default void checkAction(AuthorizedSubject subject, AuthorizedResource resource, AccessAction action,
+ AccessContext context) throws AccessDeniedException {
+ throw AccessDeniedException.of(subject, resource, AccessRequirement.of(action), name());
+ }
+
+ /**
+ * The row-level filters this source imposes on {@code table} for {@code subject}, empty when it imposes
+ * none.
+ *
+ *
Each filter is a SQL predicate in Doris dialect; the engine parses it, type-checks it and plans it
+ * as a filter over the table, combining several of them as each one's merge type says. Returning no
+ * filter is not a decision about access - whether the table may be read at all was settled by {@link
+ * #checkPrivilege}.
+ */
+ default List getRowFilters(AuthorizedSubject subject, AuthorizedResource.Table table,
+ AccessContext context) {
+ return Collections.emptyList();
+ }
+
+ /**
+ * How the named columns of {@code table} must be rewritten before {@code subject} may read them: an entry
+ * per column that is masked, keyed by the column name as it was asked about, and no entry for a column
+ * that is not.
+ *
+ *
Asked about all the columns at once because a source that answers over the network would otherwise
+ * be asked once per column of every table in the statement.
+ */
+ default Map getDataMasks(AuthorizedSubject subject, AuthorizedResource.Table table,
+ Set columns, AccessContext context) {
+ return Collections.emptyMap();
+ }
+}
diff --git a/fe/fe-authorization/fe-authorization-spi/src/main/java/org/apache/doris/authorization/spi/AuthorizationPluginFactory.java b/fe/fe-authorization/fe-authorization-spi/src/main/java/org/apache/doris/authorization/spi/AuthorizationPluginFactory.java
new file mode 100644
index 00000000000000..a14482b4404edd
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-spi/src/main/java/org/apache/doris/authorization/spi/AuthorizationPluginFactory.java
@@ -0,0 +1,48 @@
+// 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.doris.authorization.spi;
+
+import java.util.Map;
+
+/**
+ * Creates an {@link AuthorizationPlugin}. This is what a plugin jar publishes for the engine to discover.
+ *
+ *
A plugin is created once and kept: unlike an authentication attempt, an authorization decision happens
+ * many times within a single statement, and a source that caches policies has to be the same instance
+ * throughout. The engine builds a new one only when what configures it changes.
+ */
+public interface AuthorizationPluginFactory {
+
+ /**
+ * The name this source is selected by in configuration, and the name its plugin reports.
+ *
+ * @return plugin name, e.g. {@code "ranger-doris"}
+ */
+ String name();
+
+ /** One line about what this source is, for logs and diagnostics. */
+ default String description() {
+ return "";
+ }
+
+ /**
+ * @param properties configuration for this instance of the source, as configured
+ * @param context what the engine will answer if this plugin asks; see {@link AuthorizationContext}
+ */
+ AuthorizationPlugin create(Map properties, AuthorizationContext context);
+}
diff --git a/fe/fe-authorization/fe-authorization-spi/src/test/java/org/apache/doris/authorization/spi/AuthorizationPluginContractTest.java b/fe/fe-authorization/fe-authorization-spi/src/test/java/org/apache/doris/authorization/spi/AuthorizationPluginContractTest.java
new file mode 100644
index 00000000000000..588c6e9f713340
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-spi/src/test/java/org/apache/doris/authorization/spi/AuthorizationPluginContractTest.java
@@ -0,0 +1,162 @@
+// 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.doris.authorization.spi;
+
+import org.apache.doris.authorization.AccessAction;
+import org.apache.doris.authorization.AccessContext;
+import org.apache.doris.authorization.AccessDeniedException;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AuthorizedResource;
+import org.apache.doris.authorization.AuthorizedSubject;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * What a plugin gets for free, and what it must not get for free.
+ *
+ *
Two properties are being held still here. A plugin that says nothing refuses everything, so a question
+ * an author did not think about - a resource kind added after the plugin was written, say - closes rather
+ * than opens. And a plugin that answers one action at a time has its requirements taken apart the way the
+ * requirement itself says: "any of these" is satisfied by one, "all of these" by nothing less than all of
+ * them. Getting that backwards would not fail loudly anywhere - it would quietly let a {@code GRANT}
+ * statement through for someone holding only half of what it needs.
+ */
+public class AuthorizationPluginContractTest {
+
+ private static final AuthorizedSubject SUBJECT = AuthorizedSubject.of("someone", "%");
+ private static final AuthorizedResource TABLE = AuthorizedResource.table("ctl", "db", "tbl");
+
+ /** A source that has been asked nothing and answers nothing. */
+ private static class SilentPlugin implements AuthorizationPlugin {
+ @Override
+ public String name() {
+ return "silent";
+ }
+ }
+
+ /** A source that only knows how to answer about one action at a time. */
+ private static class PerActionPlugin implements AuthorizationPlugin {
+ private final Set granted = EnumSet.noneOf(AccessAction.class);
+ private final List asked = new ArrayList<>();
+
+ PerActionPlugin(AccessAction... granted) {
+ Collections.addAll(this.granted, granted);
+ }
+
+ @Override
+ public String name() {
+ return "per-action";
+ }
+
+ @Override
+ public void checkAction(AuthorizedSubject subject, AuthorizedResource resource, AccessAction action,
+ AccessContext context) throws AccessDeniedException {
+ asked.add(action);
+ if (!granted.contains(action)) {
+ throw AccessDeniedException.of(subject, resource, AccessRequirement.of(action), name());
+ }
+ }
+ }
+
+ private static boolean allows(AuthorizationPlugin plugin, AccessRequirement requirement) {
+ try {
+ plugin.checkPrivilege(SUBJECT, TABLE, requirement, AccessContext.NONE);
+ return true;
+ } catch (AccessDeniedException e) {
+ return false;
+ }
+ }
+
+ @Test
+ public void testAPluginThatSaysNothingRefusesEverything() {
+ SilentPlugin plugin = new SilentPlugin();
+
+ Assertions.assertFalse(allows(plugin, AccessRequirement.of(AccessAction.SELECT)));
+ Assertions.assertFalse(allows(plugin, AccessRequirement.anyOf(AccessAction.SELECT, AccessAction.LOAD)));
+ Assertions.assertFalse(allows(plugin, AccessRequirement.allOf(AccessAction.SELECT, AccessAction.GRANT)));
+ Assertions.assertThrows(AccessDeniedException.class, () -> plugin.checkAction(SUBJECT, TABLE,
+ AccessAction.SELECT, AccessContext.NONE));
+ }
+
+ /**
+ * Refusing is about access, not about policy: a source with no row filter to impose has not thereby
+ * refused the table, and returning "denied" here would hide every row of every table from everyone.
+ */
+ @Test
+ public void testSayingNothingMeansNoDataPolicyRatherThanRefusal() {
+ SilentPlugin plugin = new SilentPlugin();
+
+ Assertions.assertTrue(plugin.getRowFilters(SUBJECT, AuthorizedResource.table("ctl", "db", "tbl"),
+ AccessContext.NONE).isEmpty());
+ Assertions.assertTrue(plugin.getDataMasks(SUBJECT, AuthorizedResource.table("ctl", "db", "tbl"),
+ Collections.singleton("col"), AccessContext.NONE).isEmpty());
+ }
+
+ @Test
+ public void testAnyIsSatisfiedByOneActionOfTheSet() {
+ Assertions.assertTrue(allows(new PerActionPlugin(AccessAction.LOAD),
+ AccessRequirement.anyOf(AccessAction.SELECT, AccessAction.LOAD)));
+ Assertions.assertFalse(allows(new PerActionPlugin(AccessAction.DROP),
+ AccessRequirement.anyOf(AccessAction.SELECT, AccessAction.LOAD)));
+ }
+
+ /**
+ * The shape of the check a {@code GRANT} statement makes - the grantor must hold what is being granted
+ * and the right to grant it - so reading this as "any" would hand out privileges to someone
+ * holding only one of the two.
+ */
+ @Test
+ public void testAllNeedsEveryActionOfTheSet() {
+ Assertions.assertTrue(allows(new PerActionPlugin(AccessAction.SELECT, AccessAction.GRANT),
+ AccessRequirement.allOf(AccessAction.SELECT, AccessAction.GRANT)));
+ Assertions.assertFalse(allows(new PerActionPlugin(AccessAction.SELECT),
+ AccessRequirement.allOf(AccessAction.SELECT, AccessAction.GRANT)));
+ Assertions.assertFalse(allows(new PerActionPlugin(AccessAction.GRANT),
+ AccessRequirement.allOf(AccessAction.SELECT, AccessAction.GRANT)));
+ }
+
+ /**
+ * "Any of these" stops at the first action that is granted. A source reached over the network pays per
+ * question asked, and the ones a satisfied requirement no longer needs are the easiest to stop asking.
+ */
+ @Test
+ public void testAnyStopsAtTheFirstActionThatIsGranted() {
+ PerActionPlugin plugin = new PerActionPlugin(AccessAction.SELECT);
+
+ Assertions.assertTrue(allows(plugin, AccessRequirement.anyOf(AccessAction.SELECT, AccessAction.LOAD)));
+ Assertions.assertEquals(Collections.singletonList(AccessAction.SELECT), plugin.asked);
+ }
+
+ /** A refusal names the source that produced it, which is the whole point of carrying it as an object. */
+ @Test
+ public void testARefusalSaysWhoRefused() {
+ AccessDeniedException denied = Assertions.assertThrows(AccessDeniedException.class,
+ () -> new SilentPlugin().checkPrivilege(SUBJECT, TABLE,
+ AccessRequirement.of(AccessAction.SELECT), AccessContext.NONE));
+
+ Assertions.assertEquals("silent", denied.getDeniedBy().orElse(null));
+ Assertions.assertSame(TABLE, denied.getResource());
+ }
+}
diff --git a/fe/fe-authorization/pom.xml b/fe/fe-authorization/pom.xml
index 138ee4ba32aed5..c627ee7aa88a26 100644
--- a/fe/fe-authorization/pom.xml
+++ b/fe/fe-authorization/pom.xml
@@ -32,10 +32,12 @@ under the License.
fe-authorization-api
+ fe-authorization-spi
diff --git a/fe/fe-core/pom.xml b/fe/fe-core/pom.xml
index e7825f36633a50..0585cbf0d18e45 100644
--- a/fe/fe-core/pom.xml
+++ b/fe/fe-core/pom.xml
@@ -395,6 +395,11 @@ under the License.
fe-authorization-api${project.version}
+
+ ${project.groupId}
+ fe-authorization-spi
+ ${project.version}
+ org.springframework.bootspring-boot-devtools
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/RangerAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/RangerAccessController.java
index 10bb4a1137c657..9ba6b19c44ac07 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/RangerAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/RangerAccessController.java
@@ -23,7 +23,7 @@
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.authorizer.ranger.doris.DorisAccessType;
import org.apache.doris.common.AuthorizationException;
-import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.mysql.privilege.AccessControllerManager;
import org.apache.doris.mysql.privilege.CatalogAccessController;
import org.apache.doris.mysql.privilege.PrivPredicate;
@@ -60,9 +60,8 @@ public abstract class RangerAccessController implements CatalogAccessController
* own global check answers the same question one line later.
*/
protected boolean grantedByGlobalScopeAuthority(UserIdentity currentUser, PrivPredicate wanted) {
- CatalogAccessController authority = Env.getCurrentEnv().getAccessManager()
- .getAccessControllerOrDefault(InternalCatalog.INTERNAL_CATALOG_NAME);
- return authority != this && authority.checkGlobalPriv(currentUser, wanted);
+ AccessControllerManager manager = Env.getCurrentEnv().getAccessManager();
+ return !manager.isGlobalScopeAuthority(this) && manager.checkGlobalPriv(currentUser, wanted);
}
protected static boolean checkRequestResult(RangerAccessRequestImpl request,
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java
index 3f2973ba678ad4..e42464d3f56e5c 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java
@@ -25,7 +25,6 @@
import org.apache.doris.catalog.authorizer.ranger.RangerAccessController;
import org.apache.doris.common.AuthorizationException;
import org.apache.doris.common.ThreadPoolManager;
-import org.apache.doris.datasource.InternalCatalog;
import org.apache.doris.mysql.privilege.PrivPredicate;
import com.google.common.collect.Maps;
@@ -198,9 +197,8 @@ private HiveAccessType convertToAccessType(PrivPredicate predicate) {
@Override
public boolean checkGlobalPriv(UserIdentity currentUser, PrivPredicate wanted) {
// hive ranger plugin does not support global privilege
- // use internal access controller to check
- return Env.getCurrentEnv().getAccessManager().getAccessControllerOrDefault(
- InternalCatalog.INTERNAL_CATALOG_NAME).checkGlobalPriv(currentUser, wanted);
+ // use whichever authorization source governs global scope
+ return Env.getCurrentEnv().getAccessManager().checkGlobalPriv(currentUser, wanted);
}
@Override
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
index 5fe2450da2a1b9..b521f4180b87af 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
@@ -19,10 +19,13 @@
import org.apache.doris.analysis.ResourceTypeEnum;
import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.AccessDeniedException;
import org.apache.doris.authorization.AccessRequirement;
import org.apache.doris.authorization.AuthorizedResource;
import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.ResourceKind;
import org.apache.doris.authorization.RowFilterSpec;
+import org.apache.doris.authorization.spi.AuthorizationPlugin;
import org.apache.doris.catalog.AuthorizationInfo;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.info.TableNameInfo;
@@ -45,6 +48,7 @@
import org.apache.logging.log4j.Logger;
import java.io.IOException;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -55,24 +59,22 @@
/**
* AccessControllerManager is the entry point of privilege authentication.
- * There are 2 kinds of access controller:
- * SystemAccessController: for global level priv, resource priv and other Doris internal priv checking
- * CatalogAccessController: for specified catalog's priv checking, can be customized.
- * And using InternalCatalogAccessController as default.
*
- *
It routes and nothing more: each check goes to the single controller that governs the resource, and that
- * controller's answer is the answer. The manager establishes no privilege of its own beforehand and never
- * combines two controllers' verdicts, so which policies apply to a resource is readable from which controller
- * the catalog is bound to.
+ *
Access is decided by authorization sources - the built-in privilege model, or a plugin standing for an
+ * external system - and this class decides only which one to ask: system-wide objects and catalog level
+ * grants go to the source {@code access_controller_type} installs, everything inside a catalog goes to the
+ * source that catalog is bound to. Whatever that source answers is the answer. The manager establishes no
+ * privilege of its own beforehand and never combines two sources' verdicts, so which policies apply to a
+ * resource is readable from which source the catalog is bound to.
*/
public class AccessControllerManager {
private static final Logger LOG = LogManager.getLogger(AccessControllerManager.class);
private Auth auth;
- // Default access controller instance used for handling cases where no specific controller is specified
- private CatalogAccessController defaultAccessController;
- // A catalog name can be reused after DROP. Keep the catalog id next to the controller so cleanup from
- // an old catalog generation can never remove or close the replacement generation's controller.
+ // Governs everything no catalog-bound source governs; the built-in model unless configured otherwise
+ private AuthorizationPlugin defaultAccessController;
+ // A catalog name can be reused after DROP. Keep the catalog id next to the source so cleanup from
+ // an old catalog generation can never remove or close the replacement generation's source.
private Map ctlToCtlAccessController = Maps.newConcurrentMap();
// Cache of loaded access controller factories for quick creation of new access controllers
private ConcurrentHashMap accessControllerFactoriesCache
@@ -92,22 +94,22 @@ public AccessControllerManager(Auth auth) {
private static final class CatalogAccessControllerEntry {
private final long catalogId;
- private final CatalogAccessController accessController;
- // The default controller is shared with the internal catalog. Catalog aliases must detach it but never
+ private final AuthorizationPlugin accessController;
+ // The default source is shared with the internal catalog. Catalog aliases must detach it but never
// close it when an external catalog is reset or dropped.
private final boolean owned;
private CatalogAccessControllerEntry(
- long catalogId, CatalogAccessController accessController, boolean owned) {
+ long catalogId, AuthorizationPlugin accessController, boolean owned) {
this.catalogId = catalogId;
this.accessController = accessController;
this.owned = owned;
}
}
- private CatalogAccessController loadAccessControllerOrThrow(String accessControllerName) {
- if (accessControllerName.equalsIgnoreCase("default")) {
- return new InternalAccessController(auth);
+ private AuthorizationPlugin loadAccessControllerOrThrow(String accessControllerName) {
+ if (accessControllerName.equalsIgnoreCase(InternalAuthorizationPlugin.NAME)) {
+ return new InternalAuthorizationPlugin(auth);
}
if (accessControllerFactoriesCache.containsKey(accessControllerName)) {
Map prop;
@@ -117,12 +119,18 @@ private CatalogAccessController loadAccessControllerOrThrow(String accessControl
throw new RuntimeException("Failed to load authorization properties."
+ "Please check the configuration file, authorization name is " + accessControllerName, e);
}
- return accessControllerFactoriesCache.get(accessControllerName).createAccessController(prop);
+ return adapt(accessControllerName,
+ accessControllerFactoriesCache.get(accessControllerName).createAccessController(prop));
}
throw new RuntimeException("No authorization plugin factory found for " + accessControllerName
+ ". Please confirm that your plugin is placed in the correct location.");
}
+ /** Presents a controller written against the older per-scope interface as an authorization source. */
+ private AuthorizationPlugin adapt(String name, CatalogAccessController controller) {
+ return new LegacyAccessControllerPlugin(name, controller);
+ }
+
private void loadAccessControllerPlugins() {
ServiceLoader loaderFromClasspath = ServiceLoader.load(AccessControllerFactory.class);
for (AccessControllerFactory factory : loaderFromClasspath) {
@@ -143,7 +151,8 @@ private void loadAccessControllerPlugins() {
}
}
- public CatalogAccessController getAccessControllerOrDefault(String ctl) {
+ /** The authorization source governing the objects inside {@code ctl}. */
+ public AuthorizationPlugin getAccessControllerOrDefault(String ctl) {
if (InternalCatalog.INTERNAL_CATALOG_NAME.equals(ctl)) {
return defaultAccessController;
}
@@ -214,8 +223,8 @@ public boolean checkIfAccessControllerExist(String ctl) {
public void createAccessController(ExternalCatalog catalog, String acFactoryClassName, Map prop,
boolean isDryRun) {
String pluginIdentifier = getPluginIdentifierForAccessController(acFactoryClassName);
- CatalogAccessController accessController = accessControllerFactoriesCache.get(pluginIdentifier)
- .createAccessController(prop);
+ AuthorizationPlugin accessController = adapt(pluginIdentifier,
+ accessControllerFactoriesCache.get(pluginIdentifier).createAccessController(prop));
if (isDryRun) {
closeAccessController(catalog.getName(), accessController);
return;
@@ -291,7 +300,7 @@ private void closeEntry(String ctl, CatalogAccessControllerEntry entry) {
closeAccessController(ctl, entry.accessController);
}
- private void closeAccessController(String ctl, CatalogAccessController accessController) {
+ private void closeAccessController(String ctl, AuthorizationPlugin accessController) {
try {
accessController.close();
} catch (Throwable e) {
@@ -308,80 +317,105 @@ public Auth getAuth() {
/**
* Answers whether {@code subject} may act on {@code resource} as {@code requirement} demands.
*
- *
This is the one place a check is routed. Which controller is asked follows from the resource
- * alone - system-wide objects and catalog-level grants go to the controller
- * {@code access_controller_type} installs, everything inside a catalog goes to the controller that
- * catalog is bound to - and whatever it answers is the answer. Combining two controllers, or granting
- * anything before asking, would have to happen here, and deliberately does not.
+ *
This is the one place a check is routed. Which source is asked follows from the resource alone -
+ * system-wide objects and catalog-level grants go to the source {@code access_controller_type}
+ * installs, everything inside a catalog goes to the source that catalog is bound to - and whatever it
+ * answers is the answer. Combining two sources, or granting anything before asking, would have to
+ * happen here, and deliberately does not.
*
*
Columns are not decided here: see {@link #decideColumns}.
*/
public boolean decide(UserIdentity subject, AuthorizedResource resource, AccessRequirement requirement) {
- PrivPredicate wanted = AccessTranslation.privPredicateOf(requirement);
+ if (resource.getKind() == ResourceKind.COLUMNS) {
+ throw new IllegalArgumentException("column access is decided by decideColumns(), which"
+ + " reports which column was refused instead of a yes or no");
+ }
+ try {
+ ask(subject, resource, requirement);
+ return true;
+ } catch (AccessDeniedException e) {
+ // The reason travels no further for now: every caller of the boolean facades phrases its own
+ // error message. It is carried this far so that the day one of them stops doing so, there is
+ // something to phrase it from.
+ return false;
+ }
+ }
+
+ /**
+ * Checks access to named columns, reporting the column that was refused rather than a yes or no.
+ *
+ *
Kept apart from {@link #decide} because the answer has a different shape, not because the routing
+ * differs: it is the same source the table itself would be asked about.
+ */
+ public void decideColumns(UserIdentity subject, AuthorizedResource.Columns columns,
+ AccessRequirement requirement) throws AuthorizationException {
+ try {
+ ask(subject, columns, requirement);
+ } catch (AccessDeniedException e) {
+ throw new AuthorizationException(e.getMessage());
+ }
+ }
+
+ private void ask(UserIdentity subject, AuthorizedResource resource, AccessRequirement requirement)
+ throws AccessDeniedException {
+ controllerOf(resource).checkPrivilege(AccessTranslation.subjectOf(subject), resource, requirement,
+ ConnectionAccessContext.current());
+ }
+
+ /**
+ * The authorization source that answers for {@code resource}. This is the whole of the routing: system
+ * wide objects and catalog level grants belong to the source installed for the instance, everything
+ * inside a catalog to the source that catalog is bound to.
+ */
+ private AuthorizationPlugin controllerOf(AuthorizedResource resource) {
switch (resource.getKind()) {
case GLOBAL:
- return systemScopeController().checkGlobalPriv(subject, wanted);
- case CATALOG:
- // Catalog level grants are only ever stored by the system scope controller, so it answers
- // for every catalog, including those bound to a controller of their own.
- return systemScopeController().checkCtlPriv(subject,
- ((AuthorizedResource.Catalog) resource).getCatalog(), wanted);
- case DATABASE: {
- AuthorizedResource.Database database = (AuthorizedResource.Database) resource;
- return controllerOf(database.getCatalog())
- .checkDbPriv(subject, database.getCatalog(), database.getDatabase(), wanted);
- }
- case TABLE: {
- AuthorizedResource.Table table = (AuthorizedResource.Table) resource;
- return controllerOf(table.getCatalog()).checkTblPriv(subject, table.getCatalog(),
- table.getDatabase(), table.getTable(), wanted);
- }
case RESOURCE:
- return systemScopeController()
- .checkResourcePriv(subject, ((AuthorizedResource.Named) resource).getName(), wanted);
case WORKLOAD_GROUP:
- return systemScopeController()
- .checkWorkloadGroupPriv(subject, ((AuthorizedResource.Named) resource).getName(), wanted);
case STORAGE_VAULT:
- return systemScopeController()
- .checkStorageVaultPriv(subject, ((AuthorizedResource.Named) resource).getName(), wanted);
case CLOUD_GENERAL:
case CLOUD_COMPUTE_GROUP:
case CLOUD_STAGE:
case CLOUD_STORAGE_VAULT:
- return systemScopeController().checkCloudPriv(subject,
- ((AuthorizedResource.Named) resource).getName(), wanted,
- AccessTranslation.cloudTypeOf(resource.getKind()));
+ return systemScopeController();
+ case CATALOG:
+ // Catalog level grants are only ever stored by the system scope source, so it answers for
+ // every catalog, including those bound to a source of their own.
+ return systemScopeController();
+ case DATABASE:
+ return getAccessControllerOrDefault(((AuthorizedResource.Database) resource).getCatalog());
+ case TABLE:
+ return getAccessControllerOrDefault(((AuthorizedResource.Table) resource).getCatalog());
case COLUMNS:
- throw new IllegalArgumentException("column access is decided by decideColumns(), which"
- + " reports which column was refused instead of a yes or no");
+ return getAccessControllerOrDefault(((AuthorizedResource.Columns) resource).getCatalog());
default:
throw new IllegalStateException("no route for resource kind " + resource.getKind());
}
}
/**
- * Checks access to named columns, reporting the column that was refused rather than a yes or no.
- *
- *
Kept apart from {@link #decide} because the answer has a different shape, not because the routing
- * differs: it is the same controller the table itself would be asked about.
- */
- public void decideColumns(UserIdentity subject, AuthorizedResource.Columns columns,
- AccessRequirement requirement) throws AuthorizationException {
- controllerOf(columns.getCatalog()).checkColsPriv(subject, columns.getCatalog(), columns.getDatabase(),
- columns.getTable(), columns.getColumns(), AccessTranslation.privPredicateOf(requirement));
- }
-
- /**
- * The controller governing everything that is not inside a catalog: global privileges, resources,
+ * The source governing everything that is not inside a catalog: global privileges, resources,
* workload groups, cloud objects, storage vaults - and catalog level grants, which only it stores.
*/
- private CatalogAccessController systemScopeController() {
+ private AuthorizationPlugin systemScopeController() {
return defaultAccessController;
}
- private CatalogAccessController controllerOf(String ctl) {
- return getAccessControllerOrDefault(ctl);
+ /**
+ * Whether {@code candidate} is itself the source governing instance scope.
+ *
+ *
Asked by a source that would otherwise defer to that authority, so that it does not ask itself a
+ * question it is about to answer - the two would agree, at the price of evaluating the same policies
+ * twice. Identity is the question, so a controller reached through an adapter is compared against the
+ * controller, not against its wrapper.
+ */
+ public boolean isGlobalScopeAuthority(Object candidate) {
+ AuthorizationPlugin authority = systemScopeController();
+ if (authority == candidate) {
+ return true;
+ }
+ return authority instanceof LegacyAccessControllerPlugin
+ && ((LegacyAccessControllerPlugin) authority).getController() == candidate;
}
// ==== Global ====
@@ -532,15 +566,6 @@ public boolean checkPrivByAuthInfo(ConnectContext ctx, AuthorizationInfo authInf
return true;
}
- public Map> evalDataMaskPolicies(UserIdentity currentUser, String
- ctl, String db, String tbl, Set cols) {
- Map> res = Maps.newHashMap();
- for (String col : cols) {
- res.put(col, evalDataMaskPolicy(currentUser, ctl, db, tbl, col));
- }
- return res;
- }
-
public Optional evalDataMaskPolicy(UserIdentity currentUser, String
ctl, String db, String tbl, String col) {
Objects.requireNonNull(currentUser, "require currentUser object");
@@ -548,7 +573,14 @@ public Optional evalDataMaskPolicy(UserIdentity currentUser, Strin
Objects.requireNonNull(db, "require db object");
Objects.requireNonNull(tbl, "require tbl object");
Objects.requireNonNull(col, "require col object");
- return getAccessControllerOrDefault(ctl).evalDataMaskPolicy(currentUser, ctl, db, tbl, col.toLowerCase());
+ // Sources are asked about columns in lower case, which is how the ones that store policies per
+ // column have them written.
+ String column = col.toLowerCase();
+ AuthorizedResource.Table table = AuthorizedResource.table(ctl, db, tbl);
+ return Optional.ofNullable(controllerOf(table)
+ .getDataMasks(AccessTranslation.subjectOf(currentUser), table,
+ Collections.singleton(column), ConnectionAccessContext.current())
+ .get(column));
}
public List evalRowFilterPolicies(UserIdentity currentUser, String
@@ -557,6 +589,8 @@ public List evalRowFilterPolicies(UserIdentity currentUser, Strin
Objects.requireNonNull(ctl, "require ctl object");
Objects.requireNonNull(db, "require db object");
Objects.requireNonNull(tbl, "require tbl object");
- return getAccessControllerOrDefault(ctl).evalRowFilterPolicies(currentUser, ctl, db, tbl);
+ AuthorizedResource.Table table = AuthorizedResource.table(ctl, db, tbl);
+ return controllerOf(table).getRowFilters(AccessTranslation.subjectOf(currentUser), table,
+ ConnectionAccessContext.current());
}
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessTranslation.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessTranslation.java
index 214578961e293c..0dd37b59dcafb7 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessTranslation.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessTranslation.java
@@ -19,9 +19,11 @@
import org.apache.doris.analysis.CompoundPredicate.Operator;
import org.apache.doris.analysis.ResourceTypeEnum;
+import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.authorization.AccessAction;
import org.apache.doris.authorization.AccessRequirement;
import org.apache.doris.authorization.ActionMatch;
+import org.apache.doris.authorization.AuthorizedSubject;
import org.apache.doris.authorization.ResourceKind;
import com.google.common.annotations.VisibleForTesting;
@@ -176,6 +178,35 @@ public static PrivPredicate privPredicateOf(AccessRequirement requirement) {
requirement.getMatch() == ActionMatch.ALL ? Operator.AND : Operator.OR);
}
+ /**
+ * The neutral form of the account {@code user} names.
+ *
+ *
Only the three parts that identify an account are carried over, which is exactly what makes the
+ * translation reversible: {@link UserIdentity#equals} compares those three and nothing else, and every
+ * lookup an authorization decision performs - the privilege tables, the row policies - matches on them.
+ * The certificate fields a connection may also carry take part in authentication, never in a decision
+ * about what an account may do, so leaving them behind loses nothing.
+ *
+ *
Read with {@code getUser()} rather than {@code getQualifiedUser()}. The two return the same field;
+ * the second additionally insists the identity has been through analysis, and callers exist that check
+ * access with one that has not - {@code checkCloudPriv} is reached that way today. Translating is not the
+ * place to start enforcing that, since it happens on every check and would turn callers that work into
+ * callers that throw.
+ */
+ public static AuthorizedSubject subjectOf(UserIdentity user) {
+ return AuthorizedSubject.of(user.getUser(), user.getHost(), user.isDomain());
+ }
+
+ /**
+ * The account {@code subject} names, in the form the built-in privilege model looks accounts up by.
+ * Equal to the identity it was translated from; see {@link #subjectOf}.
+ */
+ public static UserIdentity userIdentityOf(AuthorizedSubject subject) {
+ return subject.isDomain()
+ ? UserIdentity.createAnalyzedUserIdentWithDomain(subject.getUser(), subject.getHost())
+ : UserIdentity.createAnalyzedUserIdentWithIp(subject.getUser(), subject.getHost());
+ }
+
/** The resource kind standing for a cloud object of {@code type}. */
public static ResourceKind cloudKindOf(ResourceTypeEnum type) {
switch (type) {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/CatalogAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/CatalogAccessController.java
index 00ce013746e0ca..13a17be97a15fd 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/CatalogAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/CatalogAccessController.java
@@ -28,14 +28,19 @@
import java.util.Set;
/**
- * Decides access to the resources of one catalog.
+ * Decides access to the resources of one catalog, one kind of object at a time.
*
*
A controller is asked only about the resources it governs, and its answer is final: nothing outside it
* grants first. In particular the engine no longer establishes a global privilege before routing, so an
* implementation that wants "holding the privilege globally is enough" has to say so itself - see
- * {@link InternalAccessController}, which checks global privileges ahead of the fine grained ones, and
* {@link org.apache.doris.catalog.authorizer.ranger.RangerAccessController}, which defers to whichever
- * controller owns global scope.
+ * source owns global scope.
+ *
+ *
This is the older shape of that contract, kept because a catalog's {@code access_controller.class}
+ * names an implementation of it and such implementations exist outside this repository. The engine reaches
+ * one through {@link LegacyAccessControllerPlugin}; a source written today implements
+ * {@link org.apache.doris.authorization.spi.AuthorizationPlugin} instead, which asks a single question about
+ * a typed resource and answers by refusing rather than by returning false.
*/
public interface CatalogAccessController {
default void close() {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/ConnectionAccessContext.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/ConnectionAccessContext.java
new file mode 100644
index 00000000000000..9b8317c5f1c7e3
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/ConnectionAccessContext.java
@@ -0,0 +1,63 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.authorization.AccessContext;
+import org.apache.doris.common.util.DebugUtil;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.thrift.TUniqueId;
+
+import com.google.common.base.Strings;
+
+import java.util.Optional;
+
+/**
+ * The circumstances of a check, read from the connection the statement is running on.
+ *
+ *
Nothing is read until asked for. A plugin that decides from grants alone asks for none of it, and there
+ * are enough checks per statement - one per object a statement touches, and every object there is when a
+ * statement lists what a user may see - that formatting a query id nobody reads would be a real cost.
+ */
+class ConnectionAccessContext implements AccessContext {
+
+ private final ConnectContext connection;
+
+ private ConnectionAccessContext(ConnectContext connection) {
+ this.connection = connection;
+ }
+
+ /**
+ * The circumstances of the statement running on this thread, or {@link AccessContext#NONE} when the check
+ * comes from somewhere other than a client statement - a background job, or replaying an edit log.
+ */
+ static AccessContext current() {
+ ConnectContext connection = ConnectContext.get();
+ return connection == null ? AccessContext.NONE : new ConnectionAccessContext(connection);
+ }
+
+ @Override
+ public Optional getClientIp() {
+ return Optional.ofNullable(Strings.emptyToNull(connection.getRemoteIP()));
+ }
+
+ @Override
+ public Optional getQueryId() {
+ TUniqueId queryId = connection.queryId();
+ return queryId == null ? Optional.empty() : Optional.of(DebugUtil.printId(queryId));
+ }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAccessController.java
deleted file mode 100644
index 333cb8e6983fb3..00000000000000
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAccessController.java
+++ /dev/null
@@ -1,140 +0,0 @@
-// 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.doris.mysql.privilege;
-
-import org.apache.doris.analysis.ResourceTypeEnum;
-import org.apache.doris.analysis.UserIdentity;
-import org.apache.doris.authorization.DataMaskSpec;
-import org.apache.doris.authorization.RowFilterMergeType;
-import org.apache.doris.authorization.RowFilterSpec;
-import org.apache.doris.catalog.Env;
-import org.apache.doris.common.AnalysisException;
-import org.apache.doris.common.AuthorizationException;
-import org.apache.doris.policy.FilterType;
-import org.apache.doris.policy.RowPolicy;
-
-import com.google.common.collect.ImmutableList;
-
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-
-/**
- * The privilege model Doris ships with: users, roles and {@code GRANT} statements.
- *
- *
Every scoped check answers "globally, then at this scope". That order is this implementation's own
- * decision, not something the engine arranges for it, and it is not redundant with the checks {@link Auth}
- * runs per role: {@link Auth} refuses NODE privileges below global level, so a caller holding only global
- * NODE_PRIV is granted by the global check here and by nothing else.
- */
-public class InternalAccessController implements CatalogAccessController {
- private Auth auth;
-
- public InternalAccessController(Auth auth) {
- this.auth = auth;
- }
-
- @Override
- public boolean checkGlobalPriv(UserIdentity currentUser, PrivPredicate wanted) {
- return auth.checkGlobalPriv(currentUser, wanted);
- }
-
- @Override
- public boolean checkCtlPriv(UserIdentity currentUser, String ctl, PrivPredicate wanted) {
- return checkGlobalPriv(currentUser, wanted) || auth.checkCtlPriv(currentUser, ctl, wanted);
- }
-
- @Override
- public boolean checkDbPriv(UserIdentity currentUser, String ctl, String db, PrivPredicate wanted) {
- return checkGlobalPriv(currentUser, wanted) || auth.checkDbPriv(currentUser, ctl, db, wanted);
- }
-
- @Override
- public boolean checkTblPriv(UserIdentity currentUser, String ctl, String db, String tbl, PrivPredicate wanted) {
- return checkGlobalPriv(currentUser, wanted) || auth.checkTblPriv(currentUser, ctl, db, tbl, wanted);
- }
-
- @Override
- public void checkColsPriv(UserIdentity currentUser, String ctl, String db, String tbl, Set cols,
- PrivPredicate wanted) throws AuthorizationException {
- if (checkGlobalPriv(currentUser, wanted)) {
- return;
- }
- auth.checkColsPriv(currentUser, ctl, db, tbl, cols, wanted);
- }
-
- @Override
- public boolean checkResourcePriv(UserIdentity currentUser, String resourceName, PrivPredicate wanted) {
- return auth.checkResourcePriv(currentUser, resourceName, wanted);
- }
-
- @Override
- public boolean checkWorkloadGroupPriv(UserIdentity currentUser, String workloadGroupName, PrivPredicate wanted) {
- return auth.checkWorkloadGroupPriv(currentUser, workloadGroupName, wanted);
- }
-
- @Override
- public boolean checkCloudPriv(UserIdentity currentUser, String cloudName,
- PrivPredicate wanted, ResourceTypeEnum type) {
- return auth.checkCloudPriv(currentUser, cloudName, wanted, type);
- }
-
- @Override
- public boolean checkStorageVaultPriv(UserIdentity currentUser, String storageVaultName, PrivPredicate wanted) {
- return auth.checkStorageVaultPriv(currentUser, storageVaultName, wanted);
- }
-
- @Override
- public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
- String col) {
- // The built-in privilege model has no column masking: there is no DDL to define one.
- return Optional.empty();
- }
-
- @Override
- public List evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db,
- String tbl) {
- List policies = Env.getCurrentEnv().getPolicyMgr().getUserPolicies(ctl, db, tbl, currentUser);
- ImmutableList.Builder specs = ImmutableList.builderWithExpectedSize(policies.size());
- for (RowPolicy policy : policies) {
- try {
- specs.add(new RowFilterSpec(policy.getPolicyIdent(), policy.getFilterSql(),
- mergeTypeOf(policy.getFilterType())));
- } catch (AnalysisException e) {
- // A policy whose statement no longer parses cannot be turned into a filter, and dropping it
- // would silently widen access to the whole table. Fail the query with the same message the
- // planner used to raise when it asked the policy for its expression.
- throw new org.apache.doris.nereids.exceptions.AnalysisException(e.getMessage(), e);
- }
- }
- return specs.build();
- }
-
- private static RowFilterMergeType mergeTypeOf(FilterType filterType) {
- switch (filterType) {
- case PERMISSIVE:
- return RowFilterMergeType.PERMISSIVE;
- case RESTRICTIVE:
- return RowFilterMergeType.RESTRICTIVE;
- default:
- // Same shape as the planner's merge switch used to have: an unmapped filter type is a bug,
- // and guessing either way would change which rows the user sees.
- throw new IllegalStateException("Invalid operator");
- }
- }
-}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAuthorizationPlugin.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAuthorizationPlugin.java
new file mode 100644
index 00000000000000..a1ecc315828ee0
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/InternalAuthorizationPlugin.java
@@ -0,0 +1,194 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.AccessContext;
+import org.apache.doris.authorization.AccessDeniedException;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AuthorizedResource;
+import org.apache.doris.authorization.AuthorizedSubject;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterMergeType;
+import org.apache.doris.authorization.RowFilterSpec;
+import org.apache.doris.authorization.spi.AuthorizationPlugin;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.common.AuthorizationException;
+import org.apache.doris.policy.FilterType;
+import org.apache.doris.policy.RowPolicy;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * The privilege model Doris ships with: users, roles and {@code GRANT} statements.
+ *
+ *
It is an authorization source like any other and is reached the same way, which is what keeps the
+ * built-in behaviour describable in the same terms as an external system's: it governs whatever no plugin has
+ * been bound to, and what it decides there it decides alone.
+ *
+ *
Every scoped check answers "globally, then at this scope". That order is this implementation's own
+ * decision, not something the engine arranges for it, and it is not redundant with the checks {@link Auth}
+ * runs per role: {@link Auth} refuses NODE privileges below global level, so a caller holding only global
+ * NODE_PRIV is granted by the global check here and by nothing else. The system-wide objects below - a
+ * resource, a workload group, a vault, a cloud object - are deliberately not preceded by one: {@link Auth}
+ * already folds the global grants into those answers itself.
+ */
+public class InternalAuthorizationPlugin implements AuthorizationPlugin {
+
+ /** The name the built-in model is selected by, and the value {@code access_controller_type} defaults to. */
+ public static final String NAME = "default";
+
+ private final Auth auth;
+
+ public InternalAuthorizationPlugin(Auth auth) {
+ this.auth = auth;
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public void checkPrivilege(AuthorizedSubject subject, AuthorizedResource resource,
+ AccessRequirement requirement, AccessContext context) throws AccessDeniedException {
+ UserIdentity currentUser = AccessTranslation.userIdentityOf(subject);
+ PrivPredicate wanted = AccessTranslation.privPredicateOf(requirement);
+ switch (resource.getKind()) {
+ case GLOBAL:
+ refuseUnless(auth.checkGlobalPriv(currentUser, wanted), subject, resource, requirement);
+ return;
+ case CATALOG: {
+ AuthorizedResource.Catalog catalog = (AuthorizedResource.Catalog) resource;
+ refuseUnless(auth.checkGlobalPriv(currentUser, wanted)
+ || auth.checkCtlPriv(currentUser, catalog.getCatalog(), wanted),
+ subject, resource, requirement);
+ return;
+ }
+ case DATABASE: {
+ AuthorizedResource.Database database = (AuthorizedResource.Database) resource;
+ refuseUnless(auth.checkGlobalPriv(currentUser, wanted)
+ || auth.checkDbPriv(currentUser, database.getCatalog(),
+ database.getDatabase(), wanted),
+ subject, resource, requirement);
+ return;
+ }
+ case TABLE: {
+ AuthorizedResource.Table table = (AuthorizedResource.Table) resource;
+ refuseUnless(auth.checkGlobalPriv(currentUser, wanted)
+ || auth.checkTblPriv(currentUser, table.getCatalog(), table.getDatabase(),
+ table.getTable(), wanted),
+ subject, resource, requirement);
+ return;
+ }
+ case COLUMNS: {
+ AuthorizedResource.Columns columns = (AuthorizedResource.Columns) resource;
+ if (auth.checkGlobalPriv(currentUser, wanted)) {
+ return;
+ }
+ try {
+ auth.checkColsPriv(currentUser, columns.getCatalog(), columns.getDatabase(),
+ columns.getTable(), columns.getColumns(), wanted);
+ } catch (AuthorizationException e) {
+ // The message names the column that failed, which is the answer here; rephrasing it
+ // would reduce "this column" to "these columns". Carried as the bare wording rather
+ // than as rendered: the engine puts this back into an AuthorizationException on the way
+ // out, and that class prefixes its own error code when it renders.
+ throw AccessDeniedException.withMessage(e.getDetailMessage(), resource, NAME);
+ }
+ return;
+ }
+ case RESOURCE:
+ refuseUnless(auth.checkResourcePriv(currentUser,
+ ((AuthorizedResource.Named) resource).getName(), wanted), subject, resource, requirement);
+ return;
+ case WORKLOAD_GROUP:
+ refuseUnless(auth.checkWorkloadGroupPriv(currentUser,
+ ((AuthorizedResource.Named) resource).getName(), wanted), subject, resource, requirement);
+ return;
+ case STORAGE_VAULT:
+ refuseUnless(auth.checkStorageVaultPriv(currentUser,
+ ((AuthorizedResource.Named) resource).getName(), wanted), subject, resource, requirement);
+ return;
+ case CLOUD_GENERAL:
+ case CLOUD_COMPUTE_GROUP:
+ case CLOUD_STAGE:
+ case CLOUD_STORAGE_VAULT:
+ refuseUnless(auth.checkCloudPriv(currentUser, ((AuthorizedResource.Named) resource).getName(),
+ wanted, AccessTranslation.cloudTypeOf(resource.getKind())),
+ subject, resource, requirement);
+ return;
+ default:
+ throw new IllegalStateException("the built-in privilege model has no answer for resource kind "
+ + resource.getKind());
+ }
+ }
+
+ private void refuseUnless(boolean allowed, AuthorizedSubject subject, AuthorizedResource resource,
+ AccessRequirement requirement) throws AccessDeniedException {
+ if (!allowed) {
+ throw AccessDeniedException.of(subject, resource, requirement, NAME);
+ }
+ }
+
+ @Override
+ public Map getDataMasks(AuthorizedSubject subject, AuthorizedResource.Table table,
+ Set columns, AccessContext context) {
+ // The built-in privilege model has no column masking: there is no DDL to define one.
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public List getRowFilters(AuthorizedSubject subject, AuthorizedResource.Table table,
+ AccessContext context) {
+ List policies = Env.getCurrentEnv().getPolicyMgr().getUserPolicies(table.getCatalog(),
+ table.getDatabase(), table.getTable(), AccessTranslation.userIdentityOf(subject));
+ ImmutableList.Builder specs = ImmutableList.builderWithExpectedSize(policies.size());
+ for (RowPolicy policy : policies) {
+ try {
+ specs.add(new RowFilterSpec(policy.getPolicyIdent(), policy.getFilterSql(),
+ mergeTypeOf(policy.getFilterType())));
+ } catch (AnalysisException e) {
+ // A policy whose statement no longer parses cannot be turned into a filter, and dropping it
+ // would silently widen access to the whole table. Fail the query with the same message the
+ // planner used to raise when it asked the policy for its expression.
+ throw new org.apache.doris.nereids.exceptions.AnalysisException(e.getMessage(), e);
+ }
+ }
+ return specs.build();
+ }
+
+ private static RowFilterMergeType mergeTypeOf(FilterType filterType) {
+ switch (filterType) {
+ case PERMISSIVE:
+ return RowFilterMergeType.PERMISSIVE;
+ case RESTRICTIVE:
+ return RowFilterMergeType.RESTRICTIVE;
+ default:
+ // Same shape as the planner's merge switch used to have: an unmapped filter type is a bug,
+ // and guessing either way would change which rows the user sees.
+ throw new IllegalStateException("Invalid operator");
+ }
+ }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/LegacyAccessControllerPlugin.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/LegacyAccessControllerPlugin.java
new file mode 100644
index 00000000000000..4557cd87ee93ad
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/LegacyAccessControllerPlugin.java
@@ -0,0 +1,174 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.AccessContext;
+import org.apache.doris.authorization.AccessDeniedException;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AuthorizedResource;
+import org.apache.doris.authorization.AuthorizedSubject;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.RowFilterSpec;
+import org.apache.doris.authorization.spi.AuthorizationPlugin;
+import org.apache.doris.common.AuthorizationException;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Presents an access controller written against the older, per-scope interface as an authorization source.
+ *
+ *
That interface asks a separate question per kind of object and answers each with a boolean; this one
+ * asks a single question about a typed resource and answers by refusing or not. The translation is the whole
+ * of this class, and it is not a temporary shim: {@code CatalogAccessController} is what a catalog's
+ * {@code access_controller.class} names, so implementations of it exist outside this repository and keep
+ * working unchanged.
+ */
+public class LegacyAccessControllerPlugin implements AuthorizationPlugin {
+
+ private final String name;
+ private final CatalogAccessController controller;
+
+ public LegacyAccessControllerPlugin(String name, CatalogAccessController controller) {
+ this.name = Objects.requireNonNull(name, "name is required");
+ this.controller = Objects.requireNonNull(controller, "controller is required");
+ }
+
+ /**
+ * The controller this presents. Needed where an identity, not a behaviour, is the question - a controller
+ * asking whether it is itself the one governing instance scope has to compare against the object it is,
+ * not against the wrapper it is reached through.
+ */
+ public CatalogAccessController getController() {
+ return controller;
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public void checkPrivilege(AuthorizedSubject subject, AuthorizedResource resource,
+ AccessRequirement requirement, AccessContext context) throws AccessDeniedException {
+ UserIdentity currentUser = AccessTranslation.userIdentityOf(subject);
+ PrivPredicate wanted = AccessTranslation.privPredicateOf(requirement);
+ switch (resource.getKind()) {
+ case GLOBAL:
+ refuseUnless(controller.checkGlobalPriv(currentUser, wanted), subject, resource, requirement);
+ return;
+ case CATALOG:
+ refuseUnless(controller.checkCtlPriv(currentUser,
+ ((AuthorizedResource.Catalog) resource).getCatalog(), wanted),
+ subject, resource, requirement);
+ return;
+ case DATABASE: {
+ AuthorizedResource.Database database = (AuthorizedResource.Database) resource;
+ refuseUnless(controller.checkDbPriv(currentUser, database.getCatalog(),
+ database.getDatabase(), wanted), subject, resource, requirement);
+ return;
+ }
+ case TABLE: {
+ AuthorizedResource.Table table = (AuthorizedResource.Table) resource;
+ refuseUnless(controller.checkTblPriv(currentUser, table.getCatalog(), table.getDatabase(),
+ table.getTable(), wanted), subject, resource, requirement);
+ return;
+ }
+ case COLUMNS: {
+ AuthorizedResource.Columns columns = (AuthorizedResource.Columns) resource;
+ try {
+ controller.checkColsPriv(currentUser, columns.getCatalog(), columns.getDatabase(),
+ columns.getTable(), columns.getColumns(), wanted);
+ } catch (AuthorizationException e) {
+ // The message names the column that failed; that is the answer, so it is carried over
+ // as written rather than restated in terms of the whole column set. As the bare wording,
+ // not as rendered - the engine wraps it in an AuthorizationException again on the way
+ // out, and that class prefixes its own error code when it renders.
+ throw AccessDeniedException.withMessage(e.getDetailMessage(), resource, name);
+ }
+ return;
+ }
+ case RESOURCE:
+ refuseUnless(controller.checkResourcePriv(currentUser,
+ ((AuthorizedResource.Named) resource).getName(), wanted),
+ subject, resource, requirement);
+ return;
+ case WORKLOAD_GROUP:
+ refuseUnless(controller.checkWorkloadGroupPriv(currentUser,
+ ((AuthorizedResource.Named) resource).getName(), wanted),
+ subject, resource, requirement);
+ return;
+ case STORAGE_VAULT:
+ refuseUnless(controller.checkStorageVaultPriv(currentUser,
+ ((AuthorizedResource.Named) resource).getName(), wanted),
+ subject, resource, requirement);
+ return;
+ case CLOUD_GENERAL:
+ case CLOUD_COMPUTE_GROUP:
+ case CLOUD_STAGE:
+ case CLOUD_STORAGE_VAULT:
+ refuseUnless(controller.checkCloudPriv(currentUser,
+ ((AuthorizedResource.Named) resource).getName(), wanted,
+ AccessTranslation.cloudTypeOf(resource.getKind())), subject, resource, requirement);
+ return;
+ default:
+ throw new IllegalStateException("access controller " + name + " has no method answering for"
+ + " resource kind " + resource.getKind());
+ }
+ }
+
+ private void refuseUnless(boolean allowed, AuthorizedSubject subject, AuthorizedResource resource,
+ AccessRequirement requirement) throws AccessDeniedException {
+ if (!allowed) {
+ throw AccessDeniedException.of(subject, resource, requirement, name);
+ }
+ }
+
+ @Override
+ public List getRowFilters(AuthorizedSubject subject, AuthorizedResource.Table table,
+ AccessContext context) {
+ return controller.evalRowFilterPolicies(AccessTranslation.userIdentityOf(subject), table.getCatalog(),
+ table.getDatabase(), table.getTable());
+ }
+
+ @Override
+ public Map getDataMasks(AuthorizedSubject subject, AuthorizedResource.Table table,
+ Set columns, AccessContext context) {
+ UserIdentity currentUser = AccessTranslation.userIdentityOf(subject);
+ Map masks = new HashMap<>();
+ for (String column : columns) {
+ // One question per column, which is what the older interface offers. A source reached over the
+ // network pays for that per column of every table in the statement; implementing the batch
+ // method directly is how a plugin stops paying it.
+ Optional mask = controller.evalDataMaskPolicy(currentUser, table.getCatalog(),
+ table.getDatabase(), table.getTable(), column);
+ mask.ifPresent(spec -> masks.put(column, spec));
+ }
+ return masks;
+ }
+
+ @Override
+ public void close() {
+ controller.close();
+ }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/RangerGlobalScopeDeferenceTest.java b/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/RangerGlobalScopeDeferenceTest.java
index a47e38e8d40372..a302d56667873e 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/RangerGlobalScopeDeferenceTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/RangerGlobalScopeDeferenceTest.java
@@ -24,6 +24,7 @@
import org.apache.doris.mysql.privilege.AccessControllerManager;
import org.apache.doris.mysql.privilege.Auth;
import org.apache.doris.mysql.privilege.CatalogAccessController;
+import org.apache.doris.mysql.privilege.LegacyAccessControllerPlugin;
import org.apache.doris.mysql.privilege.PrivPredicate;
import org.apache.doris.mysql.privilege.StubRangerPolicyEngine;
@@ -112,10 +113,17 @@ private int requestsWhile(CatalogAccessController authority, BooleanSupplier che
return engine.requests.get();
}
- /** Runs {@code check} against an FE whose {@code access_controller_type} resolves to {@code authority}. */
+ /**
+ * Runs {@code check} against an FE whose {@code access_controller_type} resolves to {@code authority}.
+ *
+ *
Installed the way the engine installs one written against the older interface - behind the adapter -
+ * because that is what makes "is this authority me?" a question about the controller rather than about
+ * the object the manager happens to hold.
+ */
private boolean withGlobalScopeAuthority(CatalogAccessController authority, BooleanSupplier check) {
AccessControllerManager manager = new AccessControllerManager(new Auth());
- Deencapsulation.setField(manager, "defaultAccessController", authority);
+ Deencapsulation.setField(manager, "defaultAccessController",
+ new LegacyAccessControllerPlugin("authority", authority));
try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) {
Env env = Mockito.mock(Env.class);
mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControlBehaviorBaselineTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControlBehaviorBaselineTest.java
index 618cd120fbfbda..0c75a4877e850f 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControlBehaviorBaselineTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControlBehaviorBaselineTest.java
@@ -20,6 +20,7 @@
import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.authorization.DataMaskSpec;
import org.apache.doris.authorization.RowFilterSpec;
+import org.apache.doris.authorization.spi.AuthorizationPlugin;
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.PrimitiveType;
@@ -213,11 +214,11 @@ private List renderSnapshot() {
lines.add("USERS: " + String.join(",", USERS));
lines.add("");
AccessControllerManager manager = Env.getCurrentEnv().getAccessManager();
- CatalogAccessController builtin = Deencapsulation.getField(manager, "defaultAccessController");
+ AuthorizationPlugin builtin = Deencapsulation.getField(manager, "defaultAccessController");
renderWithDefaultController(lines, manager, "builtin", builtin);
- renderWithDefaultController(lines, manager, "ranger",
- new RangerDorisAccessController(new StubRangerPolicyEngine()));
+ renderWithDefaultController(lines, manager, "ranger", new LegacyAccessControllerPlugin(
+ "stub-ranger-doris", new RangerDorisAccessController(new StubRangerPolicyEngine())));
return lines;
}
@@ -226,8 +227,8 @@ private List renderSnapshot() {
* {@code fe.conf: access_controller_type}.
*/
private void renderWithDefaultController(List lines, AccessControllerManager manager,
- String label, CatalogAccessController defaultController) {
- CatalogAccessController original = Deencapsulation.getField(manager, "defaultAccessController");
+ String label, AuthorizationPlugin defaultController) {
+ AuthorizationPlugin original = Deencapsulation.getField(manager, "defaultAccessController");
Map routes = Deencapsulation.getField(manager, "ctlToCtlAccessController");
Map savedRoutes = new HashMap<>(routes);
try {
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControllerManagerTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControllerManagerTest.java
index 06d8ca6d56653d..8913e56edee120 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControllerManagerTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessControllerManagerTest.java
@@ -278,7 +278,7 @@ public void testOldGenerationCannotRemoveReplacementController() {
manager.createAccessController(newCatalog, "test-controller", ImmutableMap.of(), false);
manager.removeAccessController("same_name", oldCatalog.getId());
- Assert.assertSame(newController, manager.getAccessControllerOrDefault("same_name"));
+ Assert.assertSame(newController, controllerOf(manager, "same_name"));
}
Mockito.verify(oldController).close();
@@ -324,7 +324,7 @@ public void testRemovingFallbackAliasDoesNotCloseSharedDefaultController() {
Mockito.when(env.getCatalogMgr()).thenReturn(catalogMgr);
Mockito.when(catalogMgr.getCatalog("fallback")).thenReturn(catalog);
- Assert.assertSame(defaultAccessController, manager.getAccessControllerOrDefault("fallback"));
+ Assert.assertSame(defaultAccessController, controllerOf(manager, "fallback"));
manager.removeAccessController("fallback", catalog.getId());
}
@@ -351,7 +351,13 @@ private void withCurrentCatalog(ExternalCatalog catalog, Runnable action) {
private AccessControllerManager createAccessControllerManager(CatalogAccessController defaultAccessController) {
AccessControllerManager accessControllerManager = new AccessControllerManager(new Auth());
- Deencapsulation.setField(accessControllerManager, "defaultAccessController", defaultAccessController);
+ Deencapsulation.setField(accessControllerManager, "defaultAccessController",
+ new LegacyAccessControllerPlugin("mock", defaultAccessController));
return accessControllerManager;
}
+
+ /** The controller behind an installed source; what the manager holds is the source, not the controller. */
+ private CatalogAccessController controllerOf(AccessControllerManager manager, String ctl) {
+ return ((LegacyAccessControllerPlugin) manager.getAccessControllerOrDefault(ctl)).getController();
+ }
}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessTranslationTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessTranslationTest.java
index ffb950521bac2e..1bd134047549ba 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessTranslationTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessTranslationTest.java
@@ -19,9 +19,11 @@
import org.apache.doris.analysis.CompoundPredicate.Operator;
import org.apache.doris.analysis.ResourceTypeEnum;
+import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.authorization.AccessAction;
import org.apache.doris.authorization.AccessRequirement;
import org.apache.doris.authorization.ActionMatch;
+import org.apache.doris.authorization.AuthorizedSubject;
import org.apache.doris.authorization.ResourceKind;
import org.apache.doris.common.jmockit.Deencapsulation;
@@ -184,6 +186,51 @@ public void testOnlyCloudKindsHaveACloudResourceType() {
() -> AccessTranslation.cloudTypeOf(ResourceKind.TABLE));
}
+ /**
+ * An account survives the trip out to a source and back into the privilege tables.
+ *
+ *
The built-in model is itself one of the sources, and it looks accounts up in tables keyed by
+ * account. So the neutral form has to carry everything an account is identified by - and only a test can
+ * say that it does, because dropping a part of an identity does not fail: it silently matches a
+ * different account's grants, or none.
+ */
+ @Test
+ public void testAnAccountSurvivesTheRoundTrip() {
+ for (UserIdentity original : Arrays.asList(
+ UserIdentity.createAnalyzedUserIdentWithIp("user", "192.168.%"),
+ UserIdentity.createAnalyzedUserIdentWithIp("user", "%"),
+ UserIdentity.createAnalyzedUserIdentWithDomain("user", "doris.example.com"),
+ UserIdentity.ROOT,
+ UserIdentity.ADMIN,
+ // Callers that check access with an identity they never put through analysis exist - the
+ // cloud checks are reached that way. Translating one must answer, not object.
+ new UserIdentity("user", "%"))) {
+ UserIdentity translated = AccessTranslation.userIdentityOf(AccessTranslation.subjectOf(original));
+
+ Assert.assertEquals(original.toString(), original, translated);
+ Assert.assertEquals(original.toString(), original.getUser(), translated.getUser());
+ Assert.assertEquals(original.toString(), original.getHost(), translated.getHost());
+ Assert.assertEquals(original.toString(), original.isDomain(), translated.isDomain());
+ }
+ }
+
+ /**
+ * Two accounts that differ only in where they connect from are different accounts with different grants,
+ * so they must not collapse into one subject on the way out.
+ */
+ @Test
+ public void testAccountsDifferingOnlyInHostStayApart() {
+ AuthorizedSubject anywhere = AccessTranslation.subjectOf(
+ UserIdentity.createAnalyzedUserIdentWithIp("user", "%"));
+ AuthorizedSubject fromOffice = AccessTranslation.subjectOf(
+ UserIdentity.createAnalyzedUserIdentWithIp("user", "192.168.%"));
+ AuthorizedSubject inDomain = AccessTranslation.subjectOf(
+ UserIdentity.createAnalyzedUserIdentWithDomain("user", "192.168.%"));
+
+ Assert.assertNotEquals(anywhere, fromOffice);
+ Assert.assertNotEquals(fromOffice, inDomain);
+ }
+
private void assertRoundTrips(String what, PrivPredicate wanted) {
PrivPredicate translated = AccessTranslation.privPredicateOf(AccessTranslation.requirementOf(wanted));
Assert.assertEquals(what + ": privileges changed", bitsOf(wanted), bitsOf(translated));
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/InternalAccessControllerTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/InternalAccessControllerTest.java
deleted file mode 100644
index 49d0d19f7368d5..00000000000000
--- a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/InternalAccessControllerTest.java
+++ /dev/null
@@ -1,139 +0,0 @@
-// 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.doris.mysql.privilege;
-
-import org.apache.doris.analysis.UserIdentity;
-import org.apache.doris.common.AuthorizationException;
-
-import com.google.common.collect.ImmutableSet;
-import org.junit.Assert;
-import org.junit.Test;
-import org.mockito.Mockito;
-
-/**
- * The built-in controller answers "globally, then at this scope".
- *
- *
Both halves of that sentence are load bearing. Answering globally first is what lets an administrator
- * reach a resource no grant names, and it must happen before the scoped lookup rather than instead of
- * it - the scoped lookups are the expensive ones, and one of them refuses to answer at all for privileges that
- * only exist globally. The engine used to arrange this order on every controller's behalf; now each controller
- * owns it, so these tests watch the built-in one keep it.
- */
-public class InternalAccessControllerTest {
- private static final UserIdentity USER = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
-
- private final Auth auth = Mockito.mock(Auth.class);
- private final InternalAccessController controller = new InternalAccessController(auth);
-
- private void holdsGlobally(boolean granted) {
- Mockito.when(auth.checkGlobalPriv(USER, PrivPredicate.SELECT)).thenReturn(granted);
- }
-
- @Test
- public void testCatalogCheckIsSkippedWhenPrivilegeIsHeldGlobally() {
- holdsGlobally(true);
-
- Assert.assertTrue(controller.checkCtlPriv(USER, "ctl", PrivPredicate.SELECT));
- Mockito.verify(auth, Mockito.never()).checkCtlPriv(Mockito.any(), Mockito.anyString(), Mockito.any());
- }
-
- @Test
- public void testCatalogCheckDecidesWhenPrivilegeIsNotHeldGlobally() {
- holdsGlobally(false);
- Mockito.when(auth.checkCtlPriv(USER, "ctl", PrivPredicate.SELECT)).thenReturn(true);
-
- Assert.assertTrue(controller.checkCtlPriv(USER, "ctl", PrivPredicate.SELECT));
- Assert.assertFalse(controller.checkCtlPriv(USER, "other_ctl", PrivPredicate.SELECT));
- }
-
- @Test
- public void testDatabaseCheckIsSkippedWhenPrivilegeIsHeldGlobally() {
- holdsGlobally(true);
-
- Assert.assertTrue(controller.checkDbPriv(USER, "ctl", "db", PrivPredicate.SELECT));
- Mockito.verify(auth, Mockito.never())
- .checkDbPriv(Mockito.any(), Mockito.anyString(), Mockito.anyString(), Mockito.any());
- }
-
- @Test
- public void testDatabaseCheckDecidesWhenPrivilegeIsNotHeldGlobally() {
- holdsGlobally(false);
- Mockito.when(auth.checkDbPriv(USER, "ctl", "db", PrivPredicate.SELECT)).thenReturn(true);
-
- Assert.assertTrue(controller.checkDbPriv(USER, "ctl", "db", PrivPredicate.SELECT));
- Assert.assertFalse(controller.checkDbPriv(USER, "ctl", "other_db", PrivPredicate.SELECT));
- }
-
- @Test
- public void testTableCheckIsSkippedWhenPrivilegeIsHeldGlobally() {
- holdsGlobally(true);
-
- Assert.assertTrue(controller.checkTblPriv(USER, "ctl", "db", "tbl", PrivPredicate.SELECT));
- Mockito.verify(auth, Mockito.never()).checkTblPriv(
- Mockito.any(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.any());
- }
-
- @Test
- public void testTableCheckDecidesWhenPrivilegeIsNotHeldGlobally() {
- holdsGlobally(false);
- Mockito.when(auth.checkTblPriv(USER, "ctl", "db", "tbl", PrivPredicate.SELECT)).thenReturn(true);
-
- Assert.assertTrue(controller.checkTblPriv(USER, "ctl", "db", "tbl", PrivPredicate.SELECT));
- Assert.assertFalse(controller.checkTblPriv(USER, "ctl", "db", "other_tbl", PrivPredicate.SELECT));
- }
-
- @Test
- public void testColumnCheckIsSkippedWhenPrivilegeIsHeldGlobally() throws Exception {
- holdsGlobally(true);
-
- controller.checkColsPriv(USER, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT);
- Mockito.verify(auth, Mockito.never()).checkColsPriv(Mockito.any(), Mockito.anyString(),
- Mockito.anyString(), Mockito.anyString(), Mockito.any(), Mockito.any());
- }
-
- @Test
- public void testColumnCheckDecidesWhenPrivilegeIsNotHeldGlobally() throws Exception {
- holdsGlobally(false);
-
- controller.checkColsPriv(USER, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT);
- Mockito.verify(auth).checkColsPriv(USER, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT);
- }
-
- @Test
- public void testColumnDenialIsReportedWhenPrivilegeIsNotHeldGlobally() throws Exception {
- holdsGlobally(false);
- Mockito.doThrow(new AuthorizationException("denied")).when(auth).checkColsPriv(
- USER, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT);
-
- Assert.assertThrows(AuthorizationException.class, () -> controller.checkColsPriv(
- USER, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT));
- }
-
- /**
- * A caller holding only global NODE_PRIV is why the global check has to run first instead of being folded
- * into the scoped one: {@link Auth} refuses NODE privileges below global level, so the scoped lookup would
- * turn the administrator away.
- */
- @Test
- public void testGloballyHeldNodePrivilegeIsNotRefusedByTheScopedCheck() {
- Mockito.when(auth.checkGlobalPriv(USER, PrivPredicate.OPERATOR)).thenReturn(true);
- Mockito.when(auth.checkDbPriv(USER, "ctl", "db", PrivPredicate.OPERATOR)).thenReturn(false);
-
- Assert.assertTrue(controller.checkDbPriv(USER, "ctl", "db", PrivPredicate.OPERATOR));
- }
-}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/InternalAuthorizationPluginTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/InternalAuthorizationPluginTest.java
new file mode 100644
index 00000000000000..ab6ddd4a32b672
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/InternalAuthorizationPluginTest.java
@@ -0,0 +1,196 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.AccessContext;
+import org.apache.doris.authorization.AccessDeniedException;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AuthorizedResource;
+import org.apache.doris.authorization.AuthorizedSubject;
+import org.apache.doris.common.AuthorizationException;
+
+import com.google.common.collect.ImmutableSet;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+/**
+ * The built-in source answers "globally, then at this scope".
+ *
+ *
Both halves of that sentence are load bearing. Answering globally first is what lets an administrator
+ * reach a resource no grant names, and it must happen before the scoped lookup rather than instead of
+ * it - the scoped lookups are the expensive ones, and one of them refuses to answer at all for privileges that
+ * only exist globally. The engine used to arrange this order on every source's behalf; now each source owns
+ * it, so these tests watch the built-in one keep it.
+ *
+ *
They also pin the translation on the way in, without asserting anything about it directly: the mocked
+ * {@link Auth} is told to expect the identity and the predicate the caller started from, so a translation
+ * that produced anything else would leave every expectation unmatched.
+ */
+public class InternalAuthorizationPluginTest {
+ private static final UserIdentity USER = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
+ private static final AuthorizedSubject SUBJECT = AccessTranslation.subjectOf(USER);
+ private static final AccessRequirement SELECT = AccessTranslation.requirementOf(PrivPredicate.SELECT);
+
+ private final Auth auth = Mockito.mock(Auth.class);
+ private final InternalAuthorizationPlugin plugin = new InternalAuthorizationPlugin(auth);
+
+ private void holdsGlobally(boolean granted) {
+ Mockito.when(auth.checkGlobalPriv(USER, PrivPredicate.SELECT)).thenReturn(granted);
+ }
+
+ private boolean allows(AuthorizedResource resource) {
+ return allows(resource, SELECT);
+ }
+
+ private boolean allows(AuthorizedResource resource, AccessRequirement requirement) {
+ try {
+ plugin.checkPrivilege(SUBJECT, resource, requirement, AccessContext.NONE);
+ return true;
+ } catch (AccessDeniedException e) {
+ return false;
+ }
+ }
+
+ @Test
+ public void testCatalogCheckIsSkippedWhenPrivilegeIsHeldGlobally() {
+ holdsGlobally(true);
+
+ Assert.assertTrue(allows(AuthorizedResource.catalog("ctl")));
+ Mockito.verify(auth, Mockito.never()).checkCtlPriv(Mockito.any(), Mockito.anyString(), Mockito.any());
+ }
+
+ @Test
+ public void testCatalogCheckDecidesWhenPrivilegeIsNotHeldGlobally() {
+ holdsGlobally(false);
+ Mockito.when(auth.checkCtlPriv(USER, "ctl", PrivPredicate.SELECT)).thenReturn(true);
+
+ Assert.assertTrue(allows(AuthorizedResource.catalog("ctl")));
+ Assert.assertFalse(allows(AuthorizedResource.catalog("other_ctl")));
+ }
+
+ @Test
+ public void testDatabaseCheckIsSkippedWhenPrivilegeIsHeldGlobally() {
+ holdsGlobally(true);
+
+ Assert.assertTrue(allows(AuthorizedResource.database("ctl", "db")));
+ Mockito.verify(auth, Mockito.never())
+ .checkDbPriv(Mockito.any(), Mockito.anyString(), Mockito.anyString(), Mockito.any());
+ }
+
+ @Test
+ public void testDatabaseCheckDecidesWhenPrivilegeIsNotHeldGlobally() {
+ holdsGlobally(false);
+ Mockito.when(auth.checkDbPriv(USER, "ctl", "db", PrivPredicate.SELECT)).thenReturn(true);
+
+ Assert.assertTrue(allows(AuthorizedResource.database("ctl", "db")));
+ Assert.assertFalse(allows(AuthorizedResource.database("ctl", "other_db")));
+ }
+
+ @Test
+ public void testTableCheckIsSkippedWhenPrivilegeIsHeldGlobally() {
+ holdsGlobally(true);
+
+ Assert.assertTrue(allows(AuthorizedResource.table("ctl", "db", "tbl")));
+ Mockito.verify(auth, Mockito.never()).checkTblPriv(
+ Mockito.any(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(), Mockito.any());
+ }
+
+ @Test
+ public void testTableCheckDecidesWhenPrivilegeIsNotHeldGlobally() {
+ holdsGlobally(false);
+ Mockito.when(auth.checkTblPriv(USER, "ctl", "db", "tbl", PrivPredicate.SELECT)).thenReturn(true);
+
+ Assert.assertTrue(allows(AuthorizedResource.table("ctl", "db", "tbl")));
+ Assert.assertFalse(allows(AuthorizedResource.table("ctl", "db", "other_tbl")));
+ }
+
+ @Test
+ public void testColumnCheckIsSkippedWhenPrivilegeIsHeldGlobally() throws Exception {
+ holdsGlobally(true);
+
+ plugin.checkPrivilege(SUBJECT, AuthorizedResource.columns("ctl", "db", "tbl", ImmutableSet.of("col1")),
+ SELECT, AccessContext.NONE);
+ Mockito.verify(auth, Mockito.never()).checkColsPriv(Mockito.any(), Mockito.anyString(),
+ Mockito.anyString(), Mockito.anyString(), Mockito.any(), Mockito.any());
+ }
+
+ @Test
+ public void testColumnCheckDecidesWhenPrivilegeIsNotHeldGlobally() throws Exception {
+ holdsGlobally(false);
+
+ plugin.checkPrivilege(SUBJECT, AuthorizedResource.columns("ctl", "db", "tbl", ImmutableSet.of("col1")),
+ SELECT, AccessContext.NONE);
+ Mockito.verify(auth).checkColsPriv(USER, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT);
+ }
+
+ /**
+ * A refused column check answers with the column that failed, so the wording the privilege model produced
+ * has to survive the trip out - restating it in terms of the whole column set would lose which one it was.
+ *
+ *
What the second assertion holds still is the message the user actually reads. The refusal leaves the
+ * privilege model as an {@link AuthorizationException} and reaches the caller as one again, and that class
+ * renders itself with its error code in front. Carrying the rendered form across the middle instead of the
+ * bare wording puts that prefix in twice - which does not fail anywhere, it just changes what every denied
+ * column query prints.
+ */
+ @Test
+ public void testColumnDenialKeepsTheMessageNamingTheColumn() throws Exception {
+ holdsGlobally(false);
+ AuthorizationException fromThePrivilegeModel = new AuthorizationException("denied on col1");
+ Mockito.doThrow(fromThePrivilegeModel).when(auth).checkColsPriv(
+ USER, "ctl", "db", "tbl", ImmutableSet.of("col1"), PrivPredicate.SELECT);
+
+ AccessDeniedException denied = Assert.assertThrows(AccessDeniedException.class,
+ () -> plugin.checkPrivilege(SUBJECT,
+ AuthorizedResource.columns("ctl", "db", "tbl", ImmutableSet.of("col1")),
+ SELECT, AccessContext.NONE));
+
+ Assert.assertEquals("denied on col1", denied.getMessage());
+ Assert.assertEquals(fromThePrivilegeModel.getMessage(),
+ new AuthorizationException(denied.getMessage()).getMessage());
+ }
+
+ /**
+ * A caller holding only global NODE_PRIV is why the global check has to run first instead of being folded
+ * into the scoped one: {@link Auth} refuses NODE privileges below global level, so the scoped lookup would
+ * turn the administrator away.
+ */
+ @Test
+ public void testGloballyHeldNodePrivilegeIsNotRefusedByTheScopedCheck() {
+ Mockito.when(auth.checkGlobalPriv(USER, PrivPredicate.OPERATOR)).thenReturn(true);
+ Mockito.when(auth.checkDbPriv(USER, "ctl", "db", PrivPredicate.OPERATOR)).thenReturn(false);
+
+ Assert.assertTrue(allows(AuthorizedResource.database("ctl", "db"),
+ AccessTranslation.requirementOf(PrivPredicate.OPERATOR)));
+ }
+
+ /**
+ * The system-wide objects are deliberately not preceded by a global check here: {@link Auth}
+ * folds the global grants into those answers itself, and asking twice would evaluate the same grants
+ * again for the same answer.
+ */
+ @Test
+ public void testSystemWideObjectsAreLeftEntirelyToTheGrantLookup() {
+ Mockito.when(auth.checkResourcePriv(USER, "res", PrivPredicate.SELECT)).thenReturn(true);
+
+ Assert.assertTrue(allows(AuthorizedResource.resource("res")));
+ Mockito.verify(auth, Mockito.never()).checkGlobalPriv(Mockito.any(), Mockito.any());
+ }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/LegacyAccessControllerPluginTest.java b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/LegacyAccessControllerPluginTest.java
new file mode 100644
index 00000000000000..785c0c845eb271
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/LegacyAccessControllerPluginTest.java
@@ -0,0 +1,189 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.analysis.ResourceTypeEnum;
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.AccessContext;
+import org.apache.doris.authorization.AccessDeniedException;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AuthorizedResource;
+import org.apache.doris.authorization.AuthorizedSubject;
+import org.apache.doris.authorization.DataMaskSpec;
+import org.apache.doris.authorization.ResourceKind;
+import org.apache.doris.common.AuthorizationException;
+
+import com.google.common.collect.ImmutableSet;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * A controller written against the older per-scope interface, asked the way an authorization source is asked.
+ *
+ *
The translation this covers is a lookup table with no logic in it, which is exactly why it is worth
+ * testing: every resource kind has to reach the one method that used to answer for it, and a wire crossed
+ * between two of them - a storage vault asked about as if it were a resource - produces a plausible answer
+ * from the wrong policy set, silently. So each kind is checked against the method it must land on, and each
+ * is checked to be refused when that method says no.
+ */
+public class LegacyAccessControllerPluginTest {
+
+ private static final UserIdentity USER = UserIdentity.createAnalyzedUserIdentWithIp("test_user", "%");
+ private static final AuthorizedSubject SUBJECT = AccessTranslation.subjectOf(USER);
+ private static final AccessRequirement SELECT = AccessTranslation.requirementOf(PrivPredicate.SELECT);
+
+ private final CatalogAccessController controller = Mockito.mock(CatalogAccessController.class);
+ private final LegacyAccessControllerPlugin plugin = new LegacyAccessControllerPlugin("legacy", controller);
+
+ private boolean allows(AuthorizedResource resource) {
+ try {
+ plugin.checkPrivilege(SUBJECT, resource, SELECT, AccessContext.NONE);
+ return true;
+ } catch (AccessDeniedException e) {
+ return false;
+ }
+ }
+
+ @Test
+ public void testGlobalGoesToTheGlobalCheck() {
+ Mockito.when(controller.checkGlobalPriv(USER, PrivPredicate.SELECT)).thenReturn(true);
+
+ Assert.assertTrue(allows(AuthorizedResource.global()));
+ }
+
+ @Test
+ public void testCatalogGoesToTheCatalogCheck() {
+ Mockito.when(controller.checkCtlPriv(USER, "ctl", PrivPredicate.SELECT)).thenReturn(true);
+
+ Assert.assertTrue(allows(AuthorizedResource.catalog("ctl")));
+ Assert.assertFalse(allows(AuthorizedResource.catalog("other")));
+ }
+
+ @Test
+ public void testDatabaseGoesToTheDatabaseCheck() {
+ Mockito.when(controller.checkDbPriv(USER, "ctl", "db", PrivPredicate.SELECT)).thenReturn(true);
+
+ Assert.assertTrue(allows(AuthorizedResource.database("ctl", "db")));
+ Assert.assertFalse(allows(AuthorizedResource.database("ctl", "other")));
+ }
+
+ @Test
+ public void testTableGoesToTheTableCheck() {
+ Mockito.when(controller.checkTblPriv(USER, "ctl", "db", "tbl", PrivPredicate.SELECT)).thenReturn(true);
+
+ Assert.assertTrue(allows(AuthorizedResource.table("ctl", "db", "tbl")));
+ Assert.assertFalse(allows(AuthorizedResource.table("ctl", "db", "other")));
+ }
+
+ /**
+ * The three system-wide names are the ones a crossed wire would hide best: they are all "a name and a
+ * privilege", so asking about a vault through the resource check would look like it worked and would
+ * consult grants nobody made about the vault.
+ */
+ @Test
+ public void testEachSystemWideNameGoesToItsOwnCheck() {
+ Mockito.when(controller.checkResourcePriv(USER, "name", PrivPredicate.SELECT)).thenReturn(true);
+ Mockito.when(controller.checkWorkloadGroupPriv(USER, "name", PrivPredicate.SELECT)).thenReturn(false);
+ Mockito.when(controller.checkStorageVaultPriv(USER, "name", PrivPredicate.SELECT)).thenReturn(false);
+
+ Assert.assertTrue(allows(AuthorizedResource.resource("name")));
+ Assert.assertFalse(allows(AuthorizedResource.workloadGroup("name")));
+ Assert.assertFalse(allows(AuthorizedResource.storageVault("name")));
+
+ Mockito.verify(controller).checkResourcePriv(USER, "name", PrivPredicate.SELECT);
+ Mockito.verify(controller).checkWorkloadGroupPriv(USER, "name", PrivPredicate.SELECT);
+ Mockito.verify(controller).checkStorageVaultPriv(USER, "name", PrivPredicate.SELECT);
+ }
+
+ /** Which cloud object it is travels along, because the privileges live in different tables per type. */
+ @Test
+ public void testCloudObjectsCarryTheirTypeThrough() {
+ Mockito.when(controller.checkCloudPriv(USER, "cg", PrivPredicate.SELECT, ResourceTypeEnum.CLUSTER))
+ .thenReturn(true);
+
+ Assert.assertTrue(allows(AuthorizedResource.cloud(ResourceKind.CLOUD_COMPUTE_GROUP, "cg")));
+ Assert.assertFalse(allows(AuthorizedResource.cloud(ResourceKind.CLOUD_STAGE, "cg")));
+ }
+
+ @Test
+ public void testColumnsGoToTheColumnCheck() throws Exception {
+ Set columns = ImmutableSet.of("col1", "col2");
+
+ plugin.checkPrivilege(SUBJECT, AuthorizedResource.columns("ctl", "db", "tbl", columns), SELECT,
+ AccessContext.NONE);
+
+ Mockito.verify(controller).checkColsPriv(USER, "ctl", "db", "tbl", columns, PrivPredicate.SELECT);
+ }
+
+ /**
+ * The refused column is named in the message; that name is the answer and has to arrive intact - and in
+ * the bare form the controller wrote it, because the engine puts it back into an
+ * {@link AuthorizationException} on the way out and that class renders its own error code in front.
+ * Carrying the rendered form across would print that prefix twice on every denied column query.
+ */
+ @Test
+ public void testAColumnRefusalKeepsTheMessageThatNamesTheColumn() throws Exception {
+ AuthorizationException fromTheController = new AuthorizationException("no privilege on [col2]");
+ Mockito.doThrow(fromTheController).when(controller).checkColsPriv(
+ Mockito.any(), Mockito.anyString(), Mockito.anyString(), Mockito.anyString(),
+ Mockito.any(), Mockito.any());
+
+ AccessDeniedException denied = Assert.assertThrows(AccessDeniedException.class,
+ () -> plugin.checkPrivilege(SUBJECT,
+ AuthorizedResource.columns("ctl", "db", "tbl", ImmutableSet.of("col1", "col2")),
+ SELECT, AccessContext.NONE));
+
+ Assert.assertEquals("no privilege on [col2]", denied.getMessage());
+ Assert.assertEquals("legacy", denied.getDeniedBy().orElse(null));
+ Assert.assertEquals(fromTheController.getMessage(),
+ new AuthorizationException(denied.getMessage()).getMessage());
+ }
+
+ /**
+ * The batch mask question is asked of a controller that only answers per column, so it asks per column -
+ * and reports only the columns that came back masked, an absent entry being how "not masked" is said.
+ */
+ @Test
+ public void testDataMasksAreCollectedOneColumnAtATime() {
+ DataMaskSpec spec = new DataMaskSpec("policy", "CONCAT(LEFT(col1,1),'***')");
+ Mockito.when(controller.evalDataMaskPolicy(USER, "ctl", "db", "tbl", "col1"))
+ .thenReturn(Optional.of(spec));
+ Mockito.when(controller.evalDataMaskPolicy(USER, "ctl", "db", "tbl", "col2"))
+ .thenReturn(Optional.empty());
+
+ Map masks = plugin.getDataMasks(SUBJECT,
+ AuthorizedResource.table("ctl", "db", "tbl"),
+ new LinkedHashSet<>(ImmutableSet.of("col1", "col2")), AccessContext.NONE);
+
+ Assert.assertEquals(ImmutableSet.of("col1"), masks.keySet());
+ Assert.assertSame(spec, masks.get("col1"));
+ }
+
+ @Test
+ public void testClosingTheSourceClosesTheController() {
+ plugin.close();
+
+ Mockito.verify(controller).close();
+ }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/privileges/TestCheckPrivileges.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/privileges/TestCheckPrivileges.java
index 76c7d3d91eb11f..56344470523a22 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/nereids/privileges/TestCheckPrivileges.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/privileges/TestCheckPrivileges.java
@@ -21,6 +21,7 @@
import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.authorization.DataMaskSpec;
import org.apache.doris.authorization.RowFilterSpec;
+import org.apache.doris.authorization.spi.AuthorizationPlugin;
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.PrimitiveType;
@@ -130,7 +131,7 @@ public void testPrivilegesAndPolicies() throws Exception {
);
AccessControllerManager accessManager = Env.getCurrentEnv().getAccessManager();
- CatalogAccessController catalogAccessController = accessManager.getAccessControllerOrDefault(catalog);
+ AuthorizationPlugin catalogAccessController = accessManager.getAccessControllerOrDefault(catalog);
AccessControllerManager spyAccessManager = Mockito.spy(accessManager);
Mockito.doReturn(catalogAccessController).when(spyAccessManager)
.getAccessControllerOrDefault("internal");
From 12fa0a250fd365f93aeedde575e42643cc1f7f2d Mon Sep 17 00:00:00 2001
From: morningman
Date: Wed, 12 Aug 2026 20:21:18 +0800
Subject: [PATCH 08/22] [improvement](authorization) let the Ranger sources
answer the plugin contract themselves
The two Ranger sources shipped in the tree were reached through the adapter
for the older per-scope controller interface, so the contract had exactly one
native implementation - the built-in privilege model, which answers out of its
own tables. A contract that has only been implemented by the thing it was
extracted from has not been tried.
They now implement it directly: one question about a typed resource, answered
by refusing or not, dispatched inside the source. What each of them decides is
unchanged, down to walking the resource hierarchy one privilege per request
while remembering which ones an outer level already granted - that walk is why
the engine asks about a whole requirement rather than one action at a time.
Two things they used to reach into the engine for now arrive through the
context the engine hands them: the roles a Doris account holds, which a Ranger
policy may be written against and which only the engine can resolve, and
whether whoever governs instance scope already grants the privilege. The
latter is also configurable now, so a deployment can decide that inside what
Ranger governs only Ranger's policies grant anything - not even to an
administrator of the instance.
Recognising which question is being asked used to be object identity against
the engine's own predicate constants. Those constants cannot follow the
sources out of the engine, so the questions the engine asks by name are now
values in the neutral module, compared by equality and pinned on both sides:
a source that stopped recognising "may this subject see the object" would
silently answer a different question rather than fail.
Behaviour matrix unchanged. The refusal message for a column now names the
requirement in neutral terms.
---
.../authorization/AccessRequirements.java | 82 ++++
.../ranger/RangerAccessController.java | 124 ++++--
.../ranger/doris/DorisAccessType.java | 43 +-
.../doris/RangerDorisAccessController.java | 410 +++++++++---------
.../hive/RangerHiveAccessController.java | 270 ++++++------
.../RangerHiveAccessControllerFactory.java | 20 +-
.../privilege/AccessControllerManager.java | 97 +++--
.../privilege/EngineAuthorizationContext.java | 83 ++++
.../LegacyAccessControllerPlugin.java | 6 +-
.../RangerDorisAccessControllerFactory.java | 41 +-
...horization.spi.AuthorizationPluginFactory} | 2 +-
.../RangerGlobalScopeDeferenceTest.java | 167 ++++---
.../hive/RangerHiveAccessControllerTest.java | 96 ++++
.../AccessControlBehaviorBaselineTest.java | 12 +-
.../AccessRequirementVocabularyTest.java | 69 +++
.../EngineAuthorizationContextTest.java | 146 +++++++
...angerDorisAccessControllerFactoryTest.java | 27 +-
.../doris/mysql/privilege/RangerTest.java | 237 ++++++----
.../StubRangerAccessControllerFactory.java | 13 +-
...thorization.spi.AuthorizationPluginFactory | 18 +
...is.mysql.privilege.AccessControllerFactory | 1 -
21 files changed, 1381 insertions(+), 583 deletions(-)
create mode 100644 fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessRequirements.java
create mode 100644 fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/EngineAuthorizationContext.java
rename fe/fe-core/src/main/resources/META-INF/services/{org.apache.doris.mysql.privilege.AccessControllerFactory => org.apache.doris.authorization.spi.AuthorizationPluginFactory} (98%)
create mode 100644 fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessControllerTest.java
create mode 100644 fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/AccessRequirementVocabularyTest.java
create mode 100644 fe/fe-core/src/test/java/org/apache/doris/mysql/privilege/EngineAuthorizationContextTest.java
create mode 100644 fe/fe-core/src/test/resources/META-INF/services/org.apache.doris.authorization.spi.AuthorizationPluginFactory
diff --git a/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessRequirements.java b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessRequirements.java
new file mode 100644
index 00000000000000..805bfdbef39d2c
--- /dev/null
+++ b/fe/fe-authorization/fe-authorization-api/src/main/java/org/apache/doris/authorization/AccessRequirements.java
@@ -0,0 +1,82 @@
+// 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.doris.authorization;
+
+/**
+ * The requirements the engine asks about by name, so that a plugin can recognise which question it is being
+ * asked.
+ *
+ *
Doris does not ask "may this subject select"; it asks for a set of actions and how many of them are
+ * needed. A plugin that answers out of a model of its own has to map that back onto its own vocabulary, and
+ * for that it must be able to tell "may this subject read the table" apart from "may this subject see that the
+ * table exists" - two questions that differ only in which actions they name. Comparing against these
+ * constants is how that is done; they are values, so equality is the comparison.
+ *
+ *
These are the questions the engine has always asked, named here rather than left implicit in the
+ * built-in privilege model, because a plugin that lives outside this repository cannot read that model and
+ * would otherwise have to restate the action sets and drift when one of them changes.
+ *
+ *
A requirement not listed here is not a defect and must not be treated as one: privilege checks are also
+ * built at run time - granting a privilege requires holding both it and the right to grant it - and a plugin
+ * that recognises none of these is free to answer from the action set alone.
+ */
+public final class AccessRequirements {
+
+ /**
+ * May the subject see that the object exists at all. Not a privilege anyone grants: holding any privilege
+ * that implies knowledge of the object answers it.
+ */
+ public static final AccessRequirement VISIBILITY = AccessRequirement.anyOf(AccessAction.ADMIN,
+ AccessAction.SELECT, AccessAction.LOAD, AccessAction.ALTER, AccessAction.CREATE,
+ AccessAction.DROP, AccessAction.SHOW_VIEW);
+
+ /** May the subject read the object's data. */
+ public static final AccessRequirement SELECT = AccessRequirement.anyOf(AccessAction.ADMIN,
+ AccessAction.SELECT);
+
+ /** May the subject write data into the object. */
+ public static final AccessRequirement LOAD = AccessRequirement.anyOf(AccessAction.ADMIN,
+ AccessAction.LOAD);
+
+ /** May the subject change the object's definition. */
+ public static final AccessRequirement ALTER = AccessRequirement.anyOf(AccessAction.ADMIN,
+ AccessAction.ALTER);
+
+ /** May the subject create the object. */
+ public static final AccessRequirement CREATE = AccessRequirement.anyOf(AccessAction.ADMIN,
+ AccessAction.CREATE);
+
+ /** May the subject drop the object. */
+ public static final AccessRequirement DROP = AccessRequirement.anyOf(AccessAction.ADMIN,
+ AccessAction.DROP);
+
+ /** Is the subject an administrator of this instance. */
+ public static final AccessRequirement ADMINISTRATION = AccessRequirement.of(AccessAction.ADMIN);
+
+ /**
+ * Does the subject hold any privilege at all on the object. Asked where a statement needs the object to
+ * be usable without knowing yet what will be done with it.
+ */
+ public static final AccessRequirement ANY_PRIVILEGE = AccessRequirement.anyOf(AccessAction.NODE,
+ AccessAction.ADMIN, AccessAction.SELECT, AccessAction.LOAD, AccessAction.ALTER,
+ AccessAction.CREATE, AccessAction.DROP, AccessAction.USAGE, AccessAction.CLUSTER_USAGE,
+ AccessAction.STAGE_USAGE);
+
+ private AccessRequirements() {
+ }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/RangerAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/RangerAccessController.java
index 9ba6b19c44ac07..2a008e284098c3 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/RangerAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/RangerAccessController.java
@@ -17,15 +17,16 @@
package org.apache.doris.catalog.authorizer.ranger;
-import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.AccessContext;
+import org.apache.doris.authorization.AccessDeniedException;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AuthorizedResource;
+import org.apache.doris.authorization.AuthorizedSubject;
import org.apache.doris.authorization.DataMaskSpec;
import org.apache.doris.authorization.RowFilterSpec;
-import org.apache.doris.catalog.Env;
+import org.apache.doris.authorization.spi.AuthorizationContext;
+import org.apache.doris.authorization.spi.AuthorizationPlugin;
import org.apache.doris.catalog.authorizer.ranger.doris.DorisAccessType;
-import org.apache.doris.common.AuthorizationException;
-import org.apache.doris.mysql.privilege.AccessControllerManager;
-import org.apache.doris.mysql.privilege.CatalogAccessController;
-import org.apache.doris.mysql.privilege.PrivPredicate;
import com.google.common.collect.Lists;
import org.apache.commons.lang3.StringUtils;
@@ -38,30 +39,73 @@
import org.apache.ranger.plugin.service.RangerBasePlugin;
import java.util.Collection;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
-public abstract class RangerAccessController implements CatalogAccessController {
+/**
+ * What the Ranger-backed authorization sources have in common: they answer out of a Ranger service's
+ * policies, and they honour whoever governs instance scope.
+ */
+public abstract class RangerAccessController implements AuthorizationPlugin {
private static final Logger LOG = LogManager.getLogger(RangerAccessController.class);
protected static final String CLIENT_TYPE_DORIS = "doris";
/**
- * Whether the privilege is already held at global scope, which the Ranger plugins honour as a grant on
+ * Property switching off deference to whoever governs instance scope, so that a deployment can decide
+ * that inside what Ranger governs, only Ranger's policies grant anything - not even to an administrator
+ * of the instance. Defaults to deferring, which is what Doris did before this was a source's own choice.
+ */
+ public static final String DEFER_TO_GLOBAL_SCOPE_AUTHORITY = "ranger.defer_to_global_scope_authority";
+
+ private final AuthorizationContext context;
+ private final boolean deferToGlobalScopeAuthority;
+
+ protected RangerAccessController(Map properties, AuthorizationContext context) {
+ this.context = Objects.requireNonNull(context, "authorization context is required");
+ this.deferToGlobalScopeAuthority = deferenceFrom(properties);
+ }
+
+ /** What this source may ask the engine. */
+ protected AuthorizationContext getContext() {
+ return context;
+ }
+
+ /**
+ * Whether the requirement is already held at instance scope, which these sources honour as a grant on
* everything they govern.
*
- *
Global scope is not a Ranger catalog: it belongs to whichever controller {@code access_controller_type}
- * installs, so that is who gets asked. With the built-in controller there this reproduces "an administrator
- * of the cluster can reach a Ranger-governed catalog"; with Ranger installed globally, Ranger decides its
- * own exemptions and the built-in grants stay out of it. Deciding this here rather than in the engine is
- * what lets a third-party controller refuse the exemption outright.
- *
- *
Returns false without asking when this controller is itself the global-scope authority: the caller's
- * own global check answers the same question one line later.
+ *
Instance scope is not a Ranger service: it belongs to whichever source {@code access_controller_type}
+ * installs, so that is who the engine asks on our behalf. With the built-in model there this reproduces
+ * "an administrator of the cluster can reach a Ranger-governed catalog"; with Ranger installed for the
+ * instance, Ranger decides its own exemptions and the built-in grants stay out of it. Deciding it here
+ * rather than in the engine is what lets a source refuse the exemption outright - as this one does when
+ * configured to.
*/
- protected boolean grantedByGlobalScopeAuthority(UserIdentity currentUser, PrivPredicate wanted) {
- AccessControllerManager manager = Env.getCurrentEnv().getAccessManager();
- return !manager.isGlobalScopeAuthority(this) && manager.checkGlobalPriv(currentUser, wanted);
+ protected boolean grantedByGlobalScopeAuthority(AuthorizedSubject subject, AccessRequirement requirement) {
+ return deferToGlobalScopeAuthority && context.grantedByGlobalScopeAuthority(subject, requirement);
+ }
+
+ private static boolean deferenceFrom(Map properties) {
+ String configured = properties == null ? null : properties.get(DEFER_TO_GLOBAL_SCOPE_AUTHORITY);
+ if (configured == null) {
+ return true;
+ }
+ String value = configured.trim();
+ if ("true".equalsIgnoreCase(value)) {
+ return true;
+ }
+ if ("false".equalsIgnoreCase(value)) {
+ return false;
+ }
+ // Parsing this leniently would read a typo as "false" and silently take away an administrator's
+ // access to every Ranger-governed object.
+ throw new IllegalArgumentException(DEFER_TO_GLOBAL_SCOPE_AUTHORITY + " must be true or false, but is \""
+ + configured + "\"");
}
protected static boolean checkRequestResult(RangerAccessRequestImpl request,
@@ -88,8 +132,12 @@ protected static boolean checkRequestResult(RangerAccessRequestImpl request,
}
}
- public static void checkRequestResults(Collection results, String name)
- throws AuthorizationException {
+ /**
+ * Refuses on the first request Ranger denied, naming the resource that request was about - which is the
+ * answer when a batch of requests stands for the columns of one table.
+ */
+ protected void checkRequestResults(Collection results, String name,
+ AuthorizedResource resource) throws AccessDeniedException {
for (RangerAccessResult result : results) {
if (LOG.isDebugEnabled()) {
LOG.debug("request {} match policy {}", result.getAccessRequest(), result.getPolicyId());
@@ -98,11 +146,12 @@ public static void checkRequestResults(Collection results, S
if (LOG.isDebugEnabled()) {
LOG.debug(result.getReason());
}
- throw new AuthorizationException(String.format(
+ throw AccessDeniedException.withMessage(String.format(
"Permission denied: user [%s] does not have privilege for [%s] command on [%s]",
result.getAccessRequest().getUser(), name,
Optional.ofNullable(result.getAccessRequest().getResource().getAsString())
- .orElse("unknown resource").replaceAll("/", ".")));
+ .orElse("unknown resource").replaceAll("/", ".")),
+ resource, name());
}
}
}
@@ -117,10 +166,11 @@ private static String policyIdent(RangerAccessResult policy) {
}
@Override
- public List evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db,
- String tbl) {
- RangerAccessResourceImpl resource = createResource(ctl, db, tbl);
- RangerAccessRequestImpl request = createRequest(currentUser);
+ public List getRowFilters(AuthorizedSubject subject, AuthorizedResource.Table table,
+ AccessContext context) {
+ RangerAccessResourceImpl resource = createResource(table.getCatalog(), table.getDatabase(),
+ table.getTable());
+ RangerAccessRequestImpl request = createRequest(subject);
// If the access type is not set here, it defaults to ANY1 ACCESS.
// The internal logic of the ranger is to traverse all permission items.
// Since the ranger UI will set the access type to 'SELECT',
@@ -149,10 +199,22 @@ public List evalRowFilterPolicies(UserIdentity currentUser, Strin
}
@Override
- public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
+ public Map getDataMasks(AuthorizedSubject subject, AuthorizedResource.Table table,
+ Set columns, AccessContext context) {
+ Map masks = new HashMap<>();
+ for (String column : columns) {
+ // One request per column: a masking policy in Ranger is written against a column, and the plugin
+ // evaluates them one resource at a time.
+ evalDataMaskPolicy(subject, table, column).ifPresent(mask -> masks.put(column, mask));
+ }
+ return masks;
+ }
+
+ private Optional evalDataMaskPolicy(AuthorizedSubject subject, AuthorizedResource.Table table,
String col) {
- RangerAccessResourceImpl resource = createResource(ctl, db, tbl, col);
- RangerAccessRequestImpl request = createRequest(currentUser);
+ RangerAccessResourceImpl resource = createResource(table.getCatalog(), table.getDatabase(),
+ table.getTable(), col);
+ RangerAccessRequestImpl request = createRequest(subject);
request.setAccessType(DorisAccessType.SELECT.name());
request.setResource(resource);
@@ -190,7 +252,7 @@ public Optional evalDataMaskPolicy(UserIdentity currentUser, Strin
}
}
- protected abstract RangerAccessRequestImpl createRequest(UserIdentity currentUser);
+ protected abstract RangerAccessRequestImpl createRequest(AuthorizedSubject subject);
protected abstract RangerAccessResourceImpl createResource(String ctl, String db, String tbl);
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/doris/DorisAccessType.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/doris/DorisAccessType.java
index 68a926f39cbe45..711375303bc2e3 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/doris/DorisAccessType.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/doris/DorisAccessType.java
@@ -17,9 +17,9 @@
package org.apache.doris.catalog.authorizer.ranger.doris;
-import org.apache.doris.mysql.privilege.Privilege;
+import org.apache.doris.authorization.AccessAction;
-// Same as defined in PrivPredicate.java
+// The access types a Doris Ranger service defines, one per privilege Doris grants.
public enum DorisAccessType {
NODE,
ADMIN,
@@ -32,32 +32,41 @@ public enum DorisAccessType {
USAGE,
SHOW_VIEW,
NONE;
- public static DorisAccessType toAccessType(Privilege privilege) {
- switch (privilege) {
- case ADMIN_PRIV:
+
+ /**
+ * The access type standing for {@code action}. The three ways of using something are one access type
+ * here, which is a folding the Ranger service can afford and the engine cannot: its policies are written
+ * against the resource, so a policy on a compute group and one on a stage are told apart by what they are
+ * on rather than by the name of the privilege.
+ */
+ public static DorisAccessType of(AccessAction action) {
+ switch (action) {
+ case ADMIN:
return ADMIN;
- case NODE_PRIV:
+ case NODE:
return NODE;
- case GRANT_PRIV:
+ case GRANT:
return GRANT;
- case SELECT_PRIV:
+ case SELECT:
return SELECT;
- case LOAD_PRIV:
+ case LOAD:
return LOAD;
- case ALTER_PRIV:
+ case ALTER:
return ALTER;
- case CREATE_PRIV:
+ case CREATE:
return CREATE;
- case DROP_PRIV:
+ case DROP:
return DROP;
- case USAGE_PRIV:
- case STAGE_USAGE_PRIV:
- case CLUSTER_USAGE_PRIV:
+ case USAGE:
+ case STAGE_USAGE:
+ case CLUSTER_USAGE:
return USAGE;
- case SHOW_VIEW_PRIV:
+ case SHOW_VIEW:
return SHOW_VIEW;
default:
- return NONE;
+ // Guessing would ask Ranger about an access type its service definition does not have, and
+ // every such request is denied - an action added to Doris would silently stop being grantable.
+ throw new IllegalStateException("no Ranger access type for action " + action);
}
}
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/doris/RangerDorisAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/doris/RangerDorisAccessController.java
index d02c9c7f2eb1a9..d32f5a7639b2e5 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/doris/RangerDorisAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/doris/RangerDorisAccessController.java
@@ -17,13 +17,15 @@
package org.apache.doris.catalog.authorizer.ranger.doris;
-import org.apache.doris.analysis.ResourceTypeEnum;
-import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.AccessAction;
+import org.apache.doris.authorization.AccessContext;
+import org.apache.doris.authorization.AccessDeniedException;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AccessRequirements;
+import org.apache.doris.authorization.AuthorizedResource;
+import org.apache.doris.authorization.AuthorizedSubject;
+import org.apache.doris.authorization.spi.AuthorizationContext;
import org.apache.doris.catalog.authorizer.ranger.RangerAccessController;
-import org.apache.doris.common.AuthorizationException;
-import org.apache.doris.mysql.privilege.PrivBitSet;
-import org.apache.doris.mysql.privilege.PrivPredicate;
-import org.apache.doris.mysql.privilege.Privilege;
import org.apache.doris.resource.workloadgroup.WorkloadGroupMgr;
import com.google.common.annotations.VisibleForTesting;
@@ -36,11 +38,21 @@
import org.apache.ranger.plugin.service.RangerAuthContextListener;
import org.apache.ranger.plugin.service.RangerBasePlugin;
+import java.util.Collections;
import java.util.Date;
-import java.util.Set;
+import java.util.EnumSet;
+import java.util.Map;
+/**
+ * A Doris Ranger service: it answers about every kind of object Doris has, which is why it is also the one
+ * source that can be installed for the whole instance.
+ */
public class RangerDorisAccessController extends RangerAccessController {
private static final Logger LOG = LogManager.getLogger(RangerDorisAccessController.class);
+
+ /** The name this source is selected by, in {@code access_controller_type} and in catalog properties. */
+ public static final String NAME = "ranger-doris";
+
// ranger must set name, we agreed that this name must be used
private static final String GLOBAL_PRIV_FIXED_NAME = "*";
@@ -49,11 +61,14 @@ public class RangerDorisAccessController extends RangerAccessController {
// "ranger-doris-audit-log-flusher-timer", true);
// private RangerHiveAuditHandler auditHandler;
- public RangerDorisAccessController(String serviceName) {
- this(serviceName, null);
+ public RangerDorisAccessController(String serviceName, Map properties,
+ AuthorizationContext context) {
+ this(serviceName, null, properties, context);
}
- public RangerDorisAccessController(String serviceName, RangerAuthContextListener rangerAuthContextListener) {
+ public RangerDorisAccessController(String serviceName, RangerAuthContextListener rangerAuthContextListener,
+ Map properties, AuthorizationContext context) {
+ super(properties, context);
dorisPlugin = new RangerDorisPlugin(serviceName, rangerAuthContextListener);
// auditHandler = new RangerHiveAuditHandler(dorisPlugin.getConfig());
// start a timed log flusher
@@ -61,22 +76,120 @@ public RangerDorisAccessController(String serviceName, RangerAuthContextListener
}
@VisibleForTesting
- public RangerDorisAccessController(RangerBasePlugin plugin) {
+ public RangerDorisAccessController(RangerBasePlugin plugin, AuthorizationContext context) {
+ this(plugin, Collections.emptyMap(), context);
+ }
+
+ @VisibleForTesting
+ public RangerDorisAccessController(RangerBasePlugin plugin, Map properties,
+ AuthorizationContext context) {
+ super(properties, context);
dorisPlugin = plugin;
}
- private RangerAccessRequestImpl createRequest(UserIdentity currentUser, DorisAccessType accessType) {
- RangerAccessRequestImpl request = createRequest(currentUser);
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public void checkPrivilege(AuthorizedSubject subject, AuthorizedResource resource,
+ AccessRequirement requirement, AccessContext context) throws AccessDeniedException {
+ switch (resource.getKind()) {
+ case GLOBAL:
+ refuseUnless(checkGlobal(subject, requirement), subject, resource, requirement);
+ return;
+ case CATALOG:
+ refuseUnless(checkCatalog(subject, ((AuthorizedResource.Catalog) resource).getCatalog(),
+ requirement), subject, resource, requirement);
+ return;
+ case DATABASE: {
+ AuthorizedResource.Database database = (AuthorizedResource.Database) resource;
+ refuseUnless(checkDatabase(subject, database.getCatalog(), database.getDatabase(), requirement),
+ subject, resource, requirement);
+ return;
+ }
+ case TABLE: {
+ AuthorizedResource.Table table = (AuthorizedResource.Table) resource;
+ refuseUnless(checkTable(subject, table.getCatalog(), table.getDatabase(), table.getTable(),
+ requirement), subject, resource, requirement);
+ return;
+ }
+ case COLUMNS:
+ checkColumns(subject, (AuthorizedResource.Columns) resource, requirement);
+ return;
+ case RESOURCE: {
+ EnumSet granted = EnumSet.noneOf(AccessAction.class);
+ refuseUnless(checkGlobalInternal(subject, requirement, granted)
+ || ask(subject, requirement, granted, new RangerDorisResource(
+ DorisObjectType.RESOURCE, named(resource))),
+ subject, resource, requirement);
+ return;
+ }
+ case WORKLOAD_GROUP: {
+ // For compatibility with older versions, it is not needed to check the privileges of the
+ // default group.
+ if (WorkloadGroupMgr.DEFAULT_GROUP_NAME.equals(named(resource))) {
+ return;
+ }
+ EnumSet granted = EnumSet.noneOf(AccessAction.class);
+ refuseUnless(checkGlobalInternal(subject, requirement, granted)
+ || ask(subject, requirement, granted, new RangerDorisResource(
+ DorisObjectType.WORKLOAD_GROUP, named(resource))),
+ subject, resource, requirement);
+ return;
+ }
+ case STORAGE_VAULT: {
+ EnumSet granted = EnumSet.noneOf(AccessAction.class);
+ refuseUnless(checkGlobalInternal(subject, requirement, granted)
+ || ask(subject, requirement, granted, new RangerDorisResource(
+ DorisObjectType.STORAGE_VAULT, named(resource))),
+ subject, resource, requirement);
+ return;
+ }
+ case CLOUD_COMPUTE_GROUP: {
+ EnumSet granted = EnumSet.noneOf(AccessAction.class);
+ refuseUnless(checkGlobalInternal(subject, requirement, granted)
+ || ask(subject, requirement, granted, new RangerDorisResource(
+ DorisObjectType.COMPUTE_GROUP, named(resource))),
+ subject, resource, requirement);
+ return;
+ }
+ case CLOUD_GENERAL:
+ case CLOUD_STAGE:
+ case CLOUD_STORAGE_VAULT:
+ // A general cloud resource is asked about as a resource and a vault as a vault; a stage is
+ // reached through `copy into`, which is on its way out, so Ranger never governed it.
+ throw AccessDeniedException.of(subject, resource, requirement, NAME);
+ default:
+ throw new IllegalStateException("the Ranger Doris service has no answer for resource kind "
+ + resource.getKind());
+ }
+ }
+
+ private static String named(AuthorizedResource resource) {
+ return ((AuthorizedResource.Named) resource).getName();
+ }
+
+ private void refuseUnless(boolean allowed, AuthorizedSubject subject, AuthorizedResource resource,
+ AccessRequirement requirement) throws AccessDeniedException {
+ if (!allowed) {
+ throw AccessDeniedException.of(subject, resource, requirement, NAME);
+ }
+ }
+
+ private RangerAccessRequestImpl createRequest(AuthorizedSubject subject, DorisAccessType accessType) {
+ RangerAccessRequestImpl request = createRequest(subject);
request.setAction(accessType.name());
request.setAccessType(accessType.name());
return request;
}
@Override
- protected RangerAccessRequestImpl createRequest(UserIdentity currentUser) {
+ protected RangerAccessRequestImpl createRequest(AuthorizedSubject subject) {
RangerAccessRequestImpl request = new RangerAccessRequestImpl();
- request.setUser(currentUser.getQualifiedUser());
- request.setClientIPAddress(currentUser.getHost());
+ request.setUser(subject.getUser());
+ request.setClientIPAddress(subject.getHost());
request.setClusterType(CLIENT_TYPE_DORIS);
request.setClientType(CLIENT_TYPE_DORIS);
request.setAccessTime(new Date());
@@ -84,9 +197,9 @@ protected RangerAccessRequestImpl createRequest(UserIdentity currentUser) {
return request;
}
- private boolean checkPrivilegeByPlugin(UserIdentity currentUser, DorisAccessType accessType,
+ private boolean checkPrivilegeByPlugin(AuthorizedSubject subject, DorisAccessType accessType,
RangerDorisResource resource) {
- RangerAccessRequestImpl request = createRequest(currentUser, accessType);
+ RangerAccessRequestImpl request = createRequest(subject, accessType);
request.setResource(resource);
if (LOG.isDebugEnabled()) {
LOG.debug("ranger request: {}", request);
@@ -95,8 +208,8 @@ private boolean checkPrivilegeByPlugin(UserIdentity currentUser, DorisAccessType
return checkRequestResult(request, result, accessType.name());
}
- private boolean checkShowPrivilegeByPlugin(UserIdentity currentUser, RangerDorisResource resource) {
- RangerAccessRequestImpl request = createRequest(currentUser);
+ private boolean checkShowPrivilegeByPlugin(AuthorizedSubject subject, RangerDorisResource resource) {
+ RangerAccessRequestImpl request = createRequest(subject);
request.setResource(resource);
request.setResourceMatchingScope(ResourceMatchingScope.SELF_OR_DESCENDANTS);
if (LOG.isDebugEnabled()) {
@@ -106,215 +219,133 @@ private boolean checkShowPrivilegeByPlugin(UserIdentity currentUser, RangerDoris
return checkRequestResult(request, result, DorisAccessType.NONE.name());
}
- private boolean checkPrivilege(UserIdentity currentUser, PrivPredicate wanted,
- RangerDorisResource resource, PrivBitSet checkedPrivs) {
- PrivBitSet copy = wanted.getPrivs().copy();
- // avoid duplicate check auth at different levels
- copy.remove(checkedPrivs);
- for (Privilege privilege : copy.toPrivilegeList()) {
- boolean res = checkPrivilegeByPlugin(currentUser, DorisAccessType.toAccessType(privilege), resource);
- if (res) {
- checkedPrivs.set(privilege.getIdx());
+ /**
+ * Asks Ranger about the actions still outstanding, one request each, until the requirement is met.
+ *
+ *
{@code granted} carries what the levels already walked have granted, so a privilege answered on the
+ * catalog is not asked about again on the database and on the table: the same requirement checked down a
+ * three-level path costs one evaluation per action, not three.
+ */
+ private boolean ask(AuthorizedSubject subject, AccessRequirement requirement,
+ EnumSet granted, RangerDorisResource resource) {
+ EnumSet outstanding = EnumSet.copyOf(requirement.getActions());
+ outstanding.removeAll(granted);
+ for (AccessAction action : outstanding) {
+ if (checkPrivilegeByPlugin(subject, DorisAccessType.of(action), resource)) {
+ granted.add(action);
}
- if (Privilege.satisfy(checkedPrivs, wanted)) {
+ if (requirement.isSatisfiedBy(granted)) {
return true;
}
}
return false;
}
- @Override
- public boolean checkGlobalPriv(UserIdentity currentUser, PrivPredicate wanted) {
- PrivBitSet checkedPrivs = PrivBitSet.of();
- return checkGlobalPrivInternal(currentUser, wanted, checkedPrivs);
+ private boolean checkGlobal(AuthorizedSubject subject, AccessRequirement requirement) {
+ return checkGlobalInternal(subject, requirement, EnumSet.noneOf(AccessAction.class));
}
- private boolean checkGlobalPrivInternal(UserIdentity currentUser, PrivPredicate wanted, PrivBitSet checkedPrivs) {
- RangerDorisResource resource = new RangerDorisResource(DorisObjectType.GLOBAL, GLOBAL_PRIV_FIXED_NAME);
- return checkPrivilege(currentUser, wanted, resource, checkedPrivs);
+ private boolean checkGlobalInternal(AuthorizedSubject subject, AccessRequirement requirement,
+ EnumSet granted) {
+ return ask(subject, requirement, granted,
+ new RangerDorisResource(DorisObjectType.GLOBAL, GLOBAL_PRIV_FIXED_NAME));
}
- @Override
- public boolean checkCtlPriv(UserIdentity currentUser, String ctl, PrivPredicate wanted) {
- PrivBitSet checkedPrivs = PrivBitSet.of();
- if (checkGlobalPrivInternal(currentUser, wanted, checkedPrivs)
- || checkCtlPrivInternal(currentUser, ctl, wanted, checkedPrivs)) {
- return true;
- }
- if (wanted == PrivPredicate.SHOW && checkAnyPrivWithinCtl(currentUser, ctl)) {
+ private boolean checkCatalog(AuthorizedSubject subject, String ctl, AccessRequirement requirement) {
+ EnumSet granted = EnumSet.noneOf(AccessAction.class);
+ if (checkGlobalInternal(subject, requirement, granted)
+ || checkCatalogInternal(subject, ctl, requirement, granted)) {
return true;
}
- return false;
- }
-
- private boolean checkCtlPrivInternal(UserIdentity currentUser, String ctl, PrivPredicate wanted,
- PrivBitSet checkedPrivs) {
- RangerDorisResource resource = new RangerDorisResource(DorisObjectType.CATALOG, ctl);
- return checkPrivilege(currentUser, wanted, resource, checkedPrivs);
+ return isVisibility(requirement) && anyPrivilegeWithin(subject,
+ new RangerDorisResource(DorisObjectType.CATALOG, ctl));
}
- private boolean checkAnyPrivWithinCtl(UserIdentity currentUser, String ctl) {
- RangerDorisResource resource = new RangerDorisResource(DorisObjectType.CATALOG, ctl);
- return checkShowPrivilegeByPlugin(currentUser, resource);
+ private boolean checkCatalogInternal(AuthorizedSubject subject, String ctl, AccessRequirement requirement,
+ EnumSet granted) {
+ return ask(subject, requirement, granted, new RangerDorisResource(DorisObjectType.CATALOG, ctl));
}
- @Override
- public boolean checkDbPriv(UserIdentity currentUser, String ctl, String db, PrivPredicate wanted) {
- if (grantedByGlobalScopeAuthority(currentUser, wanted)) {
+ private boolean checkDatabase(AuthorizedSubject subject, String ctl, String db,
+ AccessRequirement requirement) {
+ if (grantedByGlobalScopeAuthority(subject, requirement)) {
return true;
}
- PrivBitSet checkedPrivs = PrivBitSet.of();
- if (checkGlobalPrivInternal(currentUser, wanted, checkedPrivs)
- || checkCtlPrivInternal(currentUser, ctl, wanted, checkedPrivs)
- || checkDbPrivInternal(currentUser, ctl, db, wanted, checkedPrivs)) {
+ EnumSet granted = EnumSet.noneOf(AccessAction.class);
+ if (checkGlobalInternal(subject, requirement, granted)
+ || checkCatalogInternal(subject, ctl, requirement, granted)
+ || checkDatabaseInternal(subject, ctl, db, requirement, granted)) {
return true;
}
- if (wanted == PrivPredicate.SHOW && checkAnyPrivWithinDb(currentUser, ctl, db)) {
- return true;
- }
- return false;
+ return isVisibility(requirement) && anyPrivilegeWithin(subject,
+ new RangerDorisResource(DorisObjectType.DATABASE, ctl, db));
}
- private boolean checkDbPrivInternal(UserIdentity currentUser, String ctl, String db, PrivPredicate wanted,
- PrivBitSet checkedPrivs) {
- RangerDorisResource resource = new RangerDorisResource(DorisObjectType.DATABASE, ctl,
- db);
- return checkPrivilege(currentUser, wanted, resource, checkedPrivs);
+ private boolean checkDatabaseInternal(AuthorizedSubject subject, String ctl, String db,
+ AccessRequirement requirement, EnumSet granted) {
+ return ask(subject, requirement, granted, new RangerDorisResource(DorisObjectType.DATABASE, ctl, db));
}
- private boolean checkAnyPrivWithinDb(UserIdentity currentUser, String ctl, String db) {
- RangerDorisResource resource = new RangerDorisResource(DorisObjectType.DATABASE, ctl,
- db);
- return checkShowPrivilegeByPlugin(currentUser, resource);
- }
-
- @Override
- public boolean checkTblPriv(UserIdentity currentUser, String ctl, String db, String tbl, PrivPredicate wanted) {
- if (grantedByGlobalScopeAuthority(currentUser, wanted)) {
+ private boolean checkTable(AuthorizedSubject subject, String ctl, String db, String tbl,
+ AccessRequirement requirement) {
+ if (grantedByGlobalScopeAuthority(subject, requirement)) {
return true;
}
- PrivBitSet checkedPrivs = PrivBitSet.of();
- if (checkGlobalPrivInternal(currentUser, wanted, checkedPrivs)
- || checkCtlPrivInternal(currentUser, ctl, wanted, checkedPrivs)
- || checkDbPrivInternal(currentUser, ctl, db, wanted, checkedPrivs)
- || checkTblPrivInternal(currentUser, ctl, db, tbl, wanted, checkedPrivs)) {
+ EnumSet granted = EnumSet.noneOf(AccessAction.class);
+ if (checkGlobalInternal(subject, requirement, granted)
+ || checkCatalogInternal(subject, ctl, requirement, granted)
+ || checkDatabaseInternal(subject, ctl, db, requirement, granted)
+ || checkTableInternal(subject, ctl, db, tbl, requirement, granted)) {
return true;
}
- if (wanted == PrivPredicate.SHOW && checkAnyPrivWithinTbl(currentUser, ctl, db, tbl)) {
- return true;
- }
- return false;
+ return isVisibility(requirement) && anyPrivilegeWithin(subject,
+ new RangerDorisResource(DorisObjectType.TABLE, ctl, db, tbl));
}
- private boolean checkTblPrivInternal(UserIdentity currentUser, String ctl, String db, String tbl,
- PrivPredicate wanted, PrivBitSet checkedPrivs) {
- RangerDorisResource resource = new RangerDorisResource(DorisObjectType.TABLE,
- ctl, db, tbl);
- return checkPrivilege(currentUser, wanted, resource, checkedPrivs);
- }
-
- private boolean checkAnyPrivWithinTbl(UserIdentity currentUser, String ctl, String db, String tbl) {
- RangerDorisResource resource = new RangerDorisResource(DorisObjectType.TABLE,
- ctl, db, tbl);
- return checkShowPrivilegeByPlugin(currentUser, resource);
+ private boolean checkTableInternal(AuthorizedSubject subject, String ctl, String db, String tbl,
+ AccessRequirement requirement, EnumSet granted) {
+ return ask(subject, requirement, granted, new RangerDorisResource(DorisObjectType.TABLE, ctl, db, tbl));
}
- @Override
- public void checkColsPriv(UserIdentity currentUser, String ctl, String db, String tbl, Set cols,
- PrivPredicate wanted) throws AuthorizationException {
- if (grantedByGlobalScopeAuthority(currentUser, wanted)) {
+ private void checkColumns(AuthorizedSubject subject, AuthorizedResource.Columns columns,
+ AccessRequirement requirement) throws AccessDeniedException {
+ if (grantedByGlobalScopeAuthority(subject, requirement)) {
return;
}
- PrivBitSet checkedPrivs = PrivBitSet.of();
- boolean hasTablePriv = checkGlobalPrivInternal(currentUser, wanted, checkedPrivs)
- || checkCtlPrivInternal(currentUser, ctl, wanted, checkedPrivs)
- || checkDbPrivInternal(currentUser, ctl, db, wanted, checkedPrivs)
- || checkTblPrivInternal(currentUser, ctl, db, tbl, wanted, checkedPrivs);
+ String ctl = columns.getCatalog();
+ String db = columns.getDatabase();
+ String tbl = columns.getTable();
+ EnumSet granted = EnumSet.noneOf(AccessAction.class);
+ boolean hasTablePriv = checkGlobalInternal(subject, requirement, granted)
+ || checkCatalogInternal(subject, ctl, requirement, granted)
+ || checkDatabaseInternal(subject, ctl, db, requirement, granted)
+ || checkTableInternal(subject, ctl, db, tbl, requirement, granted);
if (hasTablePriv) {
return;
}
- for (String col : cols) {
- if (!checkColPrivInternal(currentUser, ctl, db, tbl, col, wanted, checkedPrivs.copy())) {
- throw new AuthorizationException(String.format(
+ for (String col : columns.getColumns()) {
+ // Each column starts from what the levels above granted, and none of them may add to it: a
+ // privilege held on one column says nothing about the next one.
+ if (!ask(subject, requirement, EnumSet.copyOf(granted),
+ new RangerDorisResource(DorisObjectType.COLUMN, ctl, db, tbl, col))) {
+ throw AccessDeniedException.withMessage(String.format(
"Permission denied: user [%s] does not have privilege for [%s] command on [%s].[%s].[%s].[%s]",
- currentUser, wanted, ctl, db, tbl, col));
+ subject, requirement, ctl, db, tbl, col), columns, NAME);
}
}
}
- private boolean checkColPrivInternal(UserIdentity currentUser, String ctl, String db, String tbl, String col,
- PrivPredicate wanted, PrivBitSet checkedPrivs) {
- RangerDorisResource resource = new RangerDorisResource(DorisObjectType.COLUMN,
- ctl, db, tbl, col);
- return checkPrivilege(currentUser, wanted, resource, checkedPrivs);
- }
-
- @Override
- public boolean checkCloudPriv(UserIdentity currentUser, String cloudName,
- PrivPredicate wanted, ResourceTypeEnum type) {
- // only support CLUSTER,
- // STORAGE_VAULT should call `checkStorageVaultPriv`
- // GENERAL should call `checkResourcePriv`
- // STAGE is used to support `copy into`, but this feature will soon expire,
- // so it is no longer supported through Ranger
- if (!ResourceTypeEnum.CLUSTER.equals(type)) {
- return false;
- }
- PrivBitSet checkedPrivs = PrivBitSet.of();
- return checkGlobalPrivInternal(currentUser, wanted, checkedPrivs)
- || checkComputeGroupPrivInternal(currentUser, cloudName, wanted, checkedPrivs);
- }
-
- private boolean checkComputeGroupPrivInternal(UserIdentity currentUser, String computeGroupName,
- PrivPredicate wanted,
- PrivBitSet checkedPrivs) {
- RangerDorisResource resource = new RangerDorisResource(DorisObjectType.COMPUTE_GROUP, computeGroupName);
- return checkPrivilege(currentUser, wanted, resource, checkedPrivs);
+ /**
+ * Whether this is the question "may the subject see the object at all", which Ranger answers by looking
+ * for any policy anywhere under it rather than for a privilege on it.
+ */
+ private static boolean isVisibility(AccessRequirement requirement) {
+ return AccessRequirements.VISIBILITY.equals(requirement);
}
- @Override
- public boolean checkStorageVaultPriv(UserIdentity currentUser, String storageVaultName, PrivPredicate wanted) {
- PrivBitSet checkedPrivs = PrivBitSet.of();
- return checkGlobalPrivInternal(currentUser, wanted, checkedPrivs)
- || checkStorageVaultPrivInternal(currentUser, storageVaultName, wanted, checkedPrivs);
- }
-
- private boolean checkStorageVaultPrivInternal(UserIdentity currentUser, String storageVaultName,
- PrivPredicate wanted,
- PrivBitSet checkedPrivs) {
- RangerDorisResource resource = new RangerDorisResource(DorisObjectType.STORAGE_VAULT, storageVaultName);
- return checkPrivilege(currentUser, wanted, resource, checkedPrivs);
- }
-
- @Override
- public boolean checkResourcePriv(UserIdentity currentUser, String resourceName, PrivPredicate wanted) {
- PrivBitSet checkedPrivs = PrivBitSet.of();
- return checkGlobalPrivInternal(currentUser, wanted, checkedPrivs)
- || checkResourcePrivInternal(currentUser, resourceName, wanted, checkedPrivs);
- }
-
- private boolean checkResourcePrivInternal(UserIdentity currentUser, String resourceName, PrivPredicate wanted,
- PrivBitSet checkedPrivs) {
- RangerDorisResource resource = new RangerDorisResource(DorisObjectType.RESOURCE, resourceName);
- return checkPrivilege(currentUser, wanted, resource, checkedPrivs);
- }
-
- @Override
- public boolean checkWorkloadGroupPriv(UserIdentity currentUser, String workloadGroupName, PrivPredicate wanted) {
- // For compatibility with older versions, it is not needed to check the privileges of the default group.
- if (WorkloadGroupMgr.DEFAULT_GROUP_NAME.equals(workloadGroupName)) {
- return true;
- }
- PrivBitSet checkedPrivs = PrivBitSet.of();
- return checkGlobalPrivInternal(currentUser, wanted, checkedPrivs)
- || checkWorkloadGroupInternal(currentUser, workloadGroupName, wanted, checkedPrivs);
- }
-
- private boolean checkWorkloadGroupInternal(UserIdentity currentUser, String workloadGroupName, PrivPredicate wanted,
- PrivBitSet checkedPrivs) {
- RangerDorisResource resource = new RangerDorisResource(DorisObjectType.WORKLOAD_GROUP, workloadGroupName);
- return checkPrivilege(currentUser, wanted, resource, checkedPrivs);
+ private boolean anyPrivilegeWithin(AuthorizedSubject subject, RangerDorisResource resource) {
+ return checkShowPrivilegeByPlugin(subject, resource);
}
@Override
@@ -338,17 +369,4 @@ protected RangerBasePlugin getPlugin() {
protected RangerAccessResultProcessor getAccessResultProcessor() {
return null;
}
-
- // For test only
- public static void main(String[] args) {
- RangerDorisAccessController ac = new RangerDorisAccessController("doris");
- UserIdentity user = new UserIdentity("user1", "127.0.0.1");
- user.setIsAnalyzed();
- boolean res = ac.checkDbPriv(user, "internal", "db1", PrivPredicate.SHOW);
- System.out.println("res: " + res);
- user = new UserIdentity("user2", "127.0.0.1");
- user.setIsAnalyzed();
- res = ac.checkTblPriv(user, "internal", "db1", "tbl1", PrivPredicate.SELECT);
- System.out.println("res: " + res);
- }
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java
index e42464d3f56e5c..d6f1bc9e7a30e8 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessController.java
@@ -17,17 +17,19 @@
package org.apache.doris.catalog.authorizer.ranger.hive;
-import org.apache.doris.analysis.ResourceTypeEnum;
-import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.authorization.AccessContext;
+import org.apache.doris.authorization.AccessDeniedException;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AccessRequirements;
+import org.apache.doris.authorization.AuthorizedResource;
+import org.apache.doris.authorization.AuthorizedSubject;
import org.apache.doris.authorization.DataMaskSpec;
import org.apache.doris.authorization.RowFilterSpec;
-import org.apache.doris.catalog.Env;
+import org.apache.doris.authorization.spi.AuthorizationContext;
import org.apache.doris.catalog.authorizer.ranger.RangerAccessController;
-import org.apache.doris.common.AuthorizationException;
import org.apache.doris.common.ThreadPoolManager;
-import org.apache.doris.mysql.privilege.PrivPredicate;
-import com.google.common.collect.Maps;
+import com.google.common.annotations.VisibleForTesting;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.ranger.plugin.policyengine.RangerAccessRequest;
@@ -40,21 +42,30 @@
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
-import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
-import java.util.stream.Collectors;
+/**
+ * A Hive Ranger service governing one catalog: it knows databases, tables and columns, and nothing else Doris
+ * has. What it is not asked about it refuses, except for the two kinds it has to let through for a catalog
+ * bound to it to be usable at all - the catalog itself, and workload groups, neither of which a Hive service
+ * has policies for.
+ */
public class RangerHiveAccessController extends RangerAccessController {
private static final Logger LOG = LogManager.getLogger(RangerHiveAccessController.class);
private static final ScheduledThreadPoolExecutor LOG_FLUSH_TIMER = ThreadPoolManager.newDaemonScheduledThreadPool(1,
"ranger-hive-audit-log-flusher-timer", true);
+
+ /** The name this source is selected by in catalog properties. */
+ public static final String NAME = "ranger-hive";
+
private RangerHivePlugin hivePlugin;
private RangerHiveAuditHandler auditHandler;
private ScheduledFuture> logFlushFuture;
@@ -63,12 +74,13 @@ public class RangerHiveAccessController extends RangerAccessController {
private final ReentrantReadWriteLock lifecycleLock = new ReentrantReadWriteLock();
private boolean closed;
- public RangerHiveAccessController(Map properties) {
- this(properties, null);
+ public RangerHiveAccessController(Map properties, AuthorizationContext context) {
+ this(properties, null, context);
}
public RangerHiveAccessController(Map properties,
- RangerAuthContextListener rangerAuthContextListener) {
+ RangerAuthContextListener rangerAuthContextListener, AuthorizationContext context) {
+ super(properties, context);
String serviceName = properties.get("ranger.service.name");
hivePlugin = new RangerHivePlugin(serviceName, rangerAuthContextListener);
auditHandler = new RangerHiveAuditHandler(hivePlugin.getConfig());
@@ -77,6 +89,11 @@ public RangerHiveAccessController(Map properties,
new RangerHiveAuditLogFlusher(auditHandler), 10, 20L, TimeUnit.SECONDS);
}
+ @Override
+ public String name() {
+ return NAME;
+ }
+
@Override
public void close() {
lifecycleLock.writeLock().lock();
@@ -110,8 +127,85 @@ public void close() {
}
}
- private RangerAccessRequestImpl createRequest(UserIdentity currentUser, HiveAccessType accessType) {
- RangerAccessRequestImpl request = createRequest(currentUser);
+ @Override
+ public void checkPrivilege(AuthorizedSubject subject, AuthorizedResource resource,
+ AccessRequirement requirement, AccessContext context) throws AccessDeniedException {
+ switch (resource.getKind()) {
+ case GLOBAL:
+ // A Hive service has no notion of an instance-wide privilege, so this is entirely whoever
+ // governs instance scope. Installed as that authority itself, it grants nothing: it would
+ // otherwise be asking itself, and this configuration is not one a Hive service can serve.
+ refuseUnless(grantedByGlobalScopeAuthority(subject, requirement), subject, resource, requirement);
+ return;
+ case CATALOG:
+ // The catalog is the thing bound to this service; the policies are about what is inside it.
+ return;
+ case DATABASE: {
+ AuthorizedResource.Database database = (AuthorizedResource.Database) resource;
+ refuseUnless(checkResource(subject, requirement,
+ new RangerHiveResource(HiveObjectType.DATABASE, database.getDatabase())),
+ subject, resource, requirement);
+ return;
+ }
+ case TABLE: {
+ AuthorizedResource.Table table = (AuthorizedResource.Table) resource;
+ refuseUnless(checkResource(subject, requirement,
+ new RangerHiveResource(HiveObjectType.TABLE, table.getDatabase(), table.getTable())),
+ subject, resource, requirement);
+ return;
+ }
+ case COLUMNS:
+ checkColumns(subject, (AuthorizedResource.Columns) resource, requirement);
+ return;
+ case WORKLOAD_GROUP:
+ // Not support workload group privilege in ranger hive plugin.
+ // So always allow to pass the check
+ return;
+ case RESOURCE:
+ case STORAGE_VAULT:
+ case CLOUD_GENERAL:
+ case CLOUD_COMPUTE_GROUP:
+ case CLOUD_STAGE:
+ case CLOUD_STORAGE_VAULT:
+ throw AccessDeniedException.of(subject, resource, requirement, NAME);
+ default:
+ throw new IllegalStateException("the Ranger Hive service has no answer for resource kind "
+ + resource.getKind());
+ }
+ }
+
+ private void refuseUnless(boolean allowed, AuthorizedSubject subject, AuthorizedResource resource,
+ AccessRequirement requirement) throws AccessDeniedException {
+ if (!allowed) {
+ throw AccessDeniedException.of(subject, resource, requirement, NAME);
+ }
+ }
+
+ private boolean checkResource(AuthorizedSubject subject, AccessRequirement requirement,
+ RangerHiveResource resource) {
+ if (grantedByGlobalScopeAuthority(subject, requirement)) {
+ return true;
+ }
+ return checkPrivilege(subject, accessTypeOf(requirement), resource);
+ }
+
+ private void checkColumns(AuthorizedSubject subject, AuthorizedResource.Columns columns,
+ AccessRequirement requirement) throws AccessDeniedException {
+ if (grantedByGlobalScopeAuthority(subject, requirement)) {
+ return;
+ }
+ List resources = new ArrayList<>();
+ for (String col : columns.getColumns()) {
+ RangerHiveResource resource = new RangerHiveResource(HiveObjectType.COLUMN,
+ columns.getDatabase(), columns.getTable(), col);
+ resources.add(resource);
+ }
+
+ checkPrivileges(subject, accessTypeOf(requirement), resources, columns);
+ }
+
+ private RangerAccessRequestImpl createRequest(AuthorizedSubject subject, HiveAccessType accessType) {
+ RangerAccessRequestImpl request = createRequest(subject);
if (accessType == HiveAccessType.USE) {
request.setAccessType(RangerPolicyEngine.ANY_ACCESS);
} else {
@@ -121,14 +215,13 @@ private RangerAccessRequestImpl createRequest(UserIdentity currentUser, HiveAcce
}
@Override
- protected RangerAccessRequestImpl createRequest(UserIdentity currentUser) {
+ protected RangerAccessRequestImpl createRequest(AuthorizedSubject subject) {
RangerAccessRequestImpl request = new RangerAccessRequestImpl();
- String user = currentUser.getQualifiedUser();
- request.setUser(user);
- Set roles = Env.getCurrentEnv().getAuth().getRolesByUser(currentUser, false);
- request.setUserRoles(roles.stream().collect(
- Collectors.toSet()));
- request.setClientIPAddress(currentUser.getHost());
+ request.setUser(subject.getUser());
+ // Policies in Ranger may be written against a role rather than a user, and the roles a Doris account
+ // holds are the engine's to know, not this service's.
+ request.setUserRoles(getContext().rolesOf(subject));
+ request.setClientIPAddress(subject.getHost());
request.setClusterType(CLIENT_TYPE_DORIS);
request.setClientType(CLIENT_TYPE_DORIS);
request.setAccessTime(new Date());
@@ -136,35 +229,36 @@ protected RangerAccessRequestImpl createRequest(UserIdentity currentUser) {
return request;
}
- private void checkPrivileges(UserIdentity currentUser, HiveAccessType accessType,
- List hiveResources) throws AuthorizationException {
+ private void checkPrivileges(AuthorizedSubject subject, HiveAccessType accessType,
+ List hiveResources, AuthorizedResource asked) throws AccessDeniedException {
lifecycleLock.readLock().lock();
try {
if (closed) {
- throw new AuthorizationException("Ranger Hive access controller has been closed");
+ throw AccessDeniedException.withMessage("Ranger Hive access controller has been closed",
+ asked, NAME);
}
List requests = new ArrayList<>();
for (RangerHiveResource resource : hiveResources) {
- RangerAccessRequestImpl request = createRequest(currentUser, accessType);
+ RangerAccessRequestImpl request = createRequest(subject, accessType);
request.setResource(resource);
requests.add(request);
}
Collection results = hivePlugin.isAccessAllowed(requests, auditHandler);
- checkRequestResults(results, accessType.name());
+ checkRequestResults(results, accessType.name(), asked);
} finally {
lifecycleLock.readLock().unlock();
}
}
- private boolean checkPrivilege(UserIdentity currentUser, HiveAccessType accessType,
+ private boolean checkPrivilege(AuthorizedSubject subject, HiveAccessType accessType,
RangerHiveResource resource) {
lifecycleLock.readLock().lock();
try {
if (closed) {
return false;
}
- RangerAccessRequestImpl request = createRequest(currentUser, accessType);
+ RangerAccessRequestImpl request = createRequest(subject, accessType);
request.setResource(resource);
RangerAccessResult result = hivePlugin.isAccessAllowed(request, auditHandler);
@@ -174,20 +268,29 @@ private boolean checkPrivilege(UserIdentity currentUser, HiveAccessType accessTy
}
}
- private HiveAccessType convertToAccessType(PrivPredicate predicate) {
- if (predicate == PrivPredicate.SHOW) {
+ /**
+ * The Hive access type standing for the question being asked.
+ *
+ *
Only the questions the engine asks by name map onto one; anything else - a requirement assembled for
+ * one statement, say - is deliberately {@link HiveAccessType#NONE}, which no Hive policy grants. Guessing
+ * an access type from an unrecognised set of actions would grant on a policy written for something else.
+ */
+ @VisibleForTesting
+ static HiveAccessType accessTypeOf(AccessRequirement requirement) {
+ if (AccessRequirements.VISIBILITY.equals(requirement)) {
return HiveAccessType.USE;
- } else if (predicate == PrivPredicate.SELECT) {
+ } else if (AccessRequirements.SELECT.equals(requirement)) {
return HiveAccessType.SELECT;
- } else if (predicate == PrivPredicate.ADMIN || predicate == PrivPredicate.ALL) {
+ } else if (AccessRequirements.ADMINISTRATION.equals(requirement)
+ || AccessRequirements.ANY_PRIVILEGE.equals(requirement)) {
return HiveAccessType.ALL;
- } else if (predicate == PrivPredicate.LOAD) {
+ } else if (AccessRequirements.LOAD.equals(requirement)) {
return HiveAccessType.UPDATE;
- } else if (predicate == PrivPredicate.ALTER) {
+ } else if (AccessRequirements.ALTER.equals(requirement)) {
return HiveAccessType.ALTER;
- } else if (predicate == PrivPredicate.CREATE) {
+ } else if (AccessRequirements.CREATE.equals(requirement)) {
return HiveAccessType.CREATE;
- } else if (predicate == PrivPredicate.DROP) {
+ } else if (AccessRequirements.DROP.equals(requirement)) {
return HiveAccessType.DROP;
} else {
return HiveAccessType.NONE;
@@ -195,93 +298,23 @@ private HiveAccessType convertToAccessType(PrivPredicate predicate) {
}
@Override
- public boolean checkGlobalPriv(UserIdentity currentUser, PrivPredicate wanted) {
- // hive ranger plugin does not support global privilege
- // use whichever authorization source governs global scope
- return Env.getCurrentEnv().getAccessManager().checkGlobalPriv(currentUser, wanted);
- }
-
- @Override
- public boolean checkCtlPriv(UserIdentity currentUser, String ctl, PrivPredicate wanted) {
- return true;
- }
-
- @Override
- public boolean checkDbPriv(UserIdentity currentUser, String ctl, String db, PrivPredicate wanted) {
- if (grantedByGlobalScopeAuthority(currentUser, wanted)) {
- return true;
- }
- RangerHiveResource resource = new RangerHiveResource(HiveObjectType.DATABASE,
- db);
- return checkPrivilege(currentUser, convertToAccessType(wanted), resource);
- }
-
- @Override
- public boolean checkTblPriv(UserIdentity currentUser, String ctl, String db, String tbl, PrivPredicate wanted) {
- if (grantedByGlobalScopeAuthority(currentUser, wanted)) {
- return true;
- }
- RangerHiveResource resource = new RangerHiveResource(HiveObjectType.TABLE,
- db, tbl);
- return checkPrivilege(currentUser, convertToAccessType(wanted), resource);
- }
-
- @Override
- public void checkColsPriv(UserIdentity currentUser, String ctl, String db, String tbl, Set cols,
- PrivPredicate wanted) throws AuthorizationException {
- if (grantedByGlobalScopeAuthority(currentUser, wanted)) {
- return;
- }
- List resources = new ArrayList<>();
- for (String col : cols) {
- RangerHiveResource resource = new RangerHiveResource(HiveObjectType.COLUMN,
- db, tbl, col);
- resources.add(resource);
- }
-
- checkPrivileges(currentUser, convertToAccessType(wanted), resources);
- }
-
- @Override
- public boolean checkCloudPriv(UserIdentity currentUser, String cloudName,
- PrivPredicate wanted, ResourceTypeEnum type) {
- return false;
- }
-
- @Override
- public boolean checkStorageVaultPriv(UserIdentity currentUser, String storageVaultName, PrivPredicate wanted) {
- return false;
- }
-
- @Override
- public boolean checkResourcePriv(UserIdentity currentUser, String resourceName, PrivPredicate wanted) {
- return false;
- }
-
- @Override
- public boolean checkWorkloadGroupPriv(UserIdentity currentUser, String workloadGroupName, PrivPredicate wanted) {
- // Not support workload group privilege in ranger hive plugin.
- // So always return true to pass the check
- return true;
- }
-
- @Override
- public List evalRowFilterPolicies(UserIdentity currentUser, String ctl, String db,
- String tbl) {
+ public List getRowFilters(AuthorizedSubject subject, AuthorizedResource.Table table,
+ AccessContext context) {
lifecycleLock.readLock().lock();
try {
- return closed ? new ArrayList<>() : super.evalRowFilterPolicies(currentUser, ctl, db, tbl);
+ return closed ? new ArrayList<>() : super.getRowFilters(subject, table, context);
} finally {
lifecycleLock.readLock().unlock();
}
}
@Override
- public Optional evalDataMaskPolicy(UserIdentity currentUser, String ctl, String db, String tbl,
- String col) {
+ public Map getDataMasks(AuthorizedSubject subject, AuthorizedResource.Table table,
+ Set columns, AccessContext context) {
lifecycleLock.readLock().lock();
try {
- return closed ? Optional.empty() : super.evalDataMaskPolicy(currentUser, ctl, db, tbl, col);
+ return closed ? Collections.emptyMap()
+ : super.getDataMasks(subject, table, columns, context);
} finally {
lifecycleLock.readLock().unlock();
}
@@ -308,17 +341,4 @@ protected RangerBasePlugin getPlugin() {
protected RangerAccessResultProcessor getAccessResultProcessor() {
return auditHandler;
}
-
- // For test only
- public static void main(String[] args) {
- Map properties = Maps.newHashMap();
- properties.put("ranger.service.name", "hive");
- RangerHiveAccessController ac = new RangerHiveAccessController(properties);
- UserIdentity user = new UserIdentity("user1", "127.0.0.1");
- user.setIsAnalyzed();
- boolean res = ac.checkDbPriv(user, "hive", "tpcds_bin_partitioned_orc_1", PrivPredicate.SHOW);
- System.out.println("res: " + res);
- res = ac.checkTblPriv(user, "internal", "tpch1", "customer", PrivPredicate.SELECT);
- System.out.println("res: " + res);
- }
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessControllerFactory.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessControllerFactory.java
index a45632ff9e619c..15613f4567cc65 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessControllerFactory.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessControllerFactory.java
@@ -17,20 +17,26 @@
package org.apache.doris.catalog.authorizer.ranger.hive;
-import org.apache.doris.mysql.privilege.AccessControllerFactory;
-import org.apache.doris.mysql.privilege.CatalogAccessController;
+import org.apache.doris.authorization.spi.AuthorizationContext;
+import org.apache.doris.authorization.spi.AuthorizationPlugin;
+import org.apache.doris.authorization.spi.AuthorizationPluginFactory;
import java.util.Map;
-public class RangerHiveAccessControllerFactory implements AccessControllerFactory {
+public class RangerHiveAccessControllerFactory implements AuthorizationPluginFactory {
@Override
- public String factoryIdentifier() {
- return "ranger-hive";
+ public String name() {
+ return RangerHiveAccessController.NAME;
}
@Override
- public CatalogAccessController createAccessController(Map prop) {
- return new RangerHiveAccessController(prop);
+ public String description() {
+ return "Authorizes one catalog against the policies of a Ranger service of type hive";
+ }
+
+ @Override
+ public AuthorizationPlugin create(Map properties, AuthorizationContext context) {
+ return new RangerHiveAccessController(properties, context);
}
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
index b521f4180b87af..b3d055110203bb 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/AccessControllerManager.java
@@ -26,6 +26,7 @@
import org.apache.doris.authorization.ResourceKind;
import org.apache.doris.authorization.RowFilterSpec;
import org.apache.doris.authorization.spi.AuthorizationPlugin;
+import org.apache.doris.authorization.spi.AuthorizationPluginFactory;
import org.apache.doris.catalog.AuthorizationInfo;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.info.TableNameInfo;
@@ -76,6 +77,9 @@ public class AccessControllerManager {
// A catalog name can be reused after DROP. Keep the catalog id next to the source so cleanup from
// an old catalog generation can never remove or close the replacement generation's source.
private Map ctlToCtlAccessController = Maps.newConcurrentMap();
+ // Factories publishing an authorization source under the current contract, by the name it is selected by
+ private ConcurrentHashMap authorizationPluginFactories
+ = new ConcurrentHashMap<>();
// Cache of loaded access controller factories for quick creation of new access controllers
private ConcurrentHashMap accessControllerFactoriesCache
= new ConcurrentHashMap<>();
@@ -111,19 +115,37 @@ private AuthorizationPlugin loadAccessControllerOrThrow(String accessControllerN
if (accessControllerName.equalsIgnoreCase(InternalAuthorizationPlugin.NAME)) {
return new InternalAuthorizationPlugin(auth);
}
- if (accessControllerFactoriesCache.containsKey(accessControllerName)) {
- Map prop;
- try {
- prop = PropertiesUtils.loadAccessControllerPropertiesOrNull();
- } catch (IOException e) {
- throw new RuntimeException("Failed to load authorization properties."
- + "Please check the configuration file, authorization name is " + accessControllerName, e);
- }
- return adapt(accessControllerName,
- accessControllerFactoriesCache.get(accessControllerName).createAccessController(prop));
+ if (!isKnownAuthorizationSource(accessControllerName)) {
+ throw new RuntimeException("No authorization plugin factory found for " + accessControllerName
+ + ". Please confirm that your plugin is placed in the correct location.");
+ }
+ Map prop;
+ try {
+ prop = PropertiesUtils.loadAccessControllerPropertiesOrNull();
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to load authorization properties."
+ + "Please check the configuration file, authorization name is " + accessControllerName, e);
}
- throw new RuntimeException("No authorization plugin factory found for " + accessControllerName
- + ". Please confirm that your plugin is placed in the correct location.");
+ return create(accessControllerName, prop);
+ }
+
+ /**
+ * Builds the authorization source published under {@code name}, whichever contract publishes it.
+ *
+ *
A source written against the current contract is built with the context it may put questions to the
+ * engine through. It cannot be handed that context any earlier than this - the context has to name the
+ * source it belongs to, and the source does not exist until its factory has run.
+ */
+ private AuthorizationPlugin create(String name, Map properties) {
+ AuthorizationPluginFactory factory = authorizationPluginFactories.get(name);
+ if (factory == null) {
+ return adapt(name, accessControllerFactoriesCache.get(name).createAccessController(properties));
+ }
+ EngineAuthorizationContext context = new EngineAuthorizationContext(this, auth);
+ AuthorizationPlugin plugin = factory.create(
+ properties == null ? Collections.emptyMap() : properties, context);
+ context.servedBy(plugin);
+ return plugin;
}
/** Presents a controller written against the older per-scope interface as an authorization source. */
@@ -131,12 +153,22 @@ private AuthorizationPlugin adapt(String name, CatalogAccessController controlle
return new LegacyAccessControllerPlugin(name, controller);
}
+ private boolean isKnownAuthorizationSource(String name) {
+ return authorizationPluginFactories.containsKey(name) || accessControllerFactoriesCache.containsKey(name);
+ }
+
private void loadAccessControllerPlugins() {
+ // Sources shipped with the FE, and any on its class path. Loading them from a plugin directory is
+ // what the version gate guards, so that channel opens with the gate rather than before it.
+ for (AuthorizationPluginFactory factory : ServiceLoader.load(AuthorizationPluginFactory.class)) {
+ LOG.info("Found authorization plugin factory: {} from class path.", factory.name());
+ authorizationPluginFactories.put(factory.name(), factory);
+ accessControllerClassNameMapping.put(factory.getClass().getName(), factory.name());
+ }
ServiceLoader loaderFromClasspath = ServiceLoader.load(AccessControllerFactory.class);
for (AccessControllerFactory factory : loaderFromClasspath) {
LOG.info("Found Authentication Plugin Factories: {} from class path.", factory.factoryIdentifier());
- accessControllerFactoriesCache.put(factory.factoryIdentifier(), factory);
- accessControllerClassNameMapping.put(factory.getClass().getName(), factory.factoryIdentifier());
+ registerLegacyFactory(factory);
}
List loader = null;
try {
@@ -146,11 +178,21 @@ private void loadAccessControllerPlugins() {
}
for (AccessControllerFactory factory : loader) {
LOG.info("Found Access Controller Plugin Factory: {} from directory.", factory.factoryIdentifier());
- accessControllerFactoriesCache.put(factory.factoryIdentifier(), factory);
- accessControllerClassNameMapping.put(factory.getClass().getName(), factory.factoryIdentifier());
+ registerLegacyFactory(factory);
}
}
+ private void registerLegacyFactory(AccessControllerFactory factory) {
+ String name = factory.factoryIdentifier();
+ if (authorizationPluginFactories.containsKey(name)) {
+ // Both were found, so say which one answers rather than letting the loser look installed.
+ LOG.warn("Authorization source {} is published both as a plugin and as an access controller"
+ + " factory; the plugin is the one used.", name);
+ }
+ accessControllerFactoriesCache.put(name, factory);
+ accessControllerClassNameMapping.put(factory.getClass().getName(), name);
+ }
+
/** The authorization source governing the objects inside {@code ctl}. */
public AuthorizationPlugin getAccessControllerOrDefault(String ctl) {
if (InternalCatalog.INTERNAL_CATALOG_NAME.equals(ctl)) {
@@ -223,8 +265,7 @@ public boolean checkIfAccessControllerExist(String ctl) {
public void createAccessController(ExternalCatalog catalog, String acFactoryClassName, Map prop,
boolean isDryRun) {
String pluginIdentifier = getPluginIdentifierForAccessController(acFactoryClassName);
- AuthorizationPlugin accessController = adapt(pluginIdentifier,
- accessControllerFactoriesCache.get(pluginIdentifier).createAccessController(prop));
+ AuthorizationPlugin accessController = create(pluginIdentifier, prop);
if (isDryRun) {
closeAccessController(catalog.getName(), accessController);
return;
@@ -264,10 +305,10 @@ private String getPluginIdentifierForAccessController(String acClassName) {
if (accessControllerClassNameMapping.containsKey(acClassName)) {
pluginIdentifier = accessControllerClassNameMapping.get(acClassName);
}
- if (accessControllerFactoriesCache.containsKey(acClassName)) {
+ if (isKnownAuthorizationSource(acClassName)) {
pluginIdentifier = acClassName;
}
- if (null == pluginIdentifier || !accessControllerFactoriesCache.containsKey(pluginIdentifier)) {
+ if (null == pluginIdentifier || !isKnownAuthorizationSource(pluginIdentifier)) {
throw new RuntimeException("Access Controller Plugin Factory not found for " + acClassName);
}
return pluginIdentifier;
@@ -404,18 +445,12 @@ private AuthorizationPlugin systemScopeController() {
/**
* Whether {@code candidate} is itself the source governing instance scope.
*
- *
Asked by a source that would otherwise defer to that authority, so that it does not ask itself a
- * question it is about to answer - the two would agree, at the price of evaluating the same policies
- * twice. Identity is the question, so a controller reached through an adapter is compared against the
- * controller, not against its wrapper.
+ *
Asked on behalf of a source that would otherwise defer to that authority, so that it does not ask
+ * itself a question it is about to answer - the two would agree, at the price of evaluating the same
+ * policies twice.
*/
- public boolean isGlobalScopeAuthority(Object candidate) {
- AuthorizationPlugin authority = systemScopeController();
- if (authority == candidate) {
- return true;
- }
- return authority instanceof LegacyAccessControllerPlugin
- && ((LegacyAccessControllerPlugin) authority).getController() == candidate;
+ boolean isGlobalScopeAuthority(AuthorizationPlugin candidate) {
+ return systemScopeController() == candidate;
}
// ==== Global ====
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/EngineAuthorizationContext.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/EngineAuthorizationContext.java
new file mode 100644
index 00000000000000..d75cda0cbd67fe
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/EngineAuthorizationContext.java
@@ -0,0 +1,83 @@
+// 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.doris.mysql.privilege;
+
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AuthorizedResource;
+import org.apache.doris.authorization.AuthorizedSubject;
+import org.apache.doris.authorization.spi.AuthorizationContext;
+import org.apache.doris.authorization.spi.AuthorizationPlugin;
+
+import com.google.common.base.Preconditions;
+
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * What the engine answers when an authorization source asks it something.
+ *
+ *
One of these is created per source, so that a question whose answer depends on who is asking - "does
+ * somebody else already grant this at instance scope?" - can be answered without the source having to
+ * identify itself on every call. {@link AccessControllerManager} builds them; nothing else needs to, beyond
+ * a test standing a source up the way the manager would.
+ */
+public class EngineAuthorizationContext implements AuthorizationContext {
+
+ private final AccessControllerManager manager;
+ private final Auth auth;
+ /**
+ * The source this context serves. Assigned once the factory has built it, which is necessarily after this
+ * context exists: the factory needs the context to build the source with.
+ */
+ private volatile AuthorizationPlugin servedSource;
+
+ public EngineAuthorizationContext(AccessControllerManager manager, Auth auth) {
+ this.manager = Objects.requireNonNull(manager, "manager is required");
+ this.auth = Objects.requireNonNull(auth, "auth is required");
+ }
+
+ /** Records which source this context belongs to; called by the manager right after the source is built. */
+ public void servedBy(AuthorizationPlugin source) {
+ this.servedSource = Objects.requireNonNull(source, "source is required");
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ *
Without the per-user default role: it is an artefact of how the built-in model stores a user's own
+ * grants, not a role anybody names in a policy, and a source that saw it would be matching on a name the
+ * administrator never wrote.
+ */
+ @Override
+ public Set rolesOf(AuthorizedSubject subject) {
+ return auth.getRolesByUser(AccessTranslation.userIdentityOf(subject), false);
+ }
+
+ @Override
+ public boolean grantedByGlobalScopeAuthority(AuthorizedSubject subject, AccessRequirement requirement) {
+ Preconditions.checkState(servedSource != null,
+ "an authorization source asked about global scope before the engine knew which source it is");
+ if (manager.isGlobalScopeAuthority(servedSource)) {
+ // Asking would route straight back to the source asking, which is about to answer the same
+ // question from the same policies. Same verdict, twice the evaluations.
+ return false;
+ }
+ return manager.decide(AccessTranslation.userIdentityOf(subject), AuthorizedResource.global(),
+ requirement);
+ }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/LegacyAccessControllerPlugin.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/LegacyAccessControllerPlugin.java
index 4557cd87ee93ad..7255a328b2264c 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/LegacyAccessControllerPlugin.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/LegacyAccessControllerPlugin.java
@@ -54,11 +54,7 @@ public LegacyAccessControllerPlugin(String name, CatalogAccessController control
this.controller = Objects.requireNonNull(controller, "controller is required");
}
- /**
- * The controller this presents. Needed where an identity, not a behaviour, is the question - a controller
- * asking whether it is itself the one governing instance scope has to compare against the object it is,
- * not against the wrapper it is reached through.
- */
+ /** The controller this presents, for where the controller itself is the question rather than its answers. */
public CatalogAccessController getController() {
return controller;
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RangerDorisAccessControllerFactory.java b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RangerDorisAccessControllerFactory.java
index 7e23769911520c..b63a344debcf94 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RangerDorisAccessControllerFactory.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mysql/privilege/RangerDorisAccessControllerFactory.java
@@ -17,23 +17,48 @@
package org.apache.doris.mysql.privilege;
+import org.apache.doris.authorization.spi.AuthorizationContext;
+import org.apache.doris.authorization.spi.AuthorizationPlugin;
+import org.apache.doris.authorization.spi.AuthorizationPluginFactory;
import org.apache.doris.catalog.authorizer.ranger.doris.RangerDorisAccessController;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
import java.util.Map;
-public class RangerDorisAccessControllerFactory implements AccessControllerFactory {
- private static class SingletonHolder {
- // Every controller starts a Ranger policy refresher, so all Env instances must share one controller.
- private static final RangerDorisAccessController INSTANCE = new RangerDorisAccessController("doris");
+public class RangerDorisAccessControllerFactory implements AuthorizationPluginFactory {
+ private static final Logger LOG = LogManager.getLogger(RangerDorisAccessControllerFactory.class);
+ private static final String SERVICE_NAME = "doris";
+
+ // Every controller starts a Ranger policy refresher, so all Env instances must share one controller.
+ private static RangerDorisAccessController instance;
+
+ @Override
+ public String name() {
+ return RangerDorisAccessController.NAME;
}
@Override
- public String factoryIdentifier() {
- return "ranger-doris";
+ public String description() {
+ return "Authorizes against the policies of a Ranger service of type " + SERVICE_NAME;
}
@Override
- public RangerDorisAccessController createAccessController(Map prop) {
- return SingletonHolder.INSTANCE;
+ public AuthorizationPlugin create(Map properties, AuthorizationContext context) {
+ return singleton(properties, context);
+ }
+
+ private static synchronized RangerDorisAccessController singleton(Map properties,
+ AuthorizationContext context) {
+ if (instance == null) {
+ instance = new RangerDorisAccessController(SERVICE_NAME, properties, context);
+ } else if (!properties.isEmpty()) {
+ // There is one refresher and therefore one controller, so the configuration it was built with is
+ // the one in force. Say so rather than let a second, differently configured binding look applied.
+ LOG.warn("Ranger Doris authorization is already configured; properties {} are ignored, the"
+ + " configuration the source was created with stays in force.", properties.keySet());
+ }
+ return instance;
}
}
diff --git a/fe/fe-core/src/main/resources/META-INF/services/org.apache.doris.mysql.privilege.AccessControllerFactory b/fe/fe-core/src/main/resources/META-INF/services/org.apache.doris.authorization.spi.AuthorizationPluginFactory
similarity index 98%
rename from fe/fe-core/src/main/resources/META-INF/services/org.apache.doris.mysql.privilege.AccessControllerFactory
rename to fe/fe-core/src/main/resources/META-INF/services/org.apache.doris.authorization.spi.AuthorizationPluginFactory
index e2100cb8b23508..6d19de0830694a 100644
--- a/fe/fe-core/src/main/resources/META-INF/services/org.apache.doris.mysql.privilege.AccessControllerFactory
+++ b/fe/fe-core/src/main/resources/META-INF/services/org.apache.doris.authorization.spi.AuthorizationPluginFactory
@@ -16,4 +16,4 @@
#
#
org.apache.doris.mysql.privilege.RangerDorisAccessControllerFactory
-org.apache.doris.catalog.authorizer.ranger.hive.RangerHiveAccessControllerFactory
\ No newline at end of file
+org.apache.doris.catalog.authorizer.ranger.hive.RangerHiveAccessControllerFactory
diff --git a/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/RangerGlobalScopeDeferenceTest.java b/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/RangerGlobalScopeDeferenceTest.java
index a302d56667873e..eec85525daf9a1 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/RangerGlobalScopeDeferenceTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/RangerGlobalScopeDeferenceTest.java
@@ -17,39 +17,44 @@
package org.apache.doris.catalog.authorizer.ranger;
-import org.apache.doris.analysis.UserIdentity;
-import org.apache.doris.catalog.Env;
+import org.apache.doris.authorization.AccessContext;
+import org.apache.doris.authorization.AccessDeniedException;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AccessRequirements;
+import org.apache.doris.authorization.AuthorizedResource;
+import org.apache.doris.authorization.AuthorizedSubject;
+import org.apache.doris.authorization.ResourceKind;
+import org.apache.doris.authorization.spi.AuthorizationPlugin;
import org.apache.doris.catalog.authorizer.ranger.doris.RangerDorisAccessController;
import org.apache.doris.common.jmockit.Deencapsulation;
import org.apache.doris.mysql.privilege.AccessControllerManager;
import org.apache.doris.mysql.privilege.Auth;
-import org.apache.doris.mysql.privilege.CatalogAccessController;
-import org.apache.doris.mysql.privilege.LegacyAccessControllerPlugin;
-import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.mysql.privilege.EngineAuthorizationContext;
import org.apache.doris.mysql.privilege.StubRangerPolicyEngine;
import org.apache.ranger.plugin.policyengine.RangerAccessRequest;
import org.apache.ranger.plugin.policyengine.RangerAccessResult;
import org.junit.Assert;
import org.junit.Test;
-import org.mockito.MockedStatic;
-import org.mockito.Mockito;
+import java.util.Collections;
+import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
-import java.util.function.BooleanSupplier;
/**
- * A catalog bound to Ranger still lets through whoever holds the privilege at global scope.
+ * A catalog bound to Ranger still lets through whoever holds the privilege at instance scope.
*
- *
Global scope is not something a Ranger service knows about; it belongs to the controller named by
+ *
Instance scope is not something a Ranger service knows about; it belongs to the source named by
* {@code access_controller_type}. Honouring it is why a cluster administrator can still reach a Ranger-governed
- * catalog after the engine stopped establishing privileges on a plugin's behalf. It is the plugin's own choice,
- * so it is tested on the plugin, and a plugin that declines it stays a legal plugin.
+ * catalog after the engine stopped establishing privileges on a source's behalf. It is the source's own choice,
+ * so it is tested on the source, it is configurable, and a source that declines it stays a legal source.
*/
public class RangerGlobalScopeDeferenceTest {
- private static final UserIdentity ADMIN = UserIdentity.createAnalyzedUserIdentWithIp("admin_user", "%");
- private static final UserIdentity RANGER_USER =
- UserIdentity.createAnalyzedUserIdentWithIp(StubRangerPolicyEngine.ALLOWED_USER, "%");
+ private static final AuthorizedSubject ADMIN = AuthorizedSubject.of("admin_user", "%");
+ private static final AuthorizedSubject RANGER_USER =
+ AuthorizedSubject.of(StubRangerPolicyEngine.ALLOWED_USER, "%");
+ private static final AuthorizedResource.Table ALLOWED_TABLE = AuthorizedResource.table("ctl",
+ StubRangerPolicyEngine.ALLOWED_DB, StubRangerPolicyEngine.ALLOWED_TABLE);
/** Counts what actually reached the policy engine, so "answered without asking Ranger" is observable. */
private static final class CountingPolicyEngine extends StubRangerPolicyEngine {
@@ -62,73 +67,123 @@ public RangerAccessResult isAccessAllowed(RangerAccessRequest request) {
}
}
+ /**
+ * Stands for whatever {@code access_controller_type} installs: it grants everything to one account and
+ * nothing to anyone else, and remembers what it was asked about.
+ */
+ private static final class Authority implements AuthorizationPlugin {
+ private final AuthorizedSubject allowed;
+ private ResourceKind askedAbout;
+
+ private Authority(AuthorizedSubject allowed) {
+ this.allowed = allowed;
+ }
+
+ @Override
+ public String name() {
+ return "authority";
+ }
+
+ @Override
+ public void checkPrivilege(AuthorizedSubject subject, AuthorizedResource resource,
+ AccessRequirement requirement, AccessContext context) throws AccessDeniedException {
+ askedAbout = resource.getKind();
+ if (!subject.equals(allowed)) {
+ throw AccessDeniedException.of(subject, resource, requirement, name());
+ }
+ }
+ }
+
private final CountingPolicyEngine engine = new CountingPolicyEngine();
- private final RangerDorisAccessController controller = new RangerDorisAccessController(engine);
@Test
- public void testGlobalScopeAuthorityGrantsWithoutConsultingRanger() {
- CatalogAccessController authority = Mockito.mock(CatalogAccessController.class);
- Mockito.when(authority.checkGlobalPriv(ADMIN, PrivPredicate.SELECT)).thenReturn(true);
+ public void testGlobalScopeAuthorityGrantsWithoutConsultingRanger() throws Exception {
+ Authority authority = new Authority(ADMIN);
+ RangerDorisAccessController controller = rangerDeferringTo(authority, Collections.emptyMap());
- boolean allowed = withGlobalScopeAuthority(authority,
- () -> controller.checkTblPriv(ADMIN, "ctl", StubRangerPolicyEngine.ALLOWED_DB,
- StubRangerPolicyEngine.ALLOWED_TABLE, PrivPredicate.SELECT));
+ controller.checkPrivilege(ADMIN, ALLOWED_TABLE, AccessRequirements.SELECT, AccessContext.NONE);
- Assert.assertTrue(allowed);
// Not merely an optimisation: the Ranger policy set denies this user everywhere, so had the request
// reached the engine the answer would have been the opposite one.
Assert.assertEquals(0, engine.requests.get());
+ // And what was deferred is the privilege at instance scope, not the table.
+ Assert.assertEquals(ResourceKind.GLOBAL, authority.askedAbout);
}
@Test
- public void testRangerDecidesWhenTheAuthorityGrantsNothingGlobally() {
- CatalogAccessController authority = Mockito.mock(CatalogAccessController.class);
-
- Assert.assertTrue(withGlobalScopeAuthority(authority,
- () -> controller.checkTblPriv(RANGER_USER, "ctl", StubRangerPolicyEngine.ALLOWED_DB,
- StubRangerPolicyEngine.ALLOWED_TABLE, PrivPredicate.SELECT)));
- Assert.assertFalse(withGlobalScopeAuthority(authority,
- () -> controller.checkTblPriv(RANGER_USER, "ctl", StubRangerPolicyEngine.ALLOWED_DB,
- "other_tbl", PrivPredicate.SELECT)));
+ public void testRangerDecidesWhenTheAuthorityGrantsNothingGlobally() throws Exception {
+ RangerDorisAccessController controller = rangerDeferringTo(new Authority(ADMIN),
+ Collections.emptyMap());
+
+ controller.checkPrivilege(RANGER_USER, ALLOWED_TABLE, AccessRequirements.SELECT, AccessContext.NONE);
+ Assert.assertThrows(AccessDeniedException.class,
+ () -> controller.checkPrivilege(RANGER_USER,
+ AuthorizedResource.table("ctl", StubRangerPolicyEngine.ALLOWED_DB, "other_tbl"),
+ AccessRequirements.SELECT, AccessContext.NONE));
}
/**
- * With Ranger installed globally, the controller is itself the authority and its own global check already
- * answers the question - asking through the manager would evaluate the same Ranger policies a second time
- * on every database, table and column check.
+ * A source configured not to defer answers out of its own policies alone, so an administrator of the
+ * instance has no access to what it governs beyond what Ranger grants.
*/
@Test
- public void testBeingTheAuthorityCostsNoExtraPolicyEvaluation() {
- int asPlugin = requestsWhile(Mockito.mock(CatalogAccessController.class),
- () -> controller.checkDbPriv(RANGER_USER, "ctl", "other_db", PrivPredicate.SELECT));
- int asAuthority = requestsWhile(controller,
- () -> controller.checkDbPriv(RANGER_USER, "ctl", "other_db", PrivPredicate.SELECT));
+ public void testConfiguredNotToDeferRefusesTheGlobalScopeAuthority() throws Exception {
+ Authority authority = new Authority(ADMIN);
+ RangerDorisAccessController controller = rangerDeferringTo(authority, Collections.singletonMap(
+ RangerAccessController.DEFER_TO_GLOBAL_SCOPE_AUTHORITY, "false"));
+
+ Assert.assertThrows(AccessDeniedException.class,
+ () -> controller.checkPrivilege(ADMIN, ALLOWED_TABLE, AccessRequirements.SELECT,
+ AccessContext.NONE));
+ Assert.assertNull("the authority must not even be asked", authority.askedAbout);
+ Assert.assertNotEquals("Ranger has to be the one deciding", 0, engine.requests.get());
+ }
- Assert.assertEquals(asPlugin, asAuthority);
+ @Test
+ public void testUnreadableDeferenceSettingIsRejected() {
+ Assert.assertThrows(IllegalArgumentException.class,
+ () -> rangerDeferringTo(new Authority(ADMIN), Collections.singletonMap(
+ RangerAccessController.DEFER_TO_GLOBAL_SCOPE_AUTHORITY, "no")));
}
- private int requestsWhile(CatalogAccessController authority, BooleanSupplier check) {
+ /**
+ * With Ranger installed for the instance, the source is itself the authority and its own global check
+ * already answers the question - asking through the engine would evaluate the same Ranger policies a
+ * second time on every database, table and column check.
+ */
+ @Test
+ public void testBeingTheAuthorityCostsNoExtraPolicyEvaluation() throws Exception {
+ int asCatalogSource = requestsWhile(new Authority(ADMIN));
+ int asAuthority = requestsWhile(null);
+
+ Assert.assertEquals(asCatalogSource, asAuthority);
+ }
+
+ private int requestsWhile(Authority authority) throws Exception {
engine.requests.set(0);
- withGlobalScopeAuthority(authority, check);
+ RangerDorisAccessController controller = rangerDeferringTo(authority, Collections.emptyMap());
+ try {
+ controller.checkPrivilege(RANGER_USER, AuthorizedResource.database("ctl", "other_db"),
+ AccessRequirements.SELECT, AccessContext.NONE);
+ } catch (AccessDeniedException expected) {
+ // The point is the number of evaluations, not the verdict.
+ }
return engine.requests.get();
}
/**
- * Runs {@code check} against an FE whose {@code access_controller_type} resolves to {@code authority}.
- *
- *
Installed the way the engine installs one written against the older interface - behind the adapter -
- * because that is what makes "is this authority me?" a question about the controller rather than about
- * the object the manager happens to hold.
+ * Stands a Ranger source up the way the engine does: with a context of the engine's own, on an FE whose
+ * {@code access_controller_type} resolves to {@code authority} - or to the Ranger source itself when
+ * there is no other authority.
*/
- private boolean withGlobalScopeAuthority(CatalogAccessController authority, BooleanSupplier check) {
+ private RangerDorisAccessController rangerDeferringTo(AuthorizationPlugin authority,
+ Map properties) {
AccessControllerManager manager = new AccessControllerManager(new Auth());
+ EngineAuthorizationContext context = new EngineAuthorizationContext(manager, manager.getAuth());
+ RangerDorisAccessController controller = new RangerDorisAccessController(engine, properties, context);
+ context.servedBy(controller);
Deencapsulation.setField(manager, "defaultAccessController",
- new LegacyAccessControllerPlugin("authority", authority));
- try (MockedStatic mockedEnv = Mockito.mockStatic(Env.class)) {
- Env env = Mockito.mock(Env.class);
- mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
- Mockito.when(env.getAccessManager()).thenReturn(manager);
- return check.getAsBoolean();
- }
+ authority == null ? controller : authority);
+ return controller;
}
}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessControllerTest.java b/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessControllerTest.java
new file mode 100644
index 00000000000000..20ed56f7fcd823
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/authorizer/ranger/hive/RangerHiveAccessControllerTest.java
@@ -0,0 +1,96 @@
+// 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.doris.catalog.authorizer.ranger.hive;
+
+import org.apache.doris.authorization.AccessAction;
+import org.apache.doris.authorization.AccessRequirement;
+import org.apache.doris.authorization.AccessRequirements;
+import org.apache.doris.authorization.AuthorizedSubject;
+import org.apache.doris.authorization.spi.AuthorizationContext;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import org.apache.ranger.plugin.policyengine.RangerAccessRequestImpl;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.MockedConstruction;
+import org.mockito.Mockito;
+
+import java.util.Set;
+
+public class RangerHiveAccessControllerTest {
+
+ private static final AuthorizedSubject SUBJECT = AuthorizedSubject.of("user1", "%");
+
+ /**
+ * A Ranger policy may be written against a role, and which roles a Doris account holds is the engine's to
+ * know: the source has no user directory of its own to look them up in.
+ */
+ @Test
+ public void testRequestCarriesTheRolesTheEngineKnows() {
+ Set roles = ImmutableSet.of("analyst");
+ AuthorizationContext context = Mockito.mock(AuthorizationContext.class);
+ Mockito.when(context.rolesOf(SUBJECT)).thenReturn(roles);
+
+ try (MockedConstruction plugin = Mockito.mockConstruction(RangerHivePlugin.class);
+ MockedConstruction