FrankChen021 opened a new issue, #20847:
URL: https://github.com/apache/druid/issues/20847

   ### Motivation
   
   When a SQL query fails authorization, Druid returns:
   
   ```json
   
{"error":"druidException","errorCode":"forbidden","persona":"USER","category":"FORBIDDEN","errorMessage":"Unauthorized"}
   ```
   
   The user cannot tell which permission is missing. An MSQ ingestion such as
   
   ```sql
   SET finalizeAggregations = FALSE;
   REPLACE INTO "kttm" OVERWRITE ALL
   SELECT ... FROM TABLE(EXTERN('{"type":"http",...}', ...)) ...
   ```
   
   is checked against several kinds of resources, and failing any one of them 
produces the same message:
   
   | Resource type | Name | Action | Required by |
   |---|---|---|---|
   | `DATASOURCE` | `kttm` | WRITE | `INSERT` / `REPLACE INTO kttm` |
   | `DATASOURCE` | each table read | READ | `FROM` / `JOIN` |
   | `VIEW` | each view read | READ | `FROM view.x` (underlying tables are not 
checked) |
   | `EXTERNAL` | `EXTERNAL`, or the input source type (e.g. `http`) when 
`druid.auth.enableInputSourceSecurity=true` | READ | `EXTERN(...)` and other 
external table functions |
   | `EXTERNAL` | the export destination type | WRITE | `INSERT INTO 
EXTERN(...)` exports (`IngestHandler`) |
   | `QUERY_CONTEXT` | each secured context key | WRITE | when 
`druid.auth.authorizeQueryContextParams=true`; secured keys are the keys the 
user set, minus Druid's always-allowed keys and 
`druid.auth.unsecuredContextKeys`, intersected with 
`druid.auth.securedContextKeys` if that is set 
(`AuthConfig.contextKeysToAuthorize`) |
   
   Users must ask an operator to read Broker logs, or bisect the query by trial 
and error. Three gaps cause this:
   
   1. **The denied resource is dropped.** 
`AuthorizationUtils.authorizeAllResourceActions()` knows which `ResourceAction` 
was denied (it reports it in the `auth/forbidden` metric, #19552) but returns 
only `Access.getMessage()`. `BasicRoleBasedAuthorizer` denies with an empty 
reason, so that is `"Unauthorized"`.
   2. **The MSQ endpoints replace the message.** `SqlStatementResource` (4 
`catch (ForbiddenException e)` sites) and `SqlTaskResource` return the constant 
`"Unauthorized"` whatever the exception says.
   3. **Users cannot inspect their own permissions.** `Authorizer` exposes only 
`authorize(AuthenticationResult, Resource, Action)`. The basic-security user 
API 
(`/druid-ext/basic-security/authorization/db/{authorizer}/users/{user}?full`) 
is an admin API.
   
   ### Proposed changes
   
   #### Part 1: Name the denied resource in SQL 403 messages
   
   Before:
   
   ```
   Unauthorized
   ```
   
   After:
   
   ```
   Unauthorized: user[alice] has no READ permission on resource type[EXTERNAL] 
name[http] in authorizer[basic]. Ask your administrator to grant this 
permission to you or your roles.
   ```
   
   - **Where it applies:** SQL only, meaning `/druid/v2/sql`, the MSQ APIs 
(`/druid/v2/sql/statements` submission, `/druid/v2/sql/task`), Dart and JDBC. 
Every resource checked there comes from the query text or the query context, so 
the message names nothing the caller did not send. Non-SQL REST endpoints keep 
returning `Unauthorized`, because some of them check resources the caller did 
not name (for example `TaskResourceFilter` authorizes the datasource of a task 
looked up by ID).
   - **Unchanged:** `errorCode` (`forbidden`), `category`, `persona` and HTTP 
status 403.
   - **`druid.server.http.errorResponseTransform` is respected:** where the 
configured strategy would hide the message, the response is the bare 
`Unauthorized` as before.
   
   #### Part 2: `sys.grants`
   
   **SPI.** Add one default method to `Authorizer`:
   
   ```java
   /**
    * Returns the permissions held by the identity in authenticationResult, or 
Optional.empty() if this
    * authorizer cannot enumerate permissions. Must not return null.
    */
   default Optional<List<Grant>> listGrants(AuthenticationResult 
authenticationResult)
   {
     return Optional.empty();
   }
   ```
   
   `Grant` is a new value class in `org.apache.druid.server.security` holding 
`resourceType`, `resourceName` and `action`. They map to the same-named table 
columns below; `authorizer` and `identity` come from the caller's 
`AuthenticationResult`.
   
   **Table.** `sys.grants`, evaluated on the Broker, returns one row per 
permission held by the calling identity. Columns follow `SystemSchema` 
conventions (snake_case, `STRING`/`LONG` types):
   
   | Column | Type | Meaning |
   |---|---|---|
   | `authorizer` | STRING | Name of the caller's authorizer |
   | `identity` | STRING | The caller's identity. Always the same value; 
included so rows are self-describing when exported or joined. |
   | `resource_type` | STRING | One of `ResourceType.knownTypes()`; never a 
wildcard |
   | `resource_name` | STRING | The name or pattern as stored by the 
authorizer, in that authorizer's syntax. For both authorizers in this proposal 
it is a Java regex that must match the whole resource name. |
   | `action` | STRING | `READ` or `WRITE` |
   
   Rules:
   
   - **Caller only.** Rows come from the `AuthenticationResult` in the query's 
data context (`DATA_CTX_AUTHENTICATION_RESULT`, as `SystemSchema` already does 
for row filtering). There is no way to read another identity's grants through 
this table; admins keep the basic-security API for that.
   - **Unsupported authorizers fail.** If `listGrants` returns 
`Optional.empty()`, the query fails with `DruidException` persona `USER`, 
category `UNSUPPORTED`: `Authorizer [<name>] does not support listing grants`. 
It must not return zero rows, which would read as "you hold no permissions".
   - **Policy restrictions are not shown.** A row means the action is allowed; 
it may still be subject to a row-level policy (for example one configured on 
`AllowAllAuthorizer`, or returned by a custom authorizer).
   - **No duplicate rows.** When several roles grant the same permission, it 
appears once. The table does not say which role a permission comes from.
   - **Access to the table** follows the existing system-table rules. With 
`druid.sql.planner.authorizeSystemTablesDirectly=true`, it needs READ on 
`SYSTEM_TABLE` `grants`; otherwise no extra permission.
   
   **Implementations in this proposal:**
   
   - **`BasicRoleBasedAuthorizer`:** the permissions of every role returned by 
`roleProvider.getRoles(name, authenticationResult)`, which includes roles 
assigned directly and roles from LDAP group mappings. One row per 
`BasicAuthorizerPermission`. Reads only the in-memory role cache.
   - **`AllowAllAuthorizer`:** one row for each type in 
`ResourceType.knownTypes()` × each `Action`, with `resource_name = '.*'`.
   - **`druid-ranger-security` and third-party authorizers:** inherit the 
default and report `UNSUPPORTED`.
   
   **Sample rows.** User `alice` on a basic-security authorizer named `basic`, 
holding two roles: `analyst` (read every datasource, read cluster state) and 
`kttm_ingest` (write `kttm*` datasources, read HTTP input sources, set one 
secured context key). `SELECT * FROM sys.grants` returns:
   
   | authorizer | identity | resource_type | resource_name | action |
   |---|---|---|---|---|
   | basic | alice | DATASOURCE | .* | READ |
   | basic | alice | STATE | STATE | READ |
   | basic | alice | DATASOURCE | kttm.* | WRITE |
   | basic | alice | EXTERNAL | http | READ |
   | basic | alice | QUERY_CONTEXT | finalizeAggregations | WRITE |
   
   For the MSQ query in Motivation, these rows show `alice` holds WRITE on 
`kttm`, READ on `EXTERNAL` `http`, and the `finalizeAggregations` context key. 
If `druid.auth.enableInputSourceSecurity` were false, the query would need READ 
on `EXTERNAL` named `EXTERNAL`, which no row grants.
   
   The same query on a cluster using `AllowAllAuthorizer` (authorizer and 
identity `allowAll`) returns one row per known resource type and action (rows 
abbreviated):
   
   | authorizer | identity | resource_type | resource_name | action |
   |---|---|---|---|---|
   | allowAll | allowAll | DATASOURCE | .* | READ |
   | allowAll | allowAll | DATASOURCE | .* | WRITE |
   | allowAll | allowAll | EXTERNAL | .* | READ |
   | … | | | | |
   
   Filtering works like any other table:
   
   ```sql
   SELECT resource_type, resource_name, action
   FROM sys.grants
   WHERE resource_type = 'EXTERNAL'
   ```
   
   `resource_name` holds patterns, so `WHERE resource_name = 'kttm'` does not 
find a `.*` grant. To ask "can I write `kttm`?", match the pattern the way the 
authorizer does. For basic-security and allow-all, anchor the regex, because 
`REGEXP_LIKE` finds a match anywhere in the string:
   
   ```sql
   SELECT resource_name
   FROM sys.grants
   WHERE resource_type = 'DATASOURCE'
     AND action = 'WRITE'
     AND REGEXP_LIKE('kttm', '^(?:' || resource_name || ')$')
   ```
   
   ### Rationale
   
   - **Name the resource only on the SQL path.** The shared authorization code 
keeps the authorizer's message unchanged and records the denied resource 
separately; only the SQL layer, where every checked resource comes from the 
query, puts it into the message. Non-SQL callers stay unchanged.
   - **Part 1 answers "why was I denied"; `sys.grants` answers "what do I 
hold".** Together they cover the self-service need without a separate 
permission-check function. Such a function (like PostgreSQL's 
`has_table_privilege` or SQL Server's `HAS_PERMS_BY_NAME`) is not proposed.
   - **System table instead of `SHOW GRANTS` syntax.** No parser grammar 
change; composes with `WHERE`/`ORDER BY`; works the same from the web console, 
JDBC and the SQL API, like `sys.tasks` and `sys.servers`.
   - **Comparison with other databases.** PostgreSQL 
(`information_schema.table_privileges`), MySQL (`TABLE_PRIVILEGES`), ClickHouse 
(`system.grants`), Snowflake (`SHOW GRANTS`) and StarRocks 
(`sys.grants_to_users`) all include a grantee, which `identity` covers. Most 
also distinguish a grant to a user from a grant through a role; this proposal 
does not. `grant_option`/`is_grantable` is omitted because Druid SQL has no 
`GRANT`. Only ClickHouse models wildcards and partial revokes. Both authorizers 
here use one pattern syntax, so no syntax column is needed yet (see Future 
work), and neither produces deny grants. A separate role-membership table 
(ClickHouse `system.role_grants`) is not needed because basic-security roles do 
not nest.
   - **Rejected: listing a query's required resources via EXPLAIN.** `EXPLAIN 
PLAN FOR` reports resources but is authorized like the query itself, so a user 
lacking the permission gets the same 403.
   
   ### Operational impact
   
   - **SQL error text changes.** Clients that match the exact string 
`"Unauthorized"` on SQL responses must check `errorCode == "forbidden"` or HTTP 
403 instead. Release notes will say so. Non-SQL endpoints are unchanged.
   - **New public API surface:** `sys.grants`, `Authorizer.listGrants`, `Grant`.
   - **No new configuration, no metadata-store change, no rolling-upgrade 
ordering.** Authorizer authors need no action.
   
   ### Future work
   
   - `SHOW GRANTS` as a parser shortcut for `SELECT * FROM sys.grants`.
   - `listGrants` for `druid-ranger-security`, together with a 
`resource_name_syntax` column (e.g. `JAVA_REGEX_FULL_MATCH`, `WILDCARD`, 
`EXACT`), because Ranger patterns use `*`/`?` wildcards rather than Java regex.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to