LegacyAccessControllerPlugin.java
// 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.Collections;
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.
*
* <p>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.
*
* <p>Part of that translation is a grant the old interface never asked its implementations about. Its scoped
* methods came in pairs - {@code checkDbPriv(boolean hasGlobal, ...)} in front of
* {@code checkDbPriv(...)} - and the engine computed {@code hasGlobal} from whoever governed instance scope
* and passed it in, so a caller holding the privilege globally was granted without the controller being
* consulted at all. Under the current contract each source decides its own exemptions, and the two Ranger
* sources do decide this one for themselves. A controller written against the older interface never had the
* chance to, so the exemption is reproduced here rather than taken away from it.
*/
public class LegacyAccessControllerPlugin implements AuthorizationPlugin {
/**
* Whether whoever governs instance scope already grants this - the {@code hasGlobal} the older interface
* was handed. Unlike {@link org.apache.doris.authorization.spi.AuthorizationContext}'s question of the
* same shape, this one is asked even when the source asking is itself that authority, because that is
* what the engine did before: it computed the global verdict from the instance-wide source in every case.
*/
@FunctionalInterface
public interface GlobalScopeAuthority {
/**
* @param context the circumstances of the check that provoked this question, carried along rather than
* read off the thread: whoever governs instance scope may be a source that decides from more
* than the subject, and this is a question about the same statement.
*/
boolean grants(AuthorizedSubject subject, AccessRequirement requirement, AccessContext context);
}
private final String name;
private final CatalogAccessController controller;
private final GlobalScopeAuthority globalScope;
public LegacyAccessControllerPlugin(String name, CatalogAccessController controller,
GlobalScopeAuthority globalScope) {
this.name = Objects.requireNonNull(name, "name is required");
this.controller = Objects.requireNonNull(controller, "controller is required");
this.globalScope = Objects.requireNonNull(globalScope, "global scope authority is required");
}
/** The controller this presents, for where the controller itself is the question rather than its answers. */
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:
// Deliberately without the exemption below: this is the question the exemption is made of.
refuseUnless(controller.checkGlobalPriv(currentUser, wanted), subject, resource, requirement);
return;
case CATALOG:
if (grantedAtGlobalScope(subject, requirement, context)) {
return;
}
refuseUnless(controller.checkCtlPriv(currentUser,
((AuthorizedResource.Catalog) resource).getCatalog(), wanted),
subject, resource, requirement);
return;
case DATABASE: {
if (grantedAtGlobalScope(subject, requirement, context)) {
return;
}
AuthorizedResource.Database database = (AuthorizedResource.Database) resource;
refuseUnless(controller.checkDbPriv(currentUser, database.getCatalog(),
database.getDatabase(), wanted), subject, resource, requirement);
return;
}
case TABLE: {
if (grantedAtGlobalScope(subject, requirement, context)) {
return;
}
AuthorizedResource.Table table = (AuthorizedResource.Table) resource;
refuseUnless(controller.checkTblPriv(currentUser, table.getCatalog(), table.getDatabase(),
table.getTable(), wanted), subject, resource, requirement);
return;
}
case COLUMNS: {
if (grantedAtGlobalScope(subject, requirement, context)) {
return;
}
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());
}
}
/** The {@code hasGlobal} the deleted default methods were handed; see this class's own documentation. */
private boolean grantedAtGlobalScope(AuthorizedSubject subject, AccessRequirement requirement,
AccessContext context) {
return globalScope.grants(subject, requirement, context);
}
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<RowFilterSpec> getRowFilters(AuthorizedSubject subject, AuthorizedResource.Table table,
AccessContext context) {
List<RowFilterSpec> filters = controller.evalRowFilterPolicies(
AccessTranslation.userIdentityOf(subject), table.getCatalog(), table.getDatabase(),
table.getTable());
// Through a wildcard so that reading an element compiles to no cast of our own: the checked crossing
// below has to be what reports a wrong type, not a checkcast javac inserted a frame earlier.
List<?> answered = filters == null ? Collections.emptyList() : filters;
for (Object filter : answered) {
checkedCrossing(filter, RowFilterSpec.class, "evalRowFilterPolicies", table);
}
// The wildcard, not the field: a source outside this repository may answer null, and the SPI says
// "nothing to apply" is an empty list. Returning the field instead moves that null one frame on, into
// Utils.fastToImmutableList or - with the SQL cache off - into a CollectionUtils.isEmpty that reads it
// as "no row filter" and hands the table back whole. Sibling getDataMasks never returns null either.
@SuppressWarnings("unchecked")
List<RowFilterSpec> checked = (List<RowFilterSpec>) answered;
return checked;
}
@Override
public Map<String, DataMaskSpec> getDataMasks(AuthorizedSubject subject, AuthorizedResource.Table table,
Set<String> columns, AccessContext context) {
UserIdentity currentUser = AccessTranslation.userIdentityOf(subject);
Map<String, DataMaskSpec> 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);
if (mask != null && mask.isPresent()) {
Object spec = mask.get();
checkedCrossing(spec, DataMaskSpec.class, "evalDataMaskPolicy", table);
masks.put(column, (DataMaskSpec) spec);
}
}
return masks;
}
/**
* Refuses what a controller compiled against the older data policy types answers with, naming the source
* and what it has to do about it.
*
* <p>Both signatures erase to {@code Optional} and {@code List}, so a controller built against
* {@code RowFilterPolicy} and {@code DataMaskPolicy} - types this release deleted - still loads, still
* answers every {@code check*Priv}, and still runs this method body to completion; what it hands back is
* only ever looked at by whoever consumes it. For row filters that is a Nereids rule, several frames away
* and past everything that could say which source produced this, so the operator's first sign of it is a
* bare {@code ClassCastException} out of a planner class. The check belongs at the boundary the object
* crosses, and the message has to be the one that says "recompile your access controller".
*/
private <T> void checkedCrossing(Object answered, Class<T> expected, String method,
AuthorizedResource.Table table) {
if (expected.isInstance(answered)) {
return;
}
throw new IllegalStateException("authorization source " + name + " answered " + method + " for "
+ table + " with " + (answered == null ? "null" : answered.getClass().getName())
+ " rather than " + expected.getName() + ". An access controller built against a Doris release"
+ " that still had org.apache.doris.mysql.privilege.RowFilterPolicy and DataMaskPolicy has to"
+ " be recompiled against " + RowFilterSpec.class.getName() + " and "
+ DataMaskSpec.class.getName() + " before it can answer data policies again.");
}
@Override
public void close() {
controller.close();
}
}