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();
    }
}