This directory holds the authorization plugin framework of the Doris FE: the contract an authorization source implements, the vocabulary it decides with, and the sources shipped with the release. An authorization source decides, for the resources it governs, what a user may do with them — without fe-core knowing anything about how it decides.
Three companion documents divide the work with this one:
AGENTS.md(next to this file) — build/test recipes, the machine-checked obligations, and the invariants that are not expressible as a gate. Read it before changing code here.fe-authorization-spi/README.md— the plugin author's quickstart: a minimal plugin end to end, theMETA-INF/servicesline, the installed directory layout, and the API-version manifest entry. Copy from there; this file explains the framework the copy lands in.- Generic plugin machinery is NOT defined here: contracts live in
fe/fe-extension-spiand directory loading infe/fe-extension-loader. Each has its own README.
Four design rules shape everything in this directory:
-
One source answers, and its answer is the whole answer. Which source 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. Nothing grants access before a plugin is asked and no second plugin is consulted after it.
AccessControllerManager(fe-core) is pure routing — it establishes no privilege of its own and never combines two verdicts — which is what makes the policies in force on an object readable from the configuration.Three exemptions predate this rule and are the whole of the list. Each one grants without asking any source, and each is the engine's own, not something a plugin can influence:
ConnectContext.isSkipAuth()— honoured bycheckTblPrivandcheckColumnsPriv, and only by theirConnectContextoverloads. Set in five places, which are not all the same case: four are a statement the engine runs on behalf of one the caller was already authorized for, and the fifth,FrontendServiceImpl.fetchLoadJob, builds a context carrying no user identity at all — a system-table read on behalf of a backend, which has no account to check — and clears it in afinally.Config.skip_catalog_priv_checkon a catalog bound to a source of its own, incheckCtlPriv: such a catalog keeps no catalog-level grant anywhere, so with the check switched off there is nobody left to ask.- The literal accounts
root@'%'andadmin@'%'— not everyone holdingADMIN_PRIV— are subject to no row filter and no column mask, dropped byLogicalCheckPolicy.findPolicybefore any source is consulted. A plugin cannot give those two accounts a data policy.
-
Refusing is throwing, and silence refuses. A check that returns has allowed the access; a check that refuses throws
AccessDeniedException. There is no third outcome and no boolean for a caller to ignore. Every check method defaults to refusing, so an omission costs you access control you did not think about, never a hole. The two data-policy methods are the exception: their empty default means "this source defines no policy", which is not the same as allowing anything. -
A plugin never imports fe-core. It compiles against
fe-authorization-apiandfe-authorization-spiand nothing else of Doris — the plugin modules simply do not have fe-core on their classpath, which is a stronger guarantee than a gate. What a source cannot decide alone it asks the engine for throughAuthorizationContext(Doris roles, instance-scope authority, ownership). -
The engine adds no caching and grants no exemptions. It cannot know when an external policy changed, so whatever caching a source needs belongs inside the plugin, where it can be invalidated on that source's own terms. And exemptions that used to be the engine's — "an administrator may go anywhere" — are each plugin's own to grant or refuse.
Roles only — one line each. For anything deeper, read the javadoc; it is kept authoritative.
Contracts (both loaded from the FE, never from a plugin jar — see "Classloading")
| Module | Role |
|---|---|
fe-authorization-api |
The decision vocabulary shared by the engine and every source: AuthorizedSubject, AuthorizedResource (a closed hierarchy) with ResourceKind, AccessAction, AccessRequirement / ActionMatch and the named AccessRequirements, AccessContext, AccessDeniedException, and the data-policy payloads RowFilterSpec / DataMaskSpec / RowFilterMergeType. No dependencies at all. |
fe-authorization-spi |
The contract itself: AuthorizationPluginFactory (what a jar publishes), AuthorizationPlugin (the decisions), AuthorizationContext (what the engine answers when a source asks it something). Depends on the api and on fe-extension-spi. Its javadoc is the API reference. |
Two modules where fe-connector has one, because here the split is acyclic: a plugin decides with api types and implements spi interfaces, and the api never mentions the spi. (fe-connector's boundary is bidirectional, which is why splitting it by "who implements" would be circular there.)
Plugins — one module per source, each installed from its own plugin directory.
| Module | Role |
|---|---|
fe-authorization-plugin-ranger-common |
What the Ranger sources share: asking a Ranger policy engine and reading its answer, plus the row-filter/data-mask translation. A library, not a plugin — it publishes no service descriptor and ships no zip, so it can sit in both plugins' lib/ without either directory appearing to publish two sources. |
fe-authorization-plugin-ranger-doris |
A Ranger service of type doris. Answers about every resource kind, which is why it is the one Ranger source installable for a whole instance (access_controller_type = ranger-doris). |
fe-authorization-plugin-ranger-hive |
A Ranger service of type hive, for one external catalog ("access_controller.class" = "ranger-hive"). Also carries the audit handler. |
One source per module is not a style choice: DirectoryPluginRuntimeManager
admits exactly one factory per plugin directory, so two service descriptors
under one directory is a load failure rather than a pair of sources.
Not in this directory, but part of the picture: the built-in privilege model
is InternalAuthorizationPlugin (fe-core, name default), an authorization
source like any other and what access_controller_type defaults to. It lives
with Auth because it is Auth's front door. Read it as the reference for
how a source that governs everything answers the whole contract.
Startup. Env builds AccessControllerManager (fe-core,
org.apache.doris.mysql.privilege), which discovers factories in two rounds:
first ServiceLoader on the classpath (built-ins and tests), then
DirectoryPluginRuntimeManager over the roots named by
Config.authorization_plugins_dir (production). A classpath factory keeps its
name against a directory one, so dropping a jar into the plugin directory can
never displace a source shipped with the FE. Both channels pass the same API
version gate: nothing in this source tree publishes an AuthorizationPluginFactory
on the classpath any more, so in a release package the only thing that channel can
find is a jar somebody added to fe/lib, built against another Doris release —
exactly what the gate is for. A directory that fails is logged and skipped, because
one unusable plugin must not stop an FE from starting; if the failed one is the
source the configuration names, the manager's constructor refuses right
afterwards with the rejection reason appended to the error.
There is also a deprecated channel: AccessControllerFactory (fe-core), read
from loose jars at the root of the same directory. It is still loaded and
wrapped in LegacyAccessControllerPlugin, and a name published both ways
resolves to the newer publication; a jar of this shape in the plugin directory
gives up a name the classpath already publishes, under either contract, so the
"cannot displace a source shipped with the FE" rule holds here too. What it does
not have is a declared API version, so a plugin built against an older Doris is
admitted with no diagnosis. Do not write new sources against it.
Two things such a plugin has to be rebuilt for. Its data policy methods used to
answer with RowFilterPolicy and DataMaskPolicy, types this release deleted;
both signatures erase, so an old build still loads and still answers every
privilege check, and only the first query against a table it holds a policy for
says otherwise - as NoClassDefFoundError if the method body names a deleted
type, otherwise as an IllegalStateException naming the source, raised where
the answer crosses back into the engine. And a PrivPredicate it is handed is
the constant the caller named only where the engine derived the question from
one; a requirement built for a single statement translates back to a predicate
built to match, which is equal to no constant and identical to none. Comparing
with == therefore holds for the questions the engine asks by name and for
nothing else - read getPrivs() and getOp() for the rest.
Selection. Two channels, both naming the source by the string
AuthorizationPluginFactory.name() returns:
| Where | Key | Meaning |
|---|---|---|
fe.conf |
access_controller_type |
The source governing the instance. Defaults to default, the built-in privilege model. |
fe.conf |
authorization_plugins_dir |
Plugin roots, comma-separated. Default ${DORIS_HOME}/plugins/authorization. |
fe.conf |
authorization_config_file_path |
Properties handed to the instance-wide source, as a flat properties file. Default /conf/authorization.conf, resolved under DORIS_HOME. |
| catalog property | access_controller.class |
The source governing that one external catalog. |
| catalog property | access_controller.properties.<k> |
Properties handed to that source, with the prefix stripped. |
access_controller.class is persisted with the catalog and read back verbatim
by every later FE, so it also accepts the class name of the publishing
factory, and AccessControllerManager.SOURCES_THAT_LEFT_THE_KERNEL keeps
working the class names of sources that have since moved out of fe-core. This
is why renaming a factory class is a compatibility event; see AGENTS.md.
Routing a check. Every access decision in fe-core funnels through
AccessControllerManager.decide (yes/no) or decideColumns (which reports the
column that was refused), and both route through controllerOf(resource):
global, resource, workload group, storage vault, the cloud kinds — and
catalog-level grants, which only the instance-wide source ever stores — go to
the source installed for the instance; database, table and columns go to the
source their catalog is bound to. Row filters and column masks
(evalRowFilterPolicies, evalDataMaskPolicy) route the same way.
Lifecycle. A source is created once and kept: unlike an authentication
attempt, an authorization decision happens many times within a single statement,
so a source that caches policies has to be the same instance throughout. The
engine builds a new one only when what configures it changes, and calls
close() on the one it replaces (also on catalog DROP or reset). Note that
Plugin.initialize(PluginContext) is never called for this family:
everything a source needs arrives in create(properties, context), which is
also the only moment the AuthorizationContext can be handed over — the context
has to name the source it belongs to, and the source does not exist until its
factory has run.
Cost. 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. That shapes the contract in two visible places —
requirements are asked about as a whole rather than one action at a time, and
AccessDeniedException records no stack trace and composes its message only if
somebody reads it.
Classloading. Each plugin directory gets its own child-first classloader.
org.apache.doris.authorization. is parent-first for this family, so the api
and spi types crossing the boundary exist exactly once — a plugin carrying its
own copy would hand back objects the engine refuses to recognise as the types it
asked for. Keep both jars <scope>provided</scope> and out of your zip. The
loader's mandatory parent-first prefixes apply on top and are additive:
java., javax., sun., com.sun., org.slf4j., org.apache.logging.,
org.apache.doris.extension.spi., org.apache.doris.connector.spi.. Two
consequences worth knowing before debugging a ClassCastException here:
org.apache.hadoop.is child-first for this family (unlike the filesystem and connector families), so a plugin bundling Hadoop gets its own copy.com.sun.being parent-first splits a bundled Jersey: the host'scom.sun.jersey.corewins, which is why fe-core declaresjersey-clientitself and ranger-common bundles it as afindClassfallback. Neither side alone is enough.
The factory call itself runs with the thread context classloader pinned to the
plugin's own loader, because a bundled library that resolves class names through
the TCCL (Hadoop's Configuration is the recurring case) would otherwise load
half its classes from the engine's copy. Worker threads such a library starts
inherit that loader, which is what keeps them working after the swap is undone.
Packaging. Each plugin module assembles
target/doris-fe-authorization-<name>.zip via
src/main/assembly/plugin-zip.xml: the module jar at the zip root — the only
place scanned for the service descriptor — and everything else under lib/.
build.sh unzips each into its own subdirectory of
output/fe/plugins/authorization/, which is also where an administrator drops a
third-party source. That subdirectory's name is free: the loader takes the
source's name from the factory, never from the directory, so the two need not
match (they do for the sources shipped here, and keeping them equal is kind to
whoever reads the deployment).
This document does not list SPI methods. The truth lives in code, behind four mechanisms:
- Javadoc is the API reference. Start at
AuthorizationPluginandAuthorizationPluginFactory, thenAuthorizationContext. Every method that decides has a default that refuses, so each one's javadoc states when a source is expected to override it and what the default does instead. - The recorded surface.
fe-authorization-spi/src/test/resources/authorization-plugin-surface.txtfreezes everything a plugin can see — not just the SPI interfaces but the wholefe-authorization-apivocabulary a plugin decides with, computed as a closure rather than hand-listed.AuthorizationPluginSurfaceTestfails on any drift. That file is the API inventory; do not maintain one in prose. - Named requirements, but decisions from the action set.
AccessRequirementsnames the questions the engine asks —VISIBILITY,SELECT,ADMINISTRATION,ANY_PRIVILEGE, … — as values, so a source can recognise which one it is being asked. Recognising them is optional and matching on them exclusively is a bug: requirements are also composed at run time (granting a privilege requires holding it and the right to grant it), and a source that only answers the named ones looks, from outside, like a source with a mysteriously incomplete policy. Decide fromrequirement.getActions()andrequirement.isSatisfiedBy(granted). - Unknown is refusal.
ResourceKindis closed and known when a plugin is compiled, and the API version gate admits only matching majors, so a kind a source does not recognise means the plugin was built against a different Doris — refuse it, never guess. A kind that exists but this source does not govern is likewise a refusal, not an error.
The worked example is
fe/fe-core/src/test/java/org/apache/doris/authorizationexample/: a factory, a
plugin that grants by Doris role with one row filter, and a test that installs
it from a plugin directory and puts SQL through it. It is the shortest thing
that answers the questions a first plugin runs into.
Copy from the reference implementations: fe-authorization-plugin-ranger-doris
exercises the whole framework (instance-wide scope, every resource kind, row
filters and masks, a shared library module, a plugin zip);
ExampleAuthorizationPlugin in fe-core's test tree is the minimal contrast;
InternalAuthorizationPlugin (fe-core) shows how a source that governs
everything answers the whole contract.
Steps 2, 12 and 13 are for a source shipped in this repository. A third-party
source needs none of them — only the two jars, the service descriptor and the
manifest entry from fe-authorization-spi/README.md.
- Decide the scope first, because it decides what you must answer. A
catalog-bound source only ever sees
DATABASE,TABLE,COLUMNSand the data policies on tables. An instance-wide source is asked about every kind, includingGLOBAL,RESOURCE,WORKLOAD_GROUP,STORAGE_VAULTand theCLOUD_*kinds — and it answers for administration itself.AuthorizationContext.grantedByGlobalScopeAuthorityreturns false when the source asking is the instance-scope authority, so an instance-wide source with no administration rule of its own locks out every account, including the one that would fix the configuration. - Module. Create
fe-authorization-plugins/fe-authorization-plugin-<name>/and register it infe-authorization-plugins/pom.xml<modules>. Start the pom from ranger-doris: keepfe-authorization-apiandfe-authorization-spiat<scope>provided</scope>, set<finalName>doris-fe-authorization-<name></finalName>, and bindmaven-assembly-pluginto thepackagephase. - Name. Whatever
AuthorizationPluginFactory.name()returns is the whole selector — the value ofaccess_controller_typeand ofaccess_controller.class. Make it globally unique across sources, and note that it is matched case-sensitively (defaultis the one exception, compared case-insensitively because it predates all of this). It is persisted in catalog properties, so renaming it later is a compatibility event; pin it and the factory's class name as literals in a test (copyRangerDorisAccessControllerFactoryTest#testTheSelectorsThisSourceIsNamedBy). - Factory. Implement
AuthorizationPluginFactorywith a no-arg constructor, and register it insrc/main/resources/META-INF/services/org.apache.doris.authorization.spi.AuthorizationPluginFactory— exactly one class, and exactly one such descriptor per plugin directory. Do not implement the no-argcreate(); the SPI default already refuses it with the right message. If your source starts background threads (a policy refresher, say), decide explicitly what a second binding shares with the first:ranger-dorisshares the Ranger plugin, because that is what polling the service costs, and gives a binding configured differently a controller of its own over it — configuration that decides who may read what belongs to the binding that asked for it, never to whichever binding was created first. - Plugin. Implement
AuthorizationPlugin. OverridecheckPrivilegewhen your source can answer about a set of actions in one pass — a bit set, or a walk down a resource hierarchy that remembers what an outer level already granted. OverridecheckActionalone when it cannot, and let the SPI default take the requirement apart; leaving the other at its default is not a hole, because the engine only ever asks about a whole requirement. - Refuse by throwing, and name yourself.
AccessDeniedException.of(subject, resource, requirement, name())for the ordinary case;AccessDeniedException.withMessage(...)where the wording is the answer, as for a column check that must say which column failed. Naming the source is what later lets an operator tell which of the configured sources said no. - Deference to instance scope. If the source is catalog-bound, decide
whether an administrator of the instance may reach what you govern, and
express it by calling
AuthorizationContext.grantedByGlobalScopeAuthorityrather than by testing for a built-in privilege — the answer comes from whoever actually governs instance scope, which may itself be a plugin. Make it configurable if a deployment might want it off (seeRangerAccessController.DEFER_TO_GLOBAL_SCOPE_AUTHORITY), and parse that flag strictly: reading a typo asfalsesilently takes away every administrator's access to every object you govern. - Roles.
AuthorizationContext.rolesOfis the bridge for policies written against Doris roles — the engine, not your source, knows who holds them. Ask for them inside the check that needs them; they are deliberately not carried on the subject, because listing what an account may see walks thousands of objects per statement. - Row filters and column masks. Both are SQL text in Doris dialect; the
engine parses, type-checks and plans them. What the SQL result cache needs is
that the spec as a whole compares unequal once the policy changes: it
decides "did the policies move?" by comparing specs, so two specs that are
equal while the policy underneath is not make a stale plan look current,
while a spec that does not compare equal to an identical one evicts the cache
on every lookup.
policyIdentis the part of a spec that carries identity for auditing; whether it also has to carry the version depends on the rest. Ranger edits a policy in place keeping its id, so its ident is<policyId>:<version>; the built-inCREATE ROW POLICYhas no in-place edit and itsfilterSqlmoves whenever the predicate does, so the policy name alone is enough there. Restrictive filters are ANDed and permissive ones ORed — the engine owns the merge. Returning nothing means "no policy here", never a refusal. - Caching. Inside the plugin, invalidated on your source's terms. The engine adds none and cannot. Your instance is long-lived, so instance state is the place for it.
- Properties. Instance-wide sources are configured from
conf/authorization.conf, catalog-bound ones fromaccess_controller.properties.*— both arrive as a flatMap<String, String>with no binder. Validate in the factory or the constructor and fail loudly; a source built with an unparseable setting is worse than one that refused to be built. - Packaging. Add
src/main/assembly/plugin-zip.xml(copy from ranger-doris): the module jar at the zip root, everything else inlib/, log4j and slf4j excluded because logging is the host's. Verify throughpackage, nottest— the zip only materialises then. Unzip it once and look: the jar at the root, nofe-authorization-api/-spi, no logging implementation. - Ship it. Add the module to both lists in
build.sh— the build list (search_authz_mod) and the deploy list (searchAUTHZ_PLUGIN_DIR). Missing from one is not a no-op: the deploy step unzips whatever archive is left in the module'starget/, so a plugin built but not deployed, or deployed but not rebuilt, ships stale without failing anything. - API version. Nothing to do in tree:
fe/fe-authorization/pom.xmlstampsDoris-Authorization-Plugin-Api-Versioninto every jar built under it. Out of tree, add themaven-jar-plugin<manifestEntries>block fromfe-authorization-spi/README.md— a jar that declares nothing is refused, so a plugin written with no awareness of this contract cannot slip through. - Third-party dependencies. Bundle what you need rather than depending on what the host happens to carry; a plugin whose dependencies come half from the host is a plugin whose behaviour changes when the host is upgraded. Two CI gates apply: the ASF header check on every new file, and the dependency license review on every changed pom (see AGENTS.md).
- Tests. Module-level unit tests with Mockito, as the Ranger plugins do. If the new source changes what the engine decides, it belongs in fe-core's behaviour baseline too — see "Testing and Verification".
- Unit tests live in each module; recipes are in
AGENTS.md. - The frozen contract is guarded by fe-authorization-spi's own suite:
AuthorizationPluginSurfaceTest(the surface baseline) andAuthorizationPluginContractTest(silence refuses;ANYvsALLare taken apart the way the requirement says). Run that module's tests after any change to the api or the spi — a consumer-only run will not catch a stale baseline. - The behaviour baseline.
AccessControlBehaviorBaselineTest(fe-core) records every decision over the matrix (resource kind × action × source × privilege level of the caller) intofe/fe-core/src/test/resources/access-control-behavior-baseline.txt. The built-in half runs against a real FE with realGRANTstatements; the Ranger half runs the production controller over a deterministic stub policy engine. After a change meant to be structural,git diffon that file must be empty. - Installed-plugin end to end.
AuthorizationPluginFromDirectoryTest(fe-core) writes a plugin jar into a temporaryauthorization_plugins_dir, starts an FE on it and checks from SQL that the plugin really decides — that an account the built-in model granted nothing can read, that an account it grantedSELECTcannot, and that the row filter is planned. Copy it whenever a new mechanism can only be proved from SQL. - Version wiring is
PluginApiVersionWiringTest(fe-core), which proves each family's gate is built from its own kernel resource and moves independently of the others. - Selector compatibility is
AuthorizationSourceSelectorCompatibilityTest(fe-core): every string an older release let an operator select a source by still selects it. - Against a live Ranger: suites under
regression-test/suites/ranger_p2/, environment underdocker/thirdparties/docker-compose/ranger/. - At runtime:
SELECT * FROM information_schema.extensionslists what was actually admitted, per family — a plugin refused on its API version is absent there and explained infe.log.
Update this file ONLY when a framework-level fact changes:
- a module is added, removed or renamed under
fe/fe-authorization/; - the loading, selection, routing or lifecycle model changes;
- a durable invariant appears that no gate or test can express.
Do NOT update it for SPI method changes (javadoc is the API reference,
authorization-plugin-surface.txt is the recorded surface), for a new
AccessAction or ResourceKind constant, or for bug fixes.