LuciferYang commented on code in PR #14539: URL: https://github.com/apache/gravitino/pull/14539#discussion_r4450524736
########## design-docs/gravitino-trusted-proxy.md: ########## @@ -0,0 +1,401 @@ +<!-- + 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. +--> + +# Design: Trusted Proxy Users for Apache Gravitino + +--- + +## Background + +Gravitino authenticates each REST request with one of the authenticators in `gravitino.authenticators` (`simple`, `basic`, `oauth`, `kerberos`), and authorization, ownership and audit use the authenticated principal. This breaks when a service sits between the end user and Gravitino and the end user holds no credential that Gravitino can verify: + +| Deployment | Who authenticates the end user | What Gravitino sees today | +|------------|--------------------------------|---------------------------| +| Trino serving BI, JDBC and ODBC clients | Trino | With `authType=simple` and `gravitino.client.session.forwardUser=true`, an unverified session user name. With `oauth2`, the user's token if the session has one, otherwise the connector's identity. With `basic` or `kerberos`, only the connector's identity, because a catalog that sets `forwardUser=true` fails to load ([#13472](https://github.com/apache/gravitino/issues/13472)). | +| Standalone Lance REST server | Lance REST | One configured service identity, so the service account owns every table Lance REST creates and audit never records the caller ([#13090](https://github.com/apache/gravitino/issues/13090)). | + +Knox in front of the Gravitino UI has the same problem ([#8876](https://github.com/apache/gravitino/issues/8876)); see Non-Goal 7. + +Hadoop (`hadoop.proxyuser.*`), HiveServer2 and Kyuubi let an authenticated service act for a user that server-side rules allow. Gravitino has no such mechanism, and `SimpleAuthenticator` accepts any user name the client sends. + +--- + +## Goals + +1. **Trusted proxy**: A request carrying `X-Gravitino-Proxy-User: alice` runs as `alice` for authorization, ownership and audit if and only if proxying is enabled, its proxy headers pass the checks in [Headers](#headers), and one proxy rule matches the authenticated identity and allows `alice`, every declared group and the TCP peer address. The request must be authenticated by Kerberos, by OAuth 2 with `JwksTokenValidator` or `StaticSignKeyValidator`, by Basic, or by a custom authenticator that supports proxying. A request that fails authentication is rejected as today; among authenticated requests, a malformed proxy header gets 400 and any other failed condition gets 403. +2. **Real user in audit**: For every proxied request, post events and audit logs in the `SimpleAuditLogV2` and JSON formats record the authenticated identity as `realUser`. +3. **Per-user authorization behind Trino**: With `gravitino.client.session.forwardUser=true`, the Trino connector's Gravitino calls run as the session user under `authType=basic` and `kerberos`, and under `oauth2` for sessions without a user token when `gravitino.client.session.forwardUser.useProxy=true`. +4. **No silent fallback**: A client configured to proxy a user fails at `build()` when the server does not apply the proxy header, and rejects any later successful response that the server did not run as the proxied user. +5. **Per-caller identity behind Lance REST**: With `gravitino.lance-rest.gravitino-proxy-caller=true`, objects that a caller creates through the standalone Lance REST server are owned and audited as that caller. +6. **Off by default**: Without the new settings, requests without the new headers behave as today, apart from the [fileset fix](#fileset-fix), and requests with a proxy header are rejected. + +--- + +## Non-Goals + +1. **A Trino `SystemAccessControl` plugin or an `/authorize` decision API**: Running the connector's own calls as the end user already gives per-user decisions. A decision API can be added later; see [Solution Investigations](#solution-investigations). +2. **Server-side group resolution**: Gravitino does not look up a proxied user's groups. The proxy declares them, limited to its rule's `groups`. A group mapping SPI belongs with LDAP support ([#6006](https://github.com/apache/gravitino/issues/6006)). +3. **Credentials for processes that run user code**: User code in such a process, for example a Spark engine that Kyuubi launches, could read a proxy's credential and act for every user the proxy's rule allows. Such processes need a per-user credential, which is out of scope. +4. **Per-user identity on Iceberg REST paths that bypass the Gravitino client or forward the caller's credential**: Trino catalogs routed to Gravitino's Iceberg REST service use Trino's native Iceberg REST client, which never sends the Gravitino client's headers, so for proxied sessions they keep the identity configured for that route. Iceberg catalogs with a `rest` backend forward the caller's own `Authorization` header through `UserPrincipalForwardingAuthManager`; proxied principals carry none, so their requests to such catalogs fail. +5. **Python client**: No deployment in [Background](#background) uses the Python client. A Python `with_proxy_user` needs no server change and can follow separately. +6. **Replacing token exchange**: Sites whose IdP supports RFC 8693 token exchange can keep using it ([#13092](https://github.com/apache/gravitino/issues/13092)); Gravitino sees an exchanged token as an ordinary OAuth token. +7. **Knox and the MCP server**: Knox passes the end user in a `doAs` query parameter. Using this mechanism needs either a Knox dispatch that sends `X-Gravitino-Proxy-User` instead, or Gravitino accepting `doAs` under the same proxy rules; [#8877](https://github.com/apache/gravitino/pull/8877) proposes accepting `doAs` from any Kerberos caller. That choice is left to a follow-up. The MCP server forwards the `Authorization` header of each HTTP request to Gravitino, by default uses its own configured identity when there is none, and does not authenticate its callers, so a proxy mode would first need caller authentication in the MCP server. + +--- + +## Solution Investigations + +| Approach | Pros | Cons | Decision | +|----------|------|------|----------| +| Decision API `POST /api/metalakes/{metalake}/authorize` plus a Trino `SystemAccessControl` plugin (the sketch in #11307) | Trino enforces before planning and covers every Trino check hook | Answers allow or deny only. The connector's metadata calls still run as the service account, so owners and audit stay wrong. Each engine needs its own plugin. Does nothing for Lance REST. | Rejected | +| RFC 8693 token exchange at the IdP | Standard; Gravitino sees an ordinary OAuth token and needs no proxy rules | Requires an IdP that supports the exchange grant; Kerberos-only sites have none | Compatible, not the base mechanism | +| Replay the end user's credential (Trino `oauth2` forwarding; [#12984](https://github.com/apache/gravitino/pull/12984) for Lance REST) | No new trust configuration | Trino's SPI does not pass passwords to connectors ([#13473](https://github.com/apache/gravitino/pull/13473)). A Kerberos ticket cannot be replayed to another service. The relay holds the user's credential. | Rejected | +| Trust `simple` user names from a trusted network | Works today | Nothing is authenticated; any client can claim any name | Rejected | +| Trusted proxy header checked against server rules | Same model as Hadoop proxy users, which Trino, Kyuubi and HiveServer2 operators already run. Works with any strong authenticator. One server change serves every such deployment. | New trust configuration to manage | **Chosen** | + +--- + +## Proposal + +### Overview + +``` + BI client ──► Trino ──(Kerberos as trino/[email protected] + X-Gravitino-Proxy-User: alice)──► Gravitino + caller ────► Lance REST ──(OAuth as lance-rest + X-Gravitino-Proxy-User: bob)─────────────────► Gravitino +``` + +### Headers + +| Header | Direction | Value | Notes | +|--------|-----------|-------|-------| +| `X-Gravitino-Proxy-User` | Request | One user name | The effective user of the request | +| `X-Gravitino-Proxy-Groups` | Request | Comma-separated group names | Valid only with `X-Gravitino-Proxy-User` | +| `X-Gravitino-Effective-User` | Response | One user name | Set on every response to a request whose proxy header the server applied | + +Names are UTF-8 and percent-encoded as in RFC 3986. The server encodes `X-Gravitino-Effective-User` the same way, and the client decodes it strictly before comparing. The server splits `X-Gravitino-Proxy-Groups` on raw commas before decoding, so a comma inside a group name is sent as `%2C`. + +The server rejects with 400 a repeated proxy header, a name with an invalid percent escape or invalid UTF-8 (`+` is a literal `+`), and any decoded name that: + +- is empty or has a leading or trailing space; +- contains a space separator (category Zs) other than U+0020, a character of category Cc, Cf, Zl, Zp, Co, Cs or Cn, or a `Default_Ignorable_Code_Point` character such as a zero-width space or a variation selector; +- changes under NFKC normalization, which covers non-NFC forms and compatibility characters such as full-width letters and the long s `ſ`. + +Every name the server compares is therefore in one form and has no invisible characters. Names may contain `/` and `@`, as e-mail addresses and Kerberos principals do. + +### Configuration + +Key names follow the existing `gravitino.authenticator.oauth.*` style. `<id>` is a rule name chosen by the administrator. + +| Key | Default | Description | +|-----|---------|-------------| +| `gravitino.authenticator.proxy.enable` | `false` | Whether the server accepts proxy headers. When `false`, a proxy header gets 403, or 400 when malformed. | +| `gravitino.authenticator.proxy.rules` | empty | Comma-separated rule names | +| `gravitino.authenticator.proxy.rule.<id>.authenticator` | none, required | The authenticator that must have accepted the request: `kerberos`, `oauth`, `basic`, or a custom authenticator's class name as written in `gravitino.authenticators` | +| `gravitino.authenticator.proxy.rule.<id>.issuer` | none, required for `oauth` | The `iss` claim the proxy's token must carry | +| `gravitino.authenticator.proxy.rule.<id>.principals` | none, required | Comma-separated proxy identities the rule applies to (see below) | +| `gravitino.authenticator.proxy.rule.<id>.users` | none, required | Comma-separated users the rule allows, or `*` | +| `gravitino.authenticator.proxy.rule.<id>.excludeUsers` | empty | Comma-separated users the rule never allows, even when `users` names them | +| `gravitino.authenticator.proxy.rule.<id>.groups` | empty | Comma-separated groups the proxy may declare, or `*`. When empty, `X-Gravitino-Proxy-Groups` is rejected. | +| `gravitino.authenticator.proxy.rule.<id>.hosts` | `*` | Comma-separated IP addresses or CIDR ranges the TCP peer address must be in, or `*` | + +A rule matches when the authenticator that accepted the request is the rule's `authenticator` and the proxy identity is in `principals`. The proxy identity is the name the authenticator verified, before principal mapping: + +| Authenticator | Proxy identity | Accepted forms in `principals` | Example | +|---------------|----------------|--------------------------------|---------| +| `kerberos` | The full principal, with instance and realm | `primary@REALM`, `primary/instance@REALM`, or `primary/*@REALM`, where `*` matches one non-empty instance; primary and realm are literal | `trino/*@EXAMPLE.COM` | +| `oauth` | The token's `sub`, only when its `iss` equals the rule's `issuer` | Literal values without `*` | `lance-rest` | +| `basic` | The user name | Literal values without `*` | `etl-service` | +| A custom authenticator, by class name | The proxy identity the authenticator sets on its `UserPrincipal` | Literal values without `*` | `svc-gateway` | + +The OAuth proxy identity is always `sub`, whatever `gravitino.authenticator.oauth.principalFields` selects, because users can choose claims such as `preferred_username` in many IdPs. + +Rule semantics: + +- One rule must allow everything in a request: the authenticator, the identity, the user, every declared group and the peer address. Rules do not combine. +- `*` in `users` matches no user in `gravitino.authorization.serviceAdmins`, so a service admin can be proxied only when named in `users`. `anonymous` cannot be proxied. +- `hosts` is compared with `HttpServletRequest.getRemoteAddr()`, never with `X-Forwarded-For`. Behind a load balancer the peer is the balancer, so `hosts` stays `*` there. Review Comment: Agreed, reworded: `hosts` can list the balancer's addresses to keep out requests that bypass it, but cannot restrict the clients behind it. ########## design-docs/gravitino-trusted-proxy.md: ########## @@ -0,0 +1,401 @@ +<!-- + 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. +--> + +# Design: Trusted Proxy Users for Apache Gravitino + +--- + +## Background + +Gravitino authenticates each REST request with one of the authenticators in `gravitino.authenticators` (`simple`, `basic`, `oauth`, `kerberos`), and authorization, ownership and audit use the authenticated principal. This breaks when a service sits between the end user and Gravitino and the end user holds no credential that Gravitino can verify: + +| Deployment | Who authenticates the end user | What Gravitino sees today | +|------------|--------------------------------|---------------------------| +| Trino serving BI, JDBC and ODBC clients | Trino | With `authType=simple` and `gravitino.client.session.forwardUser=true`, an unverified session user name. With `oauth2`, the user's token if the session has one, otherwise the connector's identity. With `basic` or `kerberos`, only the connector's identity, because a catalog that sets `forwardUser=true` fails to load ([#13472](https://github.com/apache/gravitino/issues/13472)). | +| Standalone Lance REST server | Lance REST | One configured service identity, so the service account owns every table Lance REST creates and audit never records the caller ([#13090](https://github.com/apache/gravitino/issues/13090)). | + +Knox in front of the Gravitino UI has the same problem ([#8876](https://github.com/apache/gravitino/issues/8876)); see Non-Goal 7. + +Hadoop (`hadoop.proxyuser.*`), HiveServer2 and Kyuubi let an authenticated service act for a user that server-side rules allow. Gravitino has no such mechanism, and `SimpleAuthenticator` accepts any user name the client sends. + +--- + +## Goals + +1. **Trusted proxy**: A request carrying `X-Gravitino-Proxy-User: alice` runs as `alice` for authorization, ownership and audit if and only if proxying is enabled, its proxy headers pass the checks in [Headers](#headers), and one proxy rule matches the authenticated identity and allows `alice`, every declared group and the TCP peer address. The request must be authenticated by Kerberos, by OAuth 2 with `JwksTokenValidator` or `StaticSignKeyValidator`, by Basic, or by a custom authenticator that supports proxying. A request that fails authentication is rejected as today; among authenticated requests, a malformed proxy header gets 400 and any other failed condition gets 403. +2. **Real user in audit**: For every proxied request, post events and audit logs in the `SimpleAuditLogV2` and JSON formats record the authenticated identity as `realUser`. +3. **Per-user authorization behind Trino**: With `gravitino.client.session.forwardUser=true`, the Trino connector's Gravitino calls run as the session user under `authType=basic` and `kerberos`, and under `oauth2` for sessions without a user token when `gravitino.client.session.forwardUser.useProxy=true`. +4. **No silent fallback**: A client configured to proxy a user fails at `build()` when the server does not apply the proxy header, and rejects any later successful response that the server did not run as the proxied user. +5. **Per-caller identity behind Lance REST**: With `gravitino.lance-rest.gravitino-proxy-caller=true`, objects that a caller creates through the standalone Lance REST server are owned and audited as that caller. +6. **Off by default**: Without the new settings, requests without the new headers behave as today, apart from the [fileset fix](#fileset-fix), and requests with a proxy header are rejected. + +--- + +## Non-Goals + +1. **A Trino `SystemAccessControl` plugin or an `/authorize` decision API**: Running the connector's own calls as the end user already gives per-user decisions. A decision API can be added later; see [Solution Investigations](#solution-investigations). +2. **Server-side group resolution**: Gravitino does not look up a proxied user's groups. The proxy declares them, limited to its rule's `groups`. A group mapping SPI belongs with LDAP support ([#6006](https://github.com/apache/gravitino/issues/6006)). +3. **Credentials for processes that run user code**: User code in such a process, for example a Spark engine that Kyuubi launches, could read a proxy's credential and act for every user the proxy's rule allows. Such processes need a per-user credential, which is out of scope. +4. **Per-user identity on Iceberg REST paths that bypass the Gravitino client or forward the caller's credential**: Trino catalogs routed to Gravitino's Iceberg REST service use Trino's native Iceberg REST client, which never sends the Gravitino client's headers, so for proxied sessions they keep the identity configured for that route. Iceberg catalogs with a `rest` backend forward the caller's own `Authorization` header through `UserPrincipalForwardingAuthManager`; proxied principals carry none, so their requests to such catalogs fail. +5. **Python client**: No deployment in [Background](#background) uses the Python client. A Python `with_proxy_user` needs no server change and can follow separately. +6. **Replacing token exchange**: Sites whose IdP supports RFC 8693 token exchange can keep using it ([#13092](https://github.com/apache/gravitino/issues/13092)); Gravitino sees an exchanged token as an ordinary OAuth token. +7. **Knox and the MCP server**: Knox passes the end user in a `doAs` query parameter. Using this mechanism needs either a Knox dispatch that sends `X-Gravitino-Proxy-User` instead, or Gravitino accepting `doAs` under the same proxy rules; [#8877](https://github.com/apache/gravitino/pull/8877) proposes accepting `doAs` from any Kerberos caller. That choice is left to a follow-up. The MCP server forwards the `Authorization` header of each HTTP request to Gravitino, by default uses its own configured identity when there is none, and does not authenticate its callers, so a proxy mode would first need caller authentication in the MCP server. + +--- + +## Solution Investigations + +| Approach | Pros | Cons | Decision | +|----------|------|------|----------| +| Decision API `POST /api/metalakes/{metalake}/authorize` plus a Trino `SystemAccessControl` plugin (the sketch in #11307) | Trino enforces before planning and covers every Trino check hook | Answers allow or deny only. The connector's metadata calls still run as the service account, so owners and audit stay wrong. Each engine needs its own plugin. Does nothing for Lance REST. | Rejected | +| RFC 8693 token exchange at the IdP | Standard; Gravitino sees an ordinary OAuth token and needs no proxy rules | Requires an IdP that supports the exchange grant; Kerberos-only sites have none | Compatible, not the base mechanism | +| Replay the end user's credential (Trino `oauth2` forwarding; [#12984](https://github.com/apache/gravitino/pull/12984) for Lance REST) | No new trust configuration | Trino's SPI does not pass passwords to connectors ([#13473](https://github.com/apache/gravitino/pull/13473)). A Kerberos ticket cannot be replayed to another service. The relay holds the user's credential. | Rejected | +| Trust `simple` user names from a trusted network | Works today | Nothing is authenticated; any client can claim any name | Rejected | +| Trusted proxy header checked against server rules | Same model as Hadoop proxy users, which Trino, Kyuubi and HiveServer2 operators already run. Works with any strong authenticator. One server change serves every such deployment. | New trust configuration to manage | **Chosen** | + +--- + +## Proposal + +### Overview + +``` + BI client ──► Trino ──(Kerberos as trino/[email protected] + X-Gravitino-Proxy-User: alice)──► Gravitino + caller ────► Lance REST ──(OAuth as lance-rest + X-Gravitino-Proxy-User: bob)─────────────────► Gravitino +``` + +### Headers + +| Header | Direction | Value | Notes | +|--------|-----------|-------|-------| +| `X-Gravitino-Proxy-User` | Request | One user name | The effective user of the request | +| `X-Gravitino-Proxy-Groups` | Request | Comma-separated group names | Valid only with `X-Gravitino-Proxy-User` | +| `X-Gravitino-Effective-User` | Response | One user name | Set on every response to a request whose proxy header the server applied | + +Names are UTF-8 and percent-encoded as in RFC 3986. The server encodes `X-Gravitino-Effective-User` the same way, and the client decodes it strictly before comparing. The server splits `X-Gravitino-Proxy-Groups` on raw commas before decoding, so a comma inside a group name is sent as `%2C`. + +The server rejects with 400 a repeated proxy header, a name with an invalid percent escape or invalid UTF-8 (`+` is a literal `+`), and any decoded name that: + +- is empty or has a leading or trailing space; +- contains a space separator (category Zs) other than U+0020, a character of category Cc, Cf, Zl, Zp, Co, Cs or Cn, or a `Default_Ignorable_Code_Point` character such as a zero-width space or a variation selector; +- changes under NFKC normalization, which covers non-NFC forms and compatibility characters such as full-width letters and the long s `ſ`. + +Every name the server compares is therefore in one form and has no invisible characters. Names may contain `/` and `@`, as e-mail addresses and Kerberos principals do. + +### Configuration + +Key names follow the existing `gravitino.authenticator.oauth.*` style. `<id>` is a rule name chosen by the administrator. + +| Key | Default | Description | +|-----|---------|-------------| +| `gravitino.authenticator.proxy.enable` | `false` | Whether the server accepts proxy headers. When `false`, a proxy header gets 403, or 400 when malformed. | +| `gravitino.authenticator.proxy.rules` | empty | Comma-separated rule names | +| `gravitino.authenticator.proxy.rule.<id>.authenticator` | none, required | The authenticator that must have accepted the request: `kerberos`, `oauth`, `basic`, or a custom authenticator's class name as written in `gravitino.authenticators` | +| `gravitino.authenticator.proxy.rule.<id>.issuer` | none, required for `oauth` | The `iss` claim the proxy's token must carry | +| `gravitino.authenticator.proxy.rule.<id>.principals` | none, required | Comma-separated proxy identities the rule applies to (see below) | +| `gravitino.authenticator.proxy.rule.<id>.users` | none, required | Comma-separated users the rule allows, or `*` | +| `gravitino.authenticator.proxy.rule.<id>.excludeUsers` | empty | Comma-separated users the rule never allows, even when `users` names them | +| `gravitino.authenticator.proxy.rule.<id>.groups` | empty | Comma-separated groups the proxy may declare, or `*`. When empty, `X-Gravitino-Proxy-Groups` is rejected. | +| `gravitino.authenticator.proxy.rule.<id>.hosts` | `*` | Comma-separated IP addresses or CIDR ranges the TCP peer address must be in, or `*` | + +A rule matches when the authenticator that accepted the request is the rule's `authenticator` and the proxy identity is in `principals`. The proxy identity is the name the authenticator verified, before principal mapping: + +| Authenticator | Proxy identity | Accepted forms in `principals` | Example | +|---------------|----------------|--------------------------------|---------| +| `kerberos` | The full principal, with instance and realm | `primary@REALM`, `primary/instance@REALM`, or `primary/*@REALM`, where `*` matches one non-empty instance; primary and realm are literal | `trino/*@EXAMPLE.COM` | +| `oauth` | The token's `sub`, only when its `iss` equals the rule's `issuer` | Literal values without `*` | `lance-rest` | +| `basic` | The user name | Literal values without `*` | `etl-service` | +| A custom authenticator, by class name | The proxy identity the authenticator sets on its `UserPrincipal` | Literal values without `*` | `svc-gateway` | + +The OAuth proxy identity is always `sub`, whatever `gravitino.authenticator.oauth.principalFields` selects, because users can choose claims such as `preferred_username` in many IdPs. + +Rule semantics: + +- One rule must allow everything in a request: the authenticator, the identity, the user, every declared group and the peer address. Rules do not combine. +- `*` in `users` matches no user in `gravitino.authorization.serviceAdmins`, so a service admin can be proxied only when named in `users`. `anonymous` cannot be proxied. +- `hosts` is compared with `HttpServletRequest.getRemoteAddr()`, never with `X-Forwarded-For`. Behind a load balancer the peer is the balancer, so `hosts` stays `*` there. +- The standalone Iceberg REST and Lance REST servers read the same keys, and `gravitino.authorization.serviceAdmins`, from their own configuration files. +- Rules are read at startup, so a change takes effect on a server, standalone ones included, only after that server restarts. +- The server fails to start when a listed rule lacks a required key, when `authenticator` is a built-in authenticator's class name instead of its short name, when a `principals` entry does not have an accepted form, when a `users`, `excludeUsers` or `groups` entry fails the decoded-name checks in [Headers](#headers), or when a `hosts` entry is not `*`, an IP address or a CIDR range. A rule whose `authenticator` is any other name not registered on a server loads there and matches no request. + +The names in `excludeUsers` and `gravitino.authorization.serviceAdmins`, and `anonymous`, match a proxied name by `String.equalsIgnoreCase` against both the full name and its part before the first `/` or `@`. So `excludeUsers=hdfs` also excludes `HDFS`, `[email protected]` and `hdfs/[email protected]`, and `hive` also excludes `hıve` with a dotless `ı`. Exclusions ignore case because Hadoop group mapping often does. Entries in `users` and `groups` match only the exact name. + +### Changes to `Authenticator` and `AuthenticationFilter` + +`Authenticator` gains a method that marks the authenticators whose principals may act as a proxy. `KerberosAuthenticator`, `OAuth2TokenAuthenticator` and the idp-basic `BasicAuthenticator` return `true`; `SimpleAuthenticator` and existing custom authenticators keep the default. + +```java +/** + * Returns whether a principal accepted by this authenticator may act as a trusted proxy. + * + * @return true if requests accepted by this authenticator may carry a proxy header + */ +default boolean supportsProxy() { + return false; +} +``` + +An authenticator that supports proxying sets the proxy identity on the `UserPrincipal` it returns. Principal mapping happens inside `KerberosAuthenticator` and the OAuth validators (`JwksTokenValidator`, `StaticSignKeyValidator`), so they set it, and the validators also set the token's issuer. `OAuth2TokenAuthenticator` keeps both only when the configured validator's class is exactly one of those two, and drops them for any other `tokenValidatorClass`, including a subclass. A principal without a proxy identity cannot proxy. + +`AuthenticatorFactory` names each authenticator after its entry in `gravitino.authenticators`, with built-in class names normalized to `kerberos`, `oauth`, `basic` and `simple`, so an authenticator cannot claim another's name. `ServerAuthenticator` keeps an ordered name-to-instance map. For a duplicate name the factory keeps the first entry and logs a warning, which matches today's first-match behavior. The `BasicAuthenticator` of `plugins/idp-basic` is named `basic`, including the one that `IdpRESTFeature.registerBasicAuthenticator` adds. `resolvePrincipal` stores the name of the accepting authenticator as a request attribute and clears any real user on the principal, so only `applyProxyUser` sets it. + +`authenticate` gains a step between principal resolution and active roles: + +```java +private Principal authenticate(HttpServletRequest request, HttpServletResponse response) { + return applyActiveRoles(request, applyProxyUser(request, response, resolvePrincipal(request))); +} +``` + +`applyProxyUser` does the following, in order: + +1. Neither proxy header is present: return the principal unchanged. +2. A proxy header fails the checks in [Headers](#headers), or `X-Gravitino-Proxy-Groups` comes without `X-Gravitino-Proxy-User`: throw `IllegalProxyHeaderException` (400). +3. Proxying is disabled, the accepting authenticator does not support proxying, or the principal has no proxy identity: throw `ForbiddenException` (403). +4. The user is `anonymous`, or no single rule allows the authenticator, the identity, the user, the peer address and every declared group: throw `ForbiddenException` (403). +5. Otherwise set `X-Gravitino-Effective-User` on the response and return a `UserPrincipal` for the proxied user with the declared groups, no access token, and the proxy identity as real user. + +`IllegalProxyHeaderException` extends `IllegalArgumentException`. `AuthenticationFilter.doFilter` catches it next to `IllegalActiveRolesException`, and `sendAuthErrorResponse` in `AuthenticationFilter` and `LanceAuthenticationFilter` maps it to 400. `IcebergAuthenticationFilter` already maps every `IllegalArgumentException` to 400. All three map `ForbiddenException` to 403, and the Iceberg and Lance filters, which override only `sendAuthErrorResponse`, inherit the new step. Active roles are applied after it, so `X-Gravitino-Active-Roles` narrows the proxied user's roles. + +### Changes to `UserPrincipal` + +`UserPrincipal` gets three optional fields: + +| Accessor | Value | +|----------|-------| +| `getProxyIdentity()` | The name the authenticator verified, before principal mapping: the full Kerberos principal, the OAuth `sub`, the Basic user name, or what a custom authenticator sets. | +| `getProxyIdentityIssuer()` | The `iss` of the caller's OAuth token, set by the OAuth validators. Only `oauth` rules read it. | +| `getRealUser()` | The proxy identity of the service that sent `X-Gravitino-Proxy-User` for this user, for example `trino/[email protected]`. Audit and events record this form. | + +Two new public constructors take either the proxy identity and its issuer, used by the authenticators and validators above, or the real user, used by `applyProxyUser`. `withActiveRoles` and every other rebuild for the same user copy all three fields. Only `OAuth2TokenAuthenticator` and `resolvePrincipal` drop any, as described above. + +A proxied principal carries no access token, so no component can forward the proxy's credential as the user's. `UserPrincipalForwardingAuthManager`, the one server-side reader of the access token, fails with its existing `UserPrincipal has no authorization header value` error (Non-Goal 4). + +### Audit and events + +Owners, the `creator` field of audit info, and privilege checks read the current principal's name, so they see the proxied user with no change. Two additions record the real user: + +- `AuditLog` gets `default @Nullable String realUser() { return null; }`. `SimpleAuditLogV2` and the `JsonAuditLog` of `JsonAuditFormatter` write it only when it is set. `SimpleAuditLogV2` writes it as a `realUser=` field between `remoteAddress` and `customInfo`, because `customInfo` holds raw query parameters and is not escaped, so a field after it could be forged. The v1 `SimpleFormatter` does not write it. +- The `Event` constructor captures `realUser` from the current principal, as it captures `remoteAddress` from `RequestContext`, and `BaseEvent.realUser()` exposes it. `HttpAuditFilter` builds `HttpRequestEvent` and `HttpRequestFailureEvent` outside `Subject.doAs`, so it passes them `realUser` from the authenticated principal request attribute. + +### Client changes + +The Java client builder gets one method, which combines with any authentication method: + +```java +/** + * Makes every request act for the given user, which the server accepts only from a trusted proxy. + * + * @param user the user to act for + * @param groups the groups to declare for the user, or an empty list + * @return this builder + */ +public Builder<T> withProxyUser(String user, List<String> groups) +``` + +The builder keeps the user and groups in their own fields and adds the headers at `build()`, so a later `withHeaders`, which replaces the whole header map, cannot drop them. It percent-encodes names as in [Headers](#headers) and sends no groups header for an empty list. + +A client built with `withProxyUser` checks that the proxy takes effect: + +- `build()` of `GravitinoClient` and `GravitinoAdminClient` calls `GET /api/authn/me`, which returns the effective user in `principal`, and fails unless it equals the proxied user. A server without the endpoint fails the build with an error that names the endpoint. +- Every successful response must carry an `X-Gravitino-Effective-User` equal to the proxied user. + +A server that ignores the proxy header therefore fails `build()`. The per-response check fires only after a request has run, so every server behind one address must have this feature before clients proxy through it. + +Because only `withProxyUser` clients make these checks, `withHeaders` rejects both proxy headers, in any letter case, with `IllegalArgumentException`; GVFS's `fs.gravitino.client.request.header.*` keys go through `withHeaders` and are rejected too. A service that acts for many users builds one client per user, as the Trino connector already does for `forwardUser`. + +### Client configuration + +| Component | Key | Default | Description | +|-----------|-----|---------|-------------| +| Trino connector | `gravitino.client.session.forwardUser.useProxy` | `false` | Under `oauth2`, use the proxy header for sessions without a user token | +| Trino connector | `gravitino.client.session.forwardGroups` | `false` | Send the session's groups in `X-Gravitino-Proxy-Groups` on calls that use the proxy header; ignored otherwise | +| Lance REST | `gravitino.lance-rest.gravitino-proxy-caller` | `false` | Call Gravitino as the service, with the caller in proxy headers | +| Lance REST | `gravitino.lance-rest.gravitino-proxy-client-cache-size` | `1000` | Maximum number of per-caller clients in `gravitino-proxy-caller` mode | + +### Trino connector + +With `gravitino.client.session.forwardUser=true`: + +| `authType` | Current behavior | Behavior with this design | +|------------|------------------|---------------------------| +| `simple` | Sends the session user name, unverified | Unchanged | +| `basic`, `kerberos` | `GravitinoConnector.buildSessionCache` rejects the setting, so the catalog fails to load | The connector's own credentials plus `X-Gravitino-Proxy-User: <session user>` | +| `oauth2`, session has a user token | Forwards the user's token | Unchanged | +| `oauth2`, no user token | `resolveSessionMetadata` returns the shared metadata, so calls run as the connector | Proxy header when `forwardUser.useProxy=true`; unchanged otherwise | + +- `buildSessionCache` accepts `basic` and `kerberos`, `resolveSessionMetadata` honors `forwardUser.useProxy`, and `GravitinoAuthProvider.buildForSession` builds each per-user client that uses the proxy header with `withProxyUser`. +- Under `basic` and `kerberos`, and under `oauth2` with `forwardUser.useProxy`, `GravitinoMetadata` resolves the per-user client from the `ConnectorSession` of each call, not once in `getMetadata`, because Trino creates one `ConnectorMetadata` per catalog and transaction, and the session of a `SECURITY DEFINER` view shares the transaction of the query that reads it. Under `simple`, and under `oauth2` without `forwardUser.useProxy`, `GravitinoMetadata` keeps one client per transaction, as today. +- With `forwardGroups=true` the connector declares `ConnectorSession.getIdentity().getGroups()`, which is empty unless Trino has a group provider. A session with a group that the rule does not list fails every query with 403. +- Under `kerberos`, each per-user client gets its own `KerberosTokenProvider` built from the connector's keytab, so evicting one client logs out only that client. The cost is one Kerberos login per cache miss. +- Under `oauth2` with `forwardUser.useProxy`, the proxied per-user clients share the connector's OAuth token provider through a `CustomTokenProvider` that delegates `getTokenData()` and whose `close()` does nothing, as in [Lance REST proxy-caller mode](#lance-rest-proxy-caller-mode), because `HTTPClient.close()` closes its provider when the cache evicts a client. +- `sessionCacheKey` becomes a structured key of the auth type, the session user, the sorted set of declared groups, whether the proxy header is used and, for `oauth2` sessions with a user token, a SHA-256 hash of the token. No separator character can make two sessions share a client, and a refreshed user token gets a new client. + +Catalogs routed to Gravitino's Iceberg REST service are out of scope (Non-Goal 4). + +### Lance REST proxy-caller mode + +With `gravitino.lance-rest.gravitino-proxy-caller=true`, Lance REST calls Gravitino with its own OAuth client credentials (`gravitino.lance-rest.gravitino-auth-type=oauth2`): + +- For each caller it builds a client with `withProxyUser(caller, groups)` and passes the caller's `X-Gravitino-Active-Roles` through `withHeaders`, so the checks in [Client changes](#client-changes) apply to every call. +- It caches at most `gravitino-proxy-client-cache-size` clients, under a structured key of the caller, the sorted set of groups and the sorted, deduplicated set of trimmed role names. Spellings of one role list share a client, no separator character makes two callers collide, and a caller cannot grow the cache without bound. +- All clients share the one OAuth token provider that Lance REST closes at shutdown. Each client gets it through a `CustomTokenProvider` that delegates `getTokenData()` and whose `close()` does nothing, because `HTTPClient.close()` closes its provider when the cache evicts the client. +- Lance REST fails to start in this mode unless the outbound auth type is `oauth2` and its own `gravitino.authenticators`, whose default is `simple`, does not include `simple`, so it never forwards a name from `simple`. It also fails to start when the cache size is not positive. + +### Catalog impersonation + +Catalogs, schemas and filesets with `authentication.impersonation-enable` act on HMS and HDFS as the current user. Today the Kerberos call sites and the fileset proxy append a realm to a name without `@` and pass it to `UserGroupInformation.createProxyUser`. HMS and the NameNode then map that principal to a short name with their own `hadoop.security.auth_to_local` rules, which Gravitino cannot see and which may map, for example, `[email protected]` to `hdfs`. A proxied user must not reach storage through such a mapping, so for a principal that has a real user, every impersonation call site first does the following: + +1. **Reject service names.** Throw `ForbiddenException` (403) when the name contains `/` or `@`, or equals, by `String.equalsIgnoreCase`, the login user name of the call site or its part before the first `/` or `@`. Under Kerberos the login user name is the login principal, for a fileset the one in the merged catalog, schema and fileset properties; under simple authentication it is the name of the process user from `UserGroupInformation.getCurrentUser()`. No `auth_to_local` rule is applied, because the JVM's rules may differ from those of HMS and the NameNode. +2. **Pass the bare name.** Call `createProxyUser` with the name and no realm, and use the same bare name as the owner of any HMS delegation token that the call site requests. `KerberosName.getShortName` returns a one-component name without a realm unchanged, so no `auth_to_local` rule applies to it anywhere. + +| Call site | Covers | Change | +|-----------|--------|--------| +| `KerberosCatalogUtils` | Iceberg catalogs, the Iceberg REST server, `ClosableHiveCatalog` | `resolveProxyPrincipal` returns the bare name; the impersonation branch of `createRealUser` runs the check; `ClosableHiveCatalog` requests an HMS token only when impersonation is enabled ([#14408](https://github.com/apache/gravitino/pull/14408)), and for a principal with a real user uses the bare owner | +| `HmsKerberosClient.loginProxyUser` | Hive | The check runs before the existing shortcut `currentUser.equals(realLoginUgi.getUserName()) || hiveClient == null`, which returns the login UGI. For a principal with a real user, only the `hiveClient == null` branch stays; it builds the client that `HiveClientFactory` uses for token requests. The HMS token owner is the bare name. | +| `HiveClientFactory`, simple-authentication branch | Hive | The check runs before the shortcut that reuses the process UGI when the names match | +| `FilesetCatalogOperations` with `ImpersonationHDFSFileSystemProxy` | Filesets | The check runs in the request thread, where the [fileset fix](#fileset-fix) reads the user. For a principal with a real user, `FilesetCatalogOperations` tells `ImpersonationHDFSFileSystemProxy` not to append a realm, so `createProxyUser` gets the bare name. | + +Each call site throws the `ForbiddenException` before its own error handling. All code between the call site and the REST layer rethrows that exception unchanged, so the client gets 403. Several layers wrap exceptions today, among them `HiveClientFactory.createHiveClient`, `HiveClientPool.newClient`, `HiveCatalogOperations`, `HiveViewCatalogOperations` and `IcebergCatalogOperations`. The Hive client pool key (`CachedClientPool.extractKey`) and `FilesetCatalogOperations.FileSystemCacheKey` gain the real user, so proxied and non-proxied requests for the same name never share a client or a file system. The Iceberg Hive client pool needs no change: under Kerberos it is keyed by the UGI user name, which is the bare name only for a principal that has a real user. + +**Short names.** Step 1 compares only with the login user name of the call site and its first component. HMS and the NameNode may give a principal a short name that differs from its first component, for example `gravitino` for `[email protected]`, and HMS issues a delegation token without a proxy-user check when the owner's short name equals the caller's. A proxied name equal to the login principal's short name, or to the short name of another principal, such as one that a schema sets with impersonation off, therefore passes step 1 and reaches storage as that principal. + +So when any catalog, schema or fileset enables impersonation, the `excludeUsers` of every rule lists `hdfs`, `hive`, and the short name that HMS and HDFS give each principal that a catalog, schema or fileset sets, whether or not it enables impersonation. Administrators add a name before a catalog, schema or fileset is created with that principal or altered to set it. Review Comment: Agreed. `excludeUsers` now lists the users that the NameNode and HMS run as, usually `hdfs` and `hive`, rather than those two names only. -- 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]
