diff --git a/docs/lance-rest-integration.md b/docs/lance-rest-integration.md index 6942b01df41..18f581f5220 100644 --- a/docs/lance-rest-integration.md +++ b/docs/lance-rest-integration.md @@ -21,7 +21,7 @@ This documentation assumes familiarity with the Lance REST service setup as desc The following table outlines the tested compatibility between Gravitino versions and Lance connector versions: | Gravitino Version (Lance REST) | Supported lance-spark Versions | Supported lance-ray Versions | -|--------------------------------|--------------------------------|-----------------------------------------------| +| ------------------------------ | ------------------------------ | --------------------------------------------- | | 1.1.1 - 1.2.1 | 0.0.10 - 0.0.15 | 0.0.6 - 0.0.8 | | 1.3.0 | 0.2.0, 0.4.0, 0.5.1 | 0.3.0 - 0.4.2 (0.2.0 conditionally supported) | @@ -109,6 +109,56 @@ Before proceeding, ensure the following requirements are met: - For Spark integration: `pyspark` - For Ray integration: `ray`, `lance-namespace`, `lance-ray` +## Authentication and authorization + +For per-user metadata authorization, connect engines to the auxiliary Lance REST service with +`gravitino.authorization.enable=true`. Configure each engine's REST client to send the caller's +`Authorization` header on every namespace and table request. If supported by that client version, +`X-Gravitino-Active-Roles` can restrict the active roles. See the +[Lance REST authentication and privilege matrix](./lance-rest-service.md#authentication-and-authorization). + +For example, with development-only `simple` authentication, this request lists only tables that +`user1` may access (the password is not validated): + +```shell +curl --user 'user1:unused' \ + -H 'X-Gravitino-Active-Roles: ALL' \ + 'http://localhost:9101/lance/v1/namespace/lance_catalog.sales/table/list?delimiter=.' +``` + +Connector header configuration depends on the connector version. The Spark and Ray examples +below omit credentials and assume the default simple-authentication setup; in auxiliary mode +such requests use the configured Lance service identity. They do not demonstrate per-user +access control. In standalone mode, all metadata requests to Gravitino use the backend service +identity even when an engine supplies its own incoming credentials. + +Engines that probe before creating need the corresponding creation privileges. Reading table +metadata requires `SELECT_TABLE` or `MODIFY_TABLE` with parent access, while overwriting requires +`MODIFY_TABLE` and dropping requires ownership. Metadata authorization does not authorize direct +reads or writes to object storage: configure storage access independently. Lance REST responses +can return shared storage credentials configured on the catalog or table; it does not issue +per-user, scoped storage credentials. + +### Verify authentication and authorization locally + +The HTTP integration suites start Gravitino with the Lance auxiliary service and exercise +caller identity, service identity fallback, active roles, namespace and table privileges, +filtered listings, denied mutations, and rejection of non-empty Arrow creates without side effects. +They also start standalone Lance REST through its production entry point in a separate JVM to verify +its outbound service identity and propagation of backend authorization denials through the Gravitino +HTTP API. + +```shell +./gradlew :lance:lance-rest-server:test \ + --tests '*LanceRESTServiceAuthIT' \ + --tests '*LanceNamespaceAuthorizationIT' \ + --tests '*LanceTableAuthorizationIT' \ + -PskipDockerTests=true +``` + +These suites use `simple` authentication and local storage. They do not validate an external +OAuth2/Kerberos provider or object-store access policies. + ## Spark Integration ### Configuration diff --git a/docs/lance-rest-service.md b/docs/lance-rest-service.md index 7186243bb26..14b9c61a019 100644 --- a/docs/lance-rest-service.md +++ b/docs/lance-rest-service.md @@ -57,7 +57,7 @@ The Lance REST service acts as a bridge between Lance datasets and applications: ``` **Key Features:** -- Full compliance with Lance REST API specification +- Support for the Lance REST operations listed below - Can run standalone or integrated with Gravitino server - Support for namespace and table management - Metadata stored in Gravitino for unified governance @@ -67,7 +67,7 @@ The Lance REST service acts as a bridge between Lance datasets and applications: The Lance REST service provides comprehensive support for namespace management and table management. Index operations are not supported yet. The table below lists all supported operations: | Operation | Description | HTTP Method | Endpoint Pattern | -|-------------------|---------------------------------------------------------------------------|-------------|---------------------------------------| +| ----------------- | ------------------------------------------------------------------------- | ----------- | ------------------------------------- | | CreateNamespace | Create a new Lance namespace | POST | `/lance/v1/namespace/{id}/create` | | ListNamespaces | List all namespaces under a parent namespace | GET | `/lance/v1/namespace/{parent}/list` | | DescribeNamespace | Retrieve detailed information about a specific namespace | POST | `/lance/v1/namespace/{id}/describe` | @@ -99,17 +99,24 @@ REST-style canonical form. - `overwrite`: Replaces existing namespace **DropNamespace** behavior: -- Recursively deletes all child namespaces and tables -- Deletes both metadata and Lance data files -- Operation is irreversible +- Defaults to `behavior=restrict`; non-empty namespaces cannot be dropped in this mode +- `behavior=cascade` removes child metadata recursively +- Cascading a schema drop uses table drop semantics: external Lance datasets are preserved +- Use `DropTable` explicitly to delete a table's data; namespace deletion is not a storage purge #### Table Operations **RegisterTable vs CreateTable**: - **RegisterTable**: Links existing Lance datasets into Gravitino catalog without data movement -- **CreateTable**: Creates new Lance table with schema and write metadata files +- **CreateTable**: Creates an empty Lance dataset using the schema from the Arrow IPC stream :::note -The `version` field of `CreateTable` response is always null, which stands for the latest version. +The current `CreateTable` implementation accepts schema-only Arrow streams, including zero-row +batches. A stream containing rows returns HTTP `406` before any metadata or dataset changes, +including for `overwrite`. Write records through a Lance client or engine after creation. + +The `version` field of `CreateTable` reports the stored Lance dataset version when available. +`DescribeTable` currently returns the latest metadata even when a historical `version` is requested; +versioned metadata reads are not implemented. ::: **DropTable vs DeregisterTable**: @@ -124,7 +131,7 @@ The `version` field of `CreateTable` response is always null, which stands for t To enable the Lance REST service within Gravitino server, configure the following properties in your Gravitino configuration file `${GRAVITINO_HOME}/conf/gravitino.conf`: | Configuration Property | Description | Default Value | Required | -|-------------------------------------------|------------------------------------------------------------------------------|-------------------------|----------| +| ----------------------------------------- | ---------------------------------------------------------------------------- | ----------------------- | -------- | | `gravitino.auxService.names` | Auxiliary services to run. Include `lance-rest` to enable Lance REST service | iceberg-rest,lance-rest | Yes | | `gravitino.lance-rest.classpath` | Classpath for Lance REST service, relative to Gravitino home directory | lance-rest-server/libs | Yes | | `gravitino.lance-rest.httpPort` | Port number for Lance REST service | 9101 | No | @@ -135,13 +142,13 @@ To enable the Lance REST service within Gravitino server, configure the followin **Authentication to the Gravitino Server** -The Lance REST service makes its own requests to the Gravitino server. Those requests must carry -credentials, otherwise a Gravitino server configured with an authenticator other than `simple` -rejects them and every Lance operation fails. Configure the auth type to match the Gravitino -server: +In standalone mode, the Lance REST service makes HTTP requests to the Gravitino server using +its configured service credentials. Configure the auth type to match the Gravitino server. +Auxiliary mode uses internal APIs and preserves the authenticated caller instead; the simple +user name below is only the fallback for requests accepted as anonymous. | Configuration Property | Description | Default Value | Required | -|----------------------------------------------------|------------------------------------------------------------------------------------|---------------------|-------------------| +| -------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------- | ----------------- | | `gravitino.lance-rest.gravitino-auth-type` | Auth type used to reach the Gravitino server. Supported values: `simple`, `oauth2` | `simple` | No | | `gravitino.lance-rest.gravitino-simple.user-name` | User name presented when the auth type is `simple` | `lance-rest-server` | No | | `gravitino.lance-rest.gravitino-oauth2.server-uri` | OAuth2 server URI | (none) | Yes, for `oauth2` | @@ -149,8 +156,8 @@ server: | `gravitino.lance-rest.gravitino-oauth2.token-path` | Path on the OAuth2 server used to request the token | (none) | Yes, for `oauth2` | | `gravitino.lance-rest.gravitino-oauth2.scope` | Scope of the requested OAuth2 token | (none) | Yes, for `oauth2` | -This setting controls how the service authenticates to the Gravitino server. It does not change how -callers authenticate to the Lance REST service itself. +These settings control outbound authentication in standalone mode. They do not configure inbound +authentication to Lance REST. See [Authentication and authorization](#authentication-and-authorization). **Example Configuration:** @@ -176,7 +183,7 @@ To run Lance REST service independently without Gravitino server (You need to st Configure the service by editing `{GRAVITINO_HOME}/conf/gravitino-lance-rest-server.conf` or passing command-line arguments: | Configuration Property | Description | Default Value | Required | -|-------------------------------------------|----------------------------|-----------------------|----------| +| ----------------------------------------- | -------------------------- | --------------------- | -------- | | `gravitino.lance-rest.namespace-backend` | Namespace metadata backend | gravitino | Yes | | `gravitino.lance-rest.gravitino-uri` | Gravitino server URI | http://localhost:8090 | Yes | | `gravitino.lance-rest.gravitino-metalake` | Gravitino metalake name | (none) | Yes | @@ -205,7 +212,7 @@ Access the service at `http://localhost:9101`. **Environment Variables:** | Environment Variable | Configuration Property | Required | Default Value | -|--------------------------------------|-------------------------------------------|----------|-------------------------| +| ------------------------------------ | ----------------------------------------- | -------- | ----------------------- | | `LANCE_REST_NAMESPACE_BACKEND` | `gravitino.lance-rest.namespace-backend` | Yes | `gravitino` | | `LANCE_REST_GRAVITINO_METALAKE_NAME` | `gravitino.lance-rest.gravitino-metalake` | Yes | (none) | | `LANCE_REST_GRAVITINO_URI` | `gravitino.lance-rest.gravitino-uri` | Yes | `http://localhost:8090` | @@ -271,9 +278,138 @@ URL encoded: lance_catalog%24schema%24table01 - Supports only **two levels of namespaces** before tables - Tables **cannot** be nested deeper than schema level - Parent catalog must be created in Gravitino before using Lance REST API -- Namespace deletion is recursive and irreversible +- Namespace deletion defaults to `restrict`; use `cascade` to remove child metadata ::: +## Authentication and authorization + +### Authentication and deployment modes + +Lance REST uses Gravitino's `gravitino.authenticators` configuration for incoming requests in +both auxiliary and standalone mode. See [Authentication](./security/how-to-authenticate.md) for +configuring the authenticators and their credentials. Health check endpoints bypass authentication. +Authentication errors use the Lance JSON error format; unsupported credentials return HTTP `401`. +In standalone mode, backend authentication and authorization failures retain HTTP `401` and `403` +respectively. Authentication/authorization failures do not include internal stack traces in `detail`. +Unexpected failures return HTTP `500` with a generic message; the server logs retain the exception +for diagnosis. + +| Mode | Identity used for Gravitino metadata operations | Metadata authorization | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Auxiliary (running with Gravitino) | Authenticated caller, including active roles; anonymous requests fall back to `gravitino.lance-rest.gravitino-simple.user-name` (default `lance-rest-server`) | Enabled by `gravitino.authorization.enable=true` with a configured metalake | +| Standalone | Configured service credentials (`gravitino.lance-rest.gravitino-auth-type` and its simple/OAuth2 settings) | No Lance REST per-user metadata authorization; the remote Gravitino server checks the service identity if its authorization is enabled | + +The auxiliary fallback applies only after authentication accepts an anonymous request. It does +not recover a rejected authentication attempt. Authenticated callers keep their own privileges, +active roles, ownership and audit identity; they do not inherit the service user's privileges. +The fallback service user itself needs the privileges required by the requested operation. +Setting `gravitino.lance-rest.gravitino-simple.user-name` explicitly is optional in auxiliary mode; +configure it only to override the default anonymous fallback identity, `lance-rest-server`. + +With `simple` authentication, a Basic header supplies a user name without validating a password, +and a request without credentials is accepted as anonymous. Some malformed Basic credentials +also resolve to anonymous. Use an authenticator that validates credentials when caller identity +must be verified; `simple` is not password authentication. + +Standalone authenticates incoming requests, but does not forward their identities or active roles +to its Gravitino backend. All callers use the configured backend service identity. Standalone +per-user authorization and scoped temporary credential vending are outside the supported scope. The +backend service identity needs privileges for all underlying Gravitino calls, including existence +checks performed before mutations (for example, catalog access before creating a namespace). + +### Enable auxiliary metadata authorization + +Configure `${GRAVITINO_HOME}/conf/gravitino.conf`: + +```properties +gravitino.auxService.names = lance-rest +gravitino.lance-rest.gravitino-metalake = my_metalake +gravitino.authorization.enable = true +gravitino.authorization.serviceAdmins = adminUser +# Development example: simple accepts the supplied user name without password validation. +gravitino.authenticators = simple +``` + +Create the metalake, add users, and grant roles through the Gravitino API as described in +[Access Control](./security/access-control.md). The Lance service exposes this configured metalake: +a one-level namespace identifies a catalog, a two-level namespace identifies a schema, and a +three-level table identifier identifies a table. + +Requests may set `X-Gravitino-Active-Roles` to `ALL` (also the default when omitted), `NONE`, or +a comma-separated list of assigned role names. This selection reaches both operation checks +and listing filters. Malformed selections return `400`; selecting an unassigned role is forbidden. +Ownership is independent of role selection, so `NONE` does not remove ownership privileges. + +### Required privileges + +The following rules use the same Gravitino privileges and ownership rules as +[Iceberg REST authorization](./iceberg-rest-service.md). Privileges can be inherited from +ancestor scopes as described in Access Control. Service administrators and metalake owners +can operate throughout the metalake; catalog owners can operate within their catalogs. +Schema owners additionally need `USE_CATALOG`, and table owners need `USE_CATALOG` and +`USE_SCHEMA`. The ownership alternatives below include these ancestor owners. + +| Namespace operation | Required privileges or ownership | +| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `ListNamespaces` at root | Membership in the metalake; returns only accessible catalogs | +| `ListNamespaces` under a catalog; `DescribeNamespace` for a catalog; `NamespaceExists` for a catalog | `USE_CATALOG`, or ownership | +| `ListNamespaces` under a schema; `DescribeNamespace` for a schema; `ListTables` | `USE_CATALOG` and `USE_SCHEMA`, or ownership | +| `NamespaceExists` for a schema | `USE_CATALOG` and either `USE_SCHEMA` or `CREATE_SCHEMA`, or ownership | +| `CreateNamespace` for a catalog (`create`, `exist_ok`) | `CREATE_CATALOG` on the metalake, or metalake ownership | +| `CreateNamespace` for a schema (`create`, `exist_ok`) | `USE_CATALOG` and `CREATE_SCHEMA`, or catalog/metalake ownership | +| `CreateNamespace` (`overwrite`); `DropNamespace` | Ownership of the namespace or an ancestor | + +| Table operation | Required privileges or ownership | +| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `DescribeTable` | `USE_CATALOG`, `USE_SCHEMA`, and either `SELECT_TABLE` or `MODIFY_TABLE`, or ownership | +| `TableExists` | Same as `DescribeTable`, or `USE_CATALOG`, `USE_SCHEMA`, and either `PROBE_TABLE_LIKE` or `CREATE_TABLE` | +| `CreateTable` (`create`, `exist_ok`); `RegisterTable` (`create`); `DeclareTable` | `USE_CATALOG`, `USE_SCHEMA`, and `CREATE_TABLE`, or schema/ancestor ownership | +| `CreateTable` or `RegisterTable` (`overwrite`); `AlterColumns`; `DropColumns` | `USE_CATALOG`, `USE_SCHEMA`, and `MODIFY_TABLE`, or ownership | +| `DropTable`; `DeregisterTable` | Ownership of the table or an ancestor | + +`CREATE_TABLE` and `PROBE_TABLE_LIKE` authorize `TableExists` without authorizing +`DescribeTable`. `CREATE_TABLE` alone does not authorize overwrite requests. The `DropTable` +and `DeregisterTable` endpoints require ownership rather than `MODIFY_TABLE`. Similarly, namespace creation privileges do not authorize +overwriting or dropping another owner's namespace. Successful creation assigns ownership to +the effective caller. + +### Listings and concealed objects + +Namespace listings omit inaccessible catalogs and schemas. Table listings omit tables for which +the caller has neither ownership nor `SELECT_TABLE`/`MODIFY_TABLE`. Filtering happens before +pagination; hidden entries do not consume page slots. Access to the parent is checked separately. + +When a caller lacks an endpoint's required privileges, the request returns `403`, whether or not +the target exists, without returning its stored metadata or location. An authorized caller can +distinguish an existing object from a missing one (`404`). Concealment therefore does not mean that every +inaccessible object returns `404`. + +Authorization governs metadata requests; engines access Lance data files directly. Responses can +include configured storage credentials: namespace descriptions resolve secret properties, and +table descriptions, creation and declaration responses return effective `storage_options` from +catalog defaults and table overrides. These are shared configured credentials, not temporary +credentials restricted to the caller's table privileges. Access to data files depends on the +permissions of those credentials. Per-user, scoped credential vending is not implemented. + +### Authorization differences between deployment modes + +:::warning +Lance REST metadata authorization is currently supported only in **auxiliary mode**. +**Standalone mode is not recommended**: its authorization decisions can differ from auxiliary +mode and may produce unexpected results, even for the same user and privileges. Use auxiliary +mode for deployments that require Lance REST authorization. +::: + +Auxiliary mode applies the Lance endpoint authorization rules and listing filters described above. +Standalone mode does not apply this authorization pipeline. Instead, the remote Gravitino server +checks each underlying REST call using its own rules and the backend identity described above. +These checks do not provide equivalent Lance REST authorization: an operation allowed in one mode +may be denied in the other, and ownership requirements and metadata visibility can also differ. +Forwarding the caller's identity alone does not eliminate these differences. + +Alignment of the authorization behavior is tracked in +[#13089](https://github.com/apache/gravitino/issues/13089). + ## Examples The following examples demonstrate how to interact with Lance REST service using different programming languages and tools. diff --git a/lance/lance-rest-server/build.gradle.kts b/lance/lance-rest-server/build.gradle.kts index 7899bad829a..d55180fa15d 100644 --- a/lance/lance-rest-server/build.gradle.kts +++ b/lance/lance-rest-server/build.gradle.kts @@ -194,6 +194,7 @@ tasks { val primaryBundleDir = lanceSparkBundleDirFor(primaryLanceSparkBundleVersion) doFirst { + systemProperty("lance.test.runtimeClasspath", sourceSets["main"].runtimeClasspath.asPath) val bundleJar = primaryBundleDir.get().asFile.listFiles()?.singleOrNull { it.extension == "jar" } ?: throw GradleException( diff --git a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java index a3de0c46d69..a56ee9bc3bb 100644 --- a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java +++ b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/LanceExceptionMapper.java @@ -23,8 +23,10 @@ import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; +import org.apache.gravitino.exceptions.ForbiddenException; import org.apache.gravitino.exceptions.NoSuchTableException; import org.apache.gravitino.exceptions.NotFoundException; +import org.apache.gravitino.exceptions.UnauthorizedException; import org.apache.gravitino.server.web.ServerHealth; import org.lance.namespace.errors.ConcurrentModificationException; import org.lance.namespace.errors.InternalException; @@ -68,7 +70,13 @@ public Response toResponse(Throwable ex) { } private static LanceNamespaceException toLanceNamespaceException(String instance, Throwable ex) { - if (ex instanceof NoSuchTableException) { + if (ex instanceof ForbiddenException) { + return new PermissionDeniedException(ex.getMessage(), "", instance); + + } else if (ex instanceof UnauthorizedException) { + return new UnauthenticatedException(ex.getMessage(), "", instance); + + } else if (ex instanceof NoSuchTableException) { return new TableNotFoundException(ex.getMessage(), getStackTrace(ex), instance); } else if (ex instanceof NotFoundException) { @@ -86,7 +94,7 @@ private static LanceNamespaceException toLanceNamespaceException(String instance } else { LOG.warn("Lance REST server unexpected exception:", ex); - return new InternalException(ex.getMessage(), getStackTrace(ex), instance); + return new InternalException("Internal server error", "", instance); } } diff --git a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceMetadataAuthorizationMethodInterceptor.java b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceMetadataAuthorizationMethodInterceptor.java index 88b3d645ecd..3ec6e9c9037 100644 --- a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceMetadataAuthorizationMethodInterceptor.java +++ b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceMetadataAuthorizationMethodInterceptor.java @@ -18,8 +18,6 @@ */ package org.apache.gravitino.lance.service.authorization; -import static org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace; - import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.HashMap; @@ -188,9 +186,7 @@ protected Object toErrorResponse(Method method, Object[] args, Throwable throwab String namespaceId = pathArgument(method.getParameters(), args, "id").orElse(""); Exception exception; if (throwable instanceof ForbiddenException) { - exception = - new PermissionDeniedException( - throwable.getMessage(), getStackTrace(throwable), namespaceId); + exception = new PermissionDeniedException(throwable.getMessage(), "", namespaceId); } else if (throwable instanceof Exception) { exception = (Exception) throwable; } else { diff --git a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceRESTAuthInterceptionService.java b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceRESTAuthInterceptionService.java index ae10436f69d..4a717aefc77 100644 --- a/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceRESTAuthInterceptionService.java +++ b/lance/lance-rest-server/src/main/java/org/apache/gravitino/lance/service/authorization/LanceRESTAuthInterceptionService.java @@ -42,8 +42,7 @@ public class LanceRESTAuthInterceptionService implements InterceptionService { public static final String METALAKE_BINDING = "lanceAuthorizationMetalake"; // Membership here only routes a class through the interceptor; each method still opts in with - // @AuthorizationExpression, and a method without one runs unauthorized. The table writes - // (create, register, drop, alter) are still to be annotated. + // @AuthorizationExpression. Endpoint coverage tests ensure no REST operation omits it. private static final Set INTERCEPTED_CLASSES = ImmutableSet.of( LanceNamespaceOperations.class.getName(), LanceTableOperations.class.getName()); diff --git a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceNamespaceAuthorizationIT.java b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceNamespaceAuthorizationIT.java index 5226a20beb9..ae4bffe98ad 100644 --- a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceNamespaceAuthorizationIT.java +++ b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceNamespaceAuthorizationIT.java @@ -18,16 +18,21 @@ */ package org.apache.gravitino.lance.integration.test; +import java.io.Writer; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Properties; +import java.util.concurrent.TimeUnit; import org.apache.gravitino.Configs; import org.apache.gravitino.auth.AuthConstants; import org.apache.gravitino.authorization.Privileges; @@ -35,14 +40,20 @@ import org.apache.gravitino.authorization.SecurableObjects; import org.apache.gravitino.client.GravitinoMetalake; import org.apache.gravitino.integration.test.util.BaseIT; +import org.apache.gravitino.integration.test.util.HttpUtils; +import org.apache.gravitino.lance.server.GravitinoLanceRESTServer; +import org.apache.gravitino.rest.RESTUtils; import org.apache.gravitino.server.web.ObjectMapperProvider; +import org.awaitility.Awaitility; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.lance.namespace.model.CreateNamespaceRequest; import org.lance.namespace.model.DescribeNamespaceResponse; import org.lance.namespace.model.DropNamespaceRequest; +import org.lance.namespace.model.ErrorResponse; import org.lance.namespace.model.ListNamespacesResponse; /** Verifies namespace authorization and list filtering through auxiliary-mode Lance REST. */ @@ -70,6 +81,7 @@ public void startIntegrationTest() throws Exception { customConfigs.put(Configs.SERVICE_ADMINS.getKey(), ADMIN); customConfigs.put(Configs.AUTHENTICATORS.getKey(), "simple"); customConfigs.put("SimpleAuthUserName", ADMIN); + customConfigs.put("gravitino.lance-rest.gravitino-simple.user-name", WRITER); super.startIntegrationTest(); String metalakeName = getLanceRESTServerMetalakeName(); @@ -181,6 +193,252 @@ public void testDropConcealsNamespacesTheCallerMayNotSee() throws Exception { assertStatus(403, drop(USER, HIDDEN_CATALOG, "skip", null)); } + /** Verifies that active roles survive authentication and reach namespace authorization. */ + @Test + public void testActiveRolesReachAuthorizationAndListingFilters() throws Exception { + String schemaPath = "/v1/namespace/" + id(VISIBLE_CATALOG, VISIBLE_SCHEMA) + "/describe"; + assertStatus(200, sendWithRoles(USER, schemaPath, "ALL")); + assertStatus(403, sendWithRoles(USER, schemaPath, "NONE")); + assertStatus(403, sendWithRoles(USER, schemaPath, "lance_authz_catalog_role")); + assertStatus( + 200, sendWithRoles(USER, schemaPath, "lance_authz_catalog_role,lance_authz_schema_role")); + assertStatus(403, sendWithRoles(USER, schemaPath, "lance_authz_writer_role")); + + HttpResponse response = + httpClient.send( + request(USER, "/v1/namespace/" + VISIBLE_CATALOG + "/list") + .setHeader( + AuthConstants.X_GRAVITINO_ACTIVE_ROLES_HEADER, "lance_authz_catalog_role") + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + assertStatus(200, response); + Assertions.assertTrue( + ObjectMapperProvider.objectMapper() + .readValue(response.body(), ListNamespacesResponse.class) + .getNamespaces() + .isEmpty()); + // Narrowing one request must not affect subsequent requests on the same server. + Assertions.assertEquals(List.of(VISIBLE_SCHEMA), list(USER, VISIBLE_CATALOG)); + } + + /** Verifies authentication failures stop before metadata writes or service identity fallback. */ + @Test + public void testAuthenticationErrorsUseLanceJsonAndDoNotCreateMetadata() throws Exception { + String catalog = "lance_authz_rejected_auth_catalog"; + CreateNamespaceRequest body = new CreateNamespaceRequest(); + body.addIdItem(catalog); + String json = ObjectMapperProvider.objectMapper().writeValueAsString(body); + HttpResponse unauthorized = + httpClient.send( + request(ADMIN, "/v1/namespace/" + catalog + "/create") + .setHeader(AuthConstants.HTTP_HEADER_AUTHORIZATION, "Bearer unsupported-token") + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(), + HttpResponse.BodyHandlers.ofString()); + assertError(401, unauthorized); + HttpResponse malformedRoles = + httpClient.send( + request(ADMIN, "/v1/namespace/" + catalog + "/create") + .setHeader(AuthConstants.X_GRAVITINO_ACTIVE_ROLES_HEADER, "ALL,NONE") + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(), + HttpResponse.BodyHandlers.ofString()); + assertError(400, malformedRoles); + assertStatus(404, post(ADMIN, catalog, "exists")); + } + + /** Verifies anonymous fallback uses the service user's privileges and records its ownership. */ + @Test + public void testServiceIdentityFallbackIsAuthorized() throws Exception { + String catalog = "lance_authz_fallback_catalog"; + CreateNamespaceRequest body = new CreateNamespaceRequest(); + body.addIdItem(catalog); + HttpRequest.Builder anonymous = + HttpRequest.newBuilder() + .uri( + URI.create( + String.format("http://localhost:%d/lance", getLanceRESTServerPort()) + + "/v1/namespace/" + + catalog + + "/create?delimiter=.")) + .header("Content-Type", "application/json"); + assertStatus( + 200, + httpClient.send( + anonymous + .POST( + HttpRequest.BodyPublishers.ofString( + ObjectMapperProvider.objectMapper().writeValueAsString(body))) + .build(), + HttpResponse.BodyHandlers.ofString())); + GravitinoMetalake metalake = client.loadMetalake(getLanceRESTServerMetalakeName()); + Assertions.assertEquals(WRITER, metalake.loadCatalog(catalog).auditInfo().creator()); + // Ownership is usable by the real service user after creation through anonymous fallback. + assertStatus(200, drop(WRITER, catalog, null, "cascade")); + + HttpRequest denied = + HttpRequest.newBuilder() + .uri( + URI.create( + String.format("http://localhost:%d/lance", getLanceRESTServerPort()) + + "/v1/namespace/" + + HIDDEN_CATALOG + + "/describe?delimiter=.")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("{}")) + .build(); + assertStatus(403, httpClient.send(denied, HttpResponse.BodyHandlers.ofString())); + // An authenticated reader cannot borrow the fallback user's CREATE_CATALOG privilege. + assertStatus(403, create(USER, catalog, null, Map.of())); + } + + /** Verifies standalone HTTP backend calls use service credentials rather than caller roles. */ + @Test + public void testStandaloneUsesBackendServiceIdentity(@TempDir Path directory) throws Exception { + int port = RESTUtils.findAvailablePort(10000, 11000); + String catalog = "lance_authz_standalone_catalog"; + String serviceUser = "lance_authz_standalone_user"; + GravitinoMetalake metalake = client.loadMetalake(getLanceRESTServerMetalakeName()); + metalake.addUser(serviceUser); + metalake.createRole( + "lance_authz_standalone_role", + new HashMap<>(), + List.of( + SecurableObjects.ofMetalake( + metalake.name(), + new ArrayList<>( + List.of(Privileges.UseCatalog.allow(), Privileges.CreateCatalog.allow()))))); + metalake.grantRolesToUser(List.of("lance_authz_standalone_role"), serviceUser); + Properties config = new Properties(); + config.setProperty(Configs.AUTHENTICATORS.getKey(), "simple"); + config.setProperty("gravitino.lance-rest.httpPort", String.valueOf(port)); + config.setProperty( + "gravitino.lance-rest.gravitino-uri", "http://localhost:" + getGravitinoServerPort()); + config.setProperty("gravitino.lance-rest.gravitino-metalake", getLanceRESTServerMetalakeName()); + config.setProperty("gravitino.lance-rest.gravitino-auth-type", "simple"); + config.setProperty("gravitino.lance-rest.gravitino-simple.user-name", serviceUser); + Path configFile = directory.resolve("standalone.conf"); + try (Writer writer = Files.newBufferedWriter(configFile)) { + config.store(writer, "Standalone Lance REST integration test"); + } + Path logFile = directory.resolve("standalone.log"); + // Use the production bootstrap in its own JVM: deploy mode has no local GravitinoEnv, + // while embedded mode must not share its backend environment with the standalone service. + ProcessBuilder builder = + new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "--add-opens=java.base/java.nio=ALL-UNNAMED", + "-cp", + System.getProperty("lance.test.runtimeClasspath"), + GravitinoLanceRESTServer.class.getName(), + configFile.toString()) + .redirectErrorStream(true) + .redirectOutput(logFile.toFile()); + builder.environment().put("GRAVITINO_TEST", "true"); + Process standalone = builder.start(); + try { + try { + Awaitility.await() + .atMost(60, TimeUnit.SECONDS) + .until( + () -> { + Assertions.assertTrue(standalone.isAlive(), "Standalone process exited"); + // Namespace initialization is lazy and occurs on the first metadata request. + return HttpUtils.isHttpServerUp( + "http://localhost:" + port + "/lance/health/live"); + }); + } catch (Exception | AssertionError e) { + throw new AssertionError("Standalone startup failed:\n" + Files.readString(logFile), e); + } + CreateNamespaceRequest body = new CreateNamespaceRequest(); + body.addIdItem(catalog); + HttpRequest request = + request(USER, "/v1/namespace/" + catalog + "/create") + .uri( + URI.create( + "http://localhost:" + + port + + "/lance/v1/namespace/" + + catalog + + "/create?delimiter=.")) + .setHeader(AuthConstants.X_GRAVITINO_ACTIVE_ROLES_HEADER, "NONE") + .POST( + HttpRequest.BodyPublishers.ofString( + ObjectMapperProvider.objectMapper().writeValueAsString(body))) + .build(); + // USER cannot create catalogs in auxiliary mode. The backend receives the service user's + // credentials and roles, despite USER selecting NONE on this incoming request. + assertStatus(200, httpClient.send(request, HttpResponse.BodyHandlers.ofString())); + Assertions.assertEquals( + serviceUser, + client + .loadMetalake(getLanceRESTServerMetalakeName()) + .loadCatalog(catalog) + .auditInfo() + .creator()); + // The backend service user cannot read this admin-owned schema. Even an incoming admin + // must receive the backend's 403, rather than 500 or the incoming caller's privileges. + HttpRequest deniedRequest = + request(ADMIN, "/v1/namespace/" + id(VISIBLE_CATALOG, VISIBLE_SCHEMA) + "/describe") + .uri( + URI.create( + "http://localhost:" + + port + + "/lance/v1/namespace/" + + id(VISIBLE_CATALOG, VISIBLE_SCHEMA) + + "/describe?delimiter=.")) + .POST(HttpRequest.BodyPublishers.ofString("{}")) + .build(); + HttpResponse deniedResponse = + httpClient.send(deniedRequest, HttpResponse.BodyHandlers.ofString()); + assertStatus(403, deniedResponse); + ErrorResponse error = + ObjectMapperProvider.objectMapper().readValue(deniedResponse.body(), ErrorResponse.class); + Assertions.assertEquals("", error.getDetail()); + Assertions.assertTrue(error.getError().contains(serviceUser), error.getError()); + assertStatus(200, drop(serviceUser, catalog, null, "cascade")); + } finally { + standalone.destroy(); + if (!standalone.waitFor(10, TimeUnit.SECONDS)) { + standalone.destroyForcibly(); + Assertions.assertTrue( + standalone.waitFor(10, TimeUnit.SECONDS), "Standalone process did not stop"); + } + } + } + + /** Verifies health endpoints remain reachable even when credentials would be rejected. */ + @Test + public void testHealthBypassesAuthentication() throws Exception { + HttpRequest health = + request(USER, "/health") + .setHeader(AuthConstants.HTTP_HEADER_AUTHORIZATION, "Bearer unsupported-token") + .GET() + .build(); + assertStatus(200, httpClient.send(health, HttpResponse.BodyHandlers.ofString())); + } + + private HttpResponse sendWithRoles(String user, String path, String roles) + throws Exception { + return httpClient.send( + request(user, path) + .setHeader(AuthConstants.X_GRAVITINO_ACTIVE_ROLES_HEADER, roles) + .POST(HttpRequest.BodyPublishers.ofString("{}")) + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + private void assertError(int status, HttpResponse response) throws Exception { + assertStatus(status, response); + Assertions.assertTrue( + response.headers().firstValue("Content-Type").orElse("").startsWith("application/json")); + ErrorResponse error = + ObjectMapperProvider.objectMapper().readValue(response.body(), ErrorResponse.class); + Assertions.assertEquals(status, error.getCode()); + Assertions.assertFalse(error.getError().isEmpty()); + } + private void grant(GravitinoMetalake metalake, String role, SecurableObject object) { metalake.createRole(role, new HashMap<>(), List.of(object)); metalake.grantRolesToUser(List.of(role), USER); diff --git a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceExceptionMapper.java b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceExceptionMapper.java index fd3d98ded61..8f4513fdf68 100644 --- a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceExceptionMapper.java +++ b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/TestLanceExceptionMapper.java @@ -24,6 +24,8 @@ import javax.ws.rs.core.Application; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import org.apache.gravitino.exceptions.ForbiddenException; +import org.apache.gravitino.exceptions.UnauthorizedException; import org.apache.gravitino.rest.RESTUtils; import org.glassfish.jersey.jackson.JacksonFeature; import org.glassfish.jersey.server.ResourceConfig; @@ -31,6 +33,7 @@ import org.glassfish.jersey.test.TestProperties; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.lance.namespace.errors.InvalidInputException; import org.lance.namespace.model.ErrorResponse; /** Tests for {@link LanceExceptionMapper}. */ @@ -79,12 +82,57 @@ public void testErrorResponse() { Assertions.assertEquals( Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), response.getStatus()); ErrorResponse entity = response.readEntity(ErrorResponse.class); - Assertions.assertEquals("assertion failure", entity.getError()); + Assertions.assertEquals("Internal server error", entity.getError()); Assertions.assertEquals("", entity.getInstance()); - Assertions.assertTrue( - entity.getDetail().contains("java.lang.AssertionError: assertion failure")); - Assertions.assertTrue( - entity.getDetail().contains("Caused by: java.lang.IllegalStateException: root cause")); + Assertions.assertEquals("", entity.getDetail()); + } + } + + /** Verifies backend authorization failures use the Lance forbidden response. */ + @Test + public void testBackendForbidden() { + assertAuthenticationError(new ForbiddenException("Access denied"), 403); + } + + /** Verifies backend authentication failures use the Lance unauthenticated response. */ + @Test + public void testBackendUnauthorized() { + assertAuthenticationError(new UnauthorizedException("Invalid credentials"), 401); + } + + /** Verifies unexpected exceptions do not expose internal details in the response. */ + @Test + public void testInternalFailureDoesNotExposeException() { + try (Response response = + LanceExceptionMapper.toRESTResponse( + "catalog.schema.table", new RuntimeException("private-backend-detail"))) { + Assertions.assertEquals(500, response.getStatus()); + ErrorResponse error = (ErrorResponse) response.getEntity(); + Assertions.assertEquals("Internal server error", error.getError()); + Assertions.assertEquals("", error.getDetail()); + } + } + + /** Verifies intentional protocol validation details remain available to callers. */ + @Test + public void testProtocolValidationDetailsArePreserved() { + try (Response response = + LanceExceptionMapper.toRESTResponse( + "table", + new InvalidInputException("Invalid field", "field must be positive", "table"))) { + Assertions.assertEquals(400, response.getStatus()); + Assertions.assertEquals( + "field must be positive", ((ErrorResponse) response.getEntity()).getDetail()); + } + } + + private void assertAuthenticationError(Exception exception, int status) { + try (Response response = LanceExceptionMapper.toRESTResponse("catalog", exception)) { + Assertions.assertEquals(status, response.getStatus()); + ErrorResponse error = (ErrorResponse) response.getEntity(); + Assertions.assertEquals(exception.getMessage(), error.getError()); + Assertions.assertEquals("", error.getDetail()); + Assertions.assertEquals("catalog", error.getInstance()); } } } diff --git a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceNamespaceOperations.java b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceNamespaceOperations.java index a80c70a06b5..87069562bd1 100644 --- a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceNamespaceOperations.java +++ b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestLanceNamespaceOperations.java @@ -208,10 +208,9 @@ public void testListNamespaces() { ErrorResponse errorResp = resp.readEntity(ErrorResponse.class); Assertions.assertEquals(18, errorResp.getCode()); - Assertions.assertEquals("Test exception", errorResp.getError()); + Assertions.assertEquals("Internal server error", errorResp.getError()); + Assertions.assertEquals("", errorResp.getDetail()); Assertions.assertEquals("ns1.ns2", errorResp.getInstance()); - Assertions.assertNotNull(errorResp.getDetail()); - Assertions.assertTrue(errorResp.getDetail().contains("Test exception")); // root endpoint should use explicit root identifier instead of delimiter in error instance resp = @@ -262,7 +261,8 @@ public void testDescribeNamespace() { ErrorResponse errorResp = resp.readEntity(ErrorResponse.class); Assertions.assertEquals(18, errorResp.getCode()); - Assertions.assertEquals("Test exception", errorResp.getError()); + Assertions.assertEquals("Internal server error", errorResp.getError()); + Assertions.assertEquals("", errorResp.getDetail()); } @Test @@ -321,7 +321,8 @@ public void testCreateNamespace() { ErrorResponse errorResp = resp.readEntity(ErrorResponse.class); Assertions.assertEquals(18, errorResp.getCode()); - Assertions.assertEquals("Test exception", errorResp.getError()); + Assertions.assertEquals("Internal server error", errorResp.getError()); + Assertions.assertEquals("", errorResp.getDetail()); } @Test @@ -393,7 +394,8 @@ public void testDropNamespace() { ErrorResponse errorResp = resp.readEntity(ErrorResponse.class); Assertions.assertEquals(18, errorResp.getCode()); - Assertions.assertEquals("Test exception", errorResp.getError()); + Assertions.assertEquals("Internal server error", errorResp.getError()); + Assertions.assertEquals("", errorResp.getDetail()); } @Test @@ -457,7 +459,8 @@ void testCreateTable() { Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), resp.getStatus()); Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, resp.getMediaType()); ErrorResponse errorResp = resp.readEntity(ErrorResponse.class); - Assertions.assertEquals("Runtime exception", errorResp.getError()); + Assertions.assertEquals("Internal server error", errorResp.getError()); + Assertions.assertEquals("", errorResp.getDetail()); } @Test @@ -513,7 +516,8 @@ void testRegisterTable() { Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), resp.getStatus()); Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, resp.getMediaType()); ErrorResponse errorResp = resp.readEntity(ErrorResponse.class); - Assertions.assertEquals("Runtime exception", errorResp.getError()); + Assertions.assertEquals("Internal server error", errorResp.getError()); + Assertions.assertEquals("", errorResp.getDetail()); } @Test @@ -620,7 +624,8 @@ void testDeregisterTable() { Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), resp.getStatus()); Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, resp.getMediaType()); ErrorResponse errorResp = resp.readEntity(ErrorResponse.class); - Assertions.assertEquals("Runtime exception", errorResp.getError()); + Assertions.assertEquals("Internal server error", errorResp.getError()); + Assertions.assertEquals("", errorResp.getDetail()); } @Test @@ -677,7 +682,8 @@ void testDescribeTable() { Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), resp.getStatus()); Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, resp.getMediaType()); ErrorResponse errorResp = resp.readEntity(ErrorResponse.class); - Assertions.assertEquals("Runtime exception", errorResp.getError()); + Assertions.assertEquals("Internal server error", errorResp.getError()); + Assertions.assertEquals("", errorResp.getDetail()); } @Test @@ -999,6 +1005,7 @@ void testDeclareTable() { Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(), resp.getStatus()); Assertions.assertEquals(MediaType.APPLICATION_JSON_TYPE, resp.getMediaType()); ErrorResponse errorResp = resp.readEntity(ErrorResponse.class); - Assertions.assertEquals("Runtime exception", errorResp.getError()); + Assertions.assertEquals("Internal server error", errorResp.getError()); + Assertions.assertEquals("", errorResp.getDetail()); } }