This is an automated email from the ASF dual-hosted git repository.
roryqi pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 954bc1fca4 [Cherry-pick to branch-1.3] [#12840] fix(lineage): Validate
and authorize lineage events (#12850) (#12911)
954bc1fca4 is described below
commit 954bc1fca4f484f8969d99a749de9a04745b9fad
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Sep 4 19:03:58 2026 +0800
[Cherry-pick to branch-1.3] [#12840] fix(lineage): Validate and authorize
lineage events (#12850) (#12911)
**Cherry-pick Information:**
- Original commit: c0871fa58ef602782e38d87cea39cf68fae8d358
- Target branch: `branch-1.3`
- Status: ✅ **Conflicts resolved**
The cherry-pick conflicts have been manually resolved and validated.
---------
Co-authored-by: roryqi <[email protected]>
Co-authored-by: roryqi <[email protected]>
---
docs/open-api/lineage.yaml | 29 +-
lineage/build.gradle.kts | 1 +
.../lineage/source/rest/LineageEventValidator.java | 73 ++++
.../lineage/source/rest/LineageOperations.java | 24 +-
.../lineage/source/TestLineageOperations.java | 31 +-
.../source/rest/TestLineageEventValidator.java | 153 +++++++
.../annotations/AuthorizationRequest.java | 1 +
server/build.gradle.kts | 3 +
.../web/filter/GravitinoInterceptionService.java | 132 ++++--
.../authorization/AuthorizationExecutor.java | 10 +
.../authorization/AuthorizeExecutorFactory.java | 1 +
.../LineageAuthorizationExecutor.java | 188 ++++++++
.../filter/TestGravitinoInterceptionService.java | 36 ++
.../TestLineageAuthorizationExecutor.java | 478 +++++++++++++++++++++
14 files changed, 1117 insertions(+), 43 deletions(-)
diff --git a/docs/open-api/lineage.yaml b/docs/open-api/lineage.yaml
index 30074bf05b..187f3e56ef 100644
--- a/docs/open-api/lineage.yaml
+++ b/docs/open-api/lineage.yaml
@@ -21,7 +21,17 @@ paths:
/lineage:
post:
summary: Post runEvent
- description: Updates a run state for a job.
+ description: |
+ Updates a run state for a job. When authorization is enabled,
`job.namespace`
+ identifies the Gravitino metalake (organization), and every input and
output
+ dataset namespace must match it. Dataset names must be Gravitino
metadata full
+ names. The optional `datasetType` facet defaults to `TABLE`; supported
values are
+ `TABLE`, `VIEW`, `FILE`, `FILESET`, `MODEL`, and `TOPIC`.
+ Inputs and outputs both require metadata visibility because this
endpoint records
+ producer-reported lineage rather than attesting underlying read or
write execution.
+ RunEvents without datasets require only metalake membership.
Unsupported or external
+ dataset identifiers are rejected when authorization is enabled. When
authorization
+ is disabled, generic OpenLineage namespaces remain supported.
operationId: postRunEvent
tags:
- lineage
@@ -29,13 +39,18 @@ paths:
content:
application/json:
schema:
- oneOf:
- - $ref: '#/components/schemas/RunEvent'
- - $ref: '#/components/schemas/DatasetEvent'
- - $ref: '#/components/schemas/JobEvent'
+ $ref: '#/components/schemas/RunEvent'
responses:
- "200":
- description: OK
+ "201":
+ description: Created
+ "400":
+ $ref: "./openapi.yaml#/components/responses/BadRequestErrorResponse"
+ "403":
+ description: Forbidden - The caller cannot access at least one input
or output dataset.
+ content:
+ application/vnd.gravitino.v1+json:
+ schema:
+ $ref: "./openapi.yaml#/components/schemas/ErrorModel"
"429":
description: Too Many Requests
"5xx":
diff --git a/lineage/build.gradle.kts b/lineage/build.gradle.kts
index 81f3442064..26951eab2e 100644
--- a/lineage/build.gradle.kts
+++ b/lineage/build.gradle.kts
@@ -22,6 +22,7 @@ plugins {
}
dependencies {
+ implementation(project(":api"))
implementation(project(":common"))
implementation(project(":core"))
implementation(project(":server-common"))
diff --git
a/lineage/src/main/java/org/apache/gravitino/lineage/source/rest/LineageEventValidator.java
b/lineage/src/main/java/org/apache/gravitino/lineage/source/rest/LineageEventValidator.java
new file mode 100644
index 0000000000..db62ef859e
--- /dev/null
+++
b/lineage/src/main/java/org/apache/gravitino/lineage/source/rest/LineageEventValidator.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.lineage.source.rest;
+
+import com.google.common.base.Preconditions;
+import io.openlineage.server.OpenLineage.Dataset;
+import io.openlineage.server.OpenLineage.RunEvent;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+
+/** Validates the required fields of an OpenLineage run event. */
+public final class LineageEventValidator {
+
+ private LineageEventValidator() {}
+
+ /**
+ * Validates the required fields of an OpenLineage run event.
+ *
+ * @param event event to validate
+ * @throws IllegalArgumentException if a required field is absent or blank
+ */
+ public static void validate(RunEvent event) {
+ Preconditions.checkArgument(event != null, "Lineage event cannot be null");
+ Preconditions.checkArgument(event.getEventTime() != null, "eventTime is
required");
+ Preconditions.checkArgument(event.getProducer() != null, "producer is
required");
+ Preconditions.checkArgument(event.getSchemaURL() != null, "schemaURL is
required");
+ Preconditions.checkArgument(event.getRun() != null, "run is required");
+ Preconditions.checkArgument(event.getRun().getRunId() != null, "run.runId
is required");
+ Preconditions.checkArgument(event.getJob() != null, "job is required");
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(event.getJob().getNamespace()), "job.namespace
is required");
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(event.getJob().getName()), "job.name is
required");
+
+ validateDatasets(event.getInputs(), "inputs");
+ validateDatasets(event.getOutputs(), "outputs");
+ }
+
+ private static void validateDatasets(List<? extends Dataset> datasets,
String fieldName) {
+ if (datasets == null) {
+ return;
+ }
+
+ for (int index = 0; index < datasets.size(); index++) {
+ Dataset dataset = datasets.get(index);
+ Preconditions.checkArgument(dataset != null, "%s[%s] cannot be null",
fieldName, index);
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(dataset.getNamespace()),
+ "%s[%s].namespace is required",
+ fieldName,
+ index);
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(dataset.getName()), "%s[%s].name is
required", fieldName, index);
+ }
+ }
+}
diff --git
a/lineage/src/main/java/org/apache/gravitino/lineage/source/rest/LineageOperations.java
b/lineage/src/main/java/org/apache/gravitino/lineage/source/rest/LineageOperations.java
index 2f5fa472b6..ff2e6db9a5 100644
---
a/lineage/src/main/java/org/apache/gravitino/lineage/source/rest/LineageOperations.java
+++
b/lineage/src/main/java/org/apache/gravitino/lineage/source/rest/LineageOperations.java
@@ -32,6 +32,9 @@ import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.gravitino.lineage.LineageDispatcher;
import org.apache.gravitino.metrics.MetricNames;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants;
import org.apache.gravitino.server.web.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -40,7 +43,7 @@ import org.slf4j.LoggerFactory;
public class LineageOperations {
private static final Logger LOG =
LoggerFactory.getLogger(LineageOperations.class);
- private LineageDispatcher lineageDispatcher;
+ private final LineageDispatcher lineageDispatcher;
@Context private HttpServletRequest httpRequest;
@@ -53,16 +56,25 @@ public class LineageOperations {
@Produces(MediaType.APPLICATION_JSON)
@Timed(name = "post-lineage." + MetricNames.HTTP_PROCESS_DURATION, absolute
= true)
@ResponseMetered(name = "post-lineage", absolute = true)
- public Response postLineage(OpenLineage.RunEvent event) {
- LOG.info(
- "Open lineage event, run id:{}, job name:{}",
- org.apache.gravitino.lineage.Utils.getRunID(event),
- org.apache.gravitino.lineage.Utils.getJobName(event));
+ @AuthorizationExpression(expression =
AuthorizationExpressionConstants.CAN_ACCESS_METADATA)
+ public Response postLineage(
+ @AuthorizationRequest(type = AuthorizationRequest.RequestType.LINEAGE)
+ OpenLineage.RunEvent event) {
+ try {
+ LineageEventValidator.validate(event);
+ } catch (IllegalArgumentException e) {
+ LOG.warn("Invalid lineage event", e);
+ return Utils.illegalArguments(e.getMessage(), e);
+ }
try {
return Utils.doAs(
httpRequest,
() -> {
+ LOG.info(
+ "Open lineage event, run id:{}, job name:{}",
+ org.apache.gravitino.lineage.Utils.getRunID(event),
+ org.apache.gravitino.lineage.Utils.getJobName(event));
if (lineageDispatcher.dispatchLineageEvent(event)) {
return Utils.created();
} else {
diff --git
a/lineage/src/test/java/org/apache/gravitino/lineage/source/TestLineageOperations.java
b/lineage/src/test/java/org/apache/gravitino/lineage/source/TestLineageOperations.java
index 714f80171b..3fa3e0ccc7 100644
---
a/lineage/src/test/java/org/apache/gravitino/lineage/source/TestLineageOperations.java
+++
b/lineage/src/test/java/org/apache/gravitino/lineage/source/TestLineageOperations.java
@@ -92,7 +92,7 @@ public class TestLineageOperations extends JerseyTest {
@SneakyThrows
@Test
- public void testUpdateLineageSucc() {
+ public void testAuthorizationDisabledAcceptsGenericNamespaces() {
RunEvent runEvent = createRunEvent();
Mockito.when(lineageDispatcher.dispatchLineageEvent(ArgumentMatchers.any())).thenReturn(true);
Response resp =
@@ -103,6 +103,23 @@ public class TestLineageOperations extends JerseyTest {
Assertions.assertEquals(Status.CREATED.getStatusCode(), resp.getStatus());
}
+ @SneakyThrows
+ @Test
+ void testDispatcherIllegalArgumentExceptionReturnsInternalError() {
+ RunEvent runEvent = createRunEvent();
+
Mockito.when(lineageDispatcher.dispatchLineageEvent(ArgumentMatchers.any()))
+ .thenThrow(new IllegalArgumentException("Dispatcher failure"));
+
+ Response resp =
+ target("/lineage")
+ .request(MediaType.APPLICATION_JSON_TYPE)
+ .accept(MediaType.APPLICATION_JSON_TYPE)
+ .post(Entity.entity(runEvent, MediaType.APPLICATION_JSON_TYPE));
+
+ Assertions.assertEquals(Status.INTERNAL_SERVER_ERROR.getStatusCode(),
resp.getStatus());
+
Mockito.verify(lineageDispatcher).dispatchLineageEvent(ArgumentMatchers.any());
+ }
+
@SneakyThrows
@Test
public void testUpdateLineageFailed() {
@@ -116,6 +133,18 @@ public class TestLineageOperations extends JerseyTest {
Assertions.assertEquals(Status.TOO_MANY_REQUESTS.getStatusCode(),
resp.getStatus());
}
+ @Test
+ public void testRejectInvalidLineageEvent() {
+ Response resp =
+ target("/lineage")
+ .request(MediaType.APPLICATION_JSON_TYPE)
+ .accept(MediaType.APPLICATION_JSON_TYPE)
+ .post(Entity.entity("{}", MediaType.APPLICATION_JSON_TYPE));
+
+ Assertions.assertEquals(Status.BAD_REQUEST.getStatusCode(),
resp.getStatus());
+ Mockito.verifyNoInteractions(lineageDispatcher);
+ }
+
private RunEvent createRunEvent() {
URI producer = URI.create("producer");
OpenLineage ol = new OpenLineage(producer);
diff --git
a/lineage/src/test/java/org/apache/gravitino/lineage/source/rest/TestLineageEventValidator.java
b/lineage/src/test/java/org/apache/gravitino/lineage/source/rest/TestLineageEventValidator.java
new file mode 100644
index 0000000000..be648e0158
--- /dev/null
+++
b/lineage/src/test/java/org/apache/gravitino/lineage/source/rest/TestLineageEventValidator.java
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.lineage.source.rest;
+
+import io.openlineage.server.OpenLineage.InputDataset;
+import io.openlineage.server.OpenLineage.Job;
+import io.openlineage.server.OpenLineage.OutputDataset;
+import io.openlineage.server.OpenLineage.Run;
+import io.openlineage.server.OpenLineage.RunEvent;
+import io.openlineage.server.OpenLineage.RunEvent.EventType;
+import java.net.URI;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestLineageEventValidator {
+
+ private static final ZonedDateTime EVENT_TIME =
ZonedDateTime.now(ZoneOffset.UTC);
+ private static final URI PRODUCER =
URI.create("https://gravitino.apache.org/test");
+ private static final URI SCHEMA_URL =
+
URI.create("https://openlineage.io/spec/2-0-2/OpenLineage.json#/$defs/RunEvent");
+
+ @Test
+ void testAcceptValidEvent() {
+ Assertions.assertDoesNotThrow(() ->
LineageEventValidator.validate(validEvent()));
+ }
+
+ @Test
+ void testRejectMissingRequiredFields() {
+ assertInvalid("Lineage event cannot be null", null);
+ assertInvalid(
+ "eventTime is required",
+ event(null, PRODUCER, SCHEMA_URL, validRun(), validJob(), null, null));
+ assertInvalid(
+ "producer is required",
+ event(EVENT_TIME, null, SCHEMA_URL, validRun(), validJob(), null,
null));
+ assertInvalid(
+ "schemaURL is required",
+ event(EVENT_TIME, PRODUCER, null, validRun(), validJob(), null, null));
+ assertInvalid(
+ "run is required", event(EVENT_TIME, PRODUCER, SCHEMA_URL, null,
validJob(), null, null));
+ assertInvalid(
+ "run.runId is required",
+ event(EVENT_TIME, PRODUCER, SCHEMA_URL, new Run(null, null),
validJob(), null, null));
+ assertInvalid(
+ "job is required", event(EVENT_TIME, PRODUCER, SCHEMA_URL, validRun(),
null, null, null));
+ assertInvalid(
+ "job.namespace is required",
+ event(EVENT_TIME, PRODUCER, SCHEMA_URL, validRun(), new Job(" ",
"job", null), null, null));
+ assertInvalid(
+ "job.name is required",
+ event(
+ EVENT_TIME,
+ PRODUCER,
+ SCHEMA_URL,
+ validRun(),
+ new Job("namespace", " ", null),
+ null,
+ null));
+ }
+
+ @Test
+ void testRejectInvalidDatasets() {
+ assertInvalid(
+ "inputs[0] cannot be null",
+ event(
+ EVENT_TIME,
+ PRODUCER,
+ SCHEMA_URL,
+ validRun(),
+ validJob(),
+ Arrays.asList((InputDataset) null),
+ null));
+ assertInvalid(
+ "inputs[0].namespace is required",
+ event(
+ EVENT_TIME,
+ PRODUCER,
+ SCHEMA_URL,
+ validRun(),
+ validJob(),
+ List.of(new InputDataset(" ", "catalog.schema.table", null, null)),
+ null));
+ assertInvalid(
+ "outputs[0].name is required",
+ event(
+ EVENT_TIME,
+ PRODUCER,
+ SCHEMA_URL,
+ validRun(),
+ validJob(),
+ null,
+ List.of(new OutputDataset("metalake", " ", null, null))));
+ }
+
+ private static RunEvent validEvent() {
+ return event(
+ EVENT_TIME,
+ PRODUCER,
+ SCHEMA_URL,
+ validRun(),
+ validJob(),
+ List.of(new InputDataset("metalake", "catalog.schema.table", null,
null)),
+ List.of(new OutputDataset("metalake", "catalog.schema.table", null,
null)));
+ }
+
+ private static RunEvent event(
+ ZonedDateTime eventTime,
+ URI producer,
+ URI schemaURL,
+ Run run,
+ Job job,
+ List<InputDataset> inputs,
+ List<OutputDataset> outputs) {
+ return new RunEvent(eventTime, producer, schemaURL, EventType.START, run,
job, inputs, outputs);
+ }
+
+ private static Run validRun() {
+ return new Run(UUID.randomUUID(), null);
+ }
+
+ private static Job validJob() {
+ return new Job("namespace", "job", null);
+ }
+
+ private static void assertInvalid(String message, RunEvent event) {
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class, () ->
LineageEventValidator.validate(event));
+ Assertions.assertEquals(message, exception.getMessage());
+ }
+}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
index 3d84fa84f2..2492fc9976 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/annotations/AuthorizationRequest.java
@@ -33,6 +33,7 @@ public @interface AuthorizationRequest {
ASSOCIATE_TAG,
ASSOCIATE_POLICY,
RUN_JOB,
+ LINEAGE,
LOAD_TABLE,
CREATE_SCHEMA
}
diff --git a/server/build.gradle.kts b/server/build.gradle.kts
index e417e70927..c5627f62b1 100644
--- a/server/build.gradle.kts
+++ b/server/build.gradle.kts
@@ -39,6 +39,9 @@ dependencies {
implementation(libs.jackson.datatype.jsr310)
implementation(libs.jackson.databind)
implementation(libs.metrics.jersey2)
+ implementation(libs.openlineage.java) {
+ isTransitive = false
+ }
// As of Java 9 or newer, the javax.activation package (needed by the jetty
server) is no longer part of the JDK. It was removed because it was part of the
// JavaBeans Activation Framework (JAF) which has been removed from Java SE.
So we need to add it as a dependency. For more,
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
index 551a443771..d515cec330 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java
@@ -43,8 +43,10 @@ import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.authorization.AuthorizationRequestContext;
import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.exceptions.BadRequestException;
import org.apache.gravitino.exceptions.ForbiddenException;
import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.lineage.source.rest.LineageOperations;
import
org.apache.gravitino.listener.api.event.server.AuthorizationDenialFailureEvent;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
@@ -113,7 +115,8 @@ public class GravitinoInterceptionService implements
InterceptionService {
PolicyOperations.class.getName(),
MetadataObjectPolicyOperations.class.getName(),
JobOperations.class.getName(),
- MetadataObjectCredentialOperations.class.getName()));
+ MetadataObjectCredentialOperations.class.getName(),
+ LineageOperations.class.getName()));
}
@Override
@@ -150,7 +153,7 @@ public class GravitinoInterceptionService implements
InterceptionService {
method.getAnnotation(AuthorizationExpression.class);
try {
- AuthorizationExecutor executor;
+ AuthorizationExecutor executor = null;
if (expressionAnnotation != null) {
String expression = expressionAnnotation.expression();
Object[] args = methodInvocation.getArguments();
@@ -162,34 +165,21 @@ public class GravitinoInterceptionService implements
InterceptionService {
AuthorizationRequestContext authorizationRequestContext =
new AuthorizationRequestContext();
- // Check metalake and user existence before authorization
+ Optional<String> authorizationMetalake = Optional.empty();
NameIdentifier metalakeIdent =
metadataContext.get(Entity.EntityType.METALAKE);
if (metalakeIdent != null) {
- String currentUser = PrincipalUtils.getCurrentUserName();
- try {
- AuthorizationUtils.checkCurrentUser(
- metalakeIdent.name(), currentUser,
authorizationRequestContext);
- } catch (NoSuchMetalakeException e) {
- LOG.warn(
- "Metalake {} does not exist when validating user {}",
metalakeIdent, currentUser);
- // Not a real authz denial — metalake is absent, not forbidden.
Skip event dispatch;
- // HttpAuditFilter will emit a generic HttpRequestFailureEvent
for this 403.
- return buildNoAuthResponse(expressionAnnotation,
metadataContext, method, expression);
- } catch (ForbiddenException ex) {
- LOG.warn(
- "User validation failed - User: {}, Metalake: {}, Reason:
{}",
- currentUser,
- metalakeIdent.name(),
- ex.getMessage());
- dispatchAuthzDenialEvent(currentUser, metalakeIdent,
method.getName(), expression);
- return Utils.forbidden(ex.getMessage(), ex);
- } catch (Exception ex) {
- LOG.error(
- "Unexpected error during user validation - User: {},
Metalake: {}",
- currentUser,
- metalakeIdent.name(),
- ex);
- return Utils.internalError("Failed to validate user", ex);
+ authorizationMetalake = Optional.of(metalakeIdent.name());
+ Optional<Response> validationFailure =
+ validateCurrentUser(
+ metalakeIdent,
+ authorizationRequestContext,
+ expressionAnnotation,
+ metadataContext,
+ method,
+ expression,
+ false);
+ if (validationFailure.isPresent()) {
+ return validationFailure.get();
}
}
@@ -211,7 +201,46 @@ public class GravitinoInterceptionService implements
InterceptionService {
args,
secondaryExpression,
secondaryExpressionCondition);
- boolean authorizeResult =
executor.execute(authorizationRequestContext);
+ Optional<String> dynamicMetalake;
+ try {
+ dynamicMetalake = executor.getAuthorizationMetalake();
+ if (dynamicMetalake.isPresent()
+ && authorizationMetalake.isPresent()
+ &&
!dynamicMetalake.get().equals(authorizationMetalake.get())) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Authorization request metalake '%s' does not match
path metalake '%s'",
+ dynamicMetalake.get(), authorizationMetalake.get()));
+ }
+ } catch (IllegalArgumentException exception) {
+ LOG.warn("Invalid authorization request", exception);
+ return Utils.illegalArguments(exception.getMessage(), exception);
+ }
+
+ if (dynamicMetalake.isPresent() &&
authorizationMetalake.isEmpty()) {
+ Optional<Response> validationFailure =
+ validateCurrentUser(
+ NameIdentifier.of(dynamicMetalake.get()),
+ authorizationRequestContext,
+ expressionAnnotation,
+ metadataContext,
+ method,
+ expression,
+ true);
+ if (validationFailure.isPresent()) {
+ return validationFailure.get();
+ }
+ }
+ }
+
+ if (executor != null) {
+ boolean authorizeResult;
+ try {
+ authorizeResult = executor.execute(authorizationRequestContext);
+ } catch (BadRequestException exception) {
+ LOG.warn("Invalid authorization request", exception);
+ return Utils.illegalArguments(exception.getMessage(), exception);
+ }
if (!authorizeResult) {
MetadataObject.Type type =
expressionAnnotation.accessMetadataType();
NameIdentifier accessMetadataName =
@@ -240,6 +269,51 @@ public class GravitinoInterceptionService implements
InterceptionService {
}
}
+ private Optional<Response> validateCurrentUser(
+ NameIdentifier metalakeIdent,
+ AuthorizationRequestContext authorizationRequestContext,
+ AuthorizationExpression expressionAnnotation,
+ Map<Entity.EntityType, NameIdentifier> metadataContext,
+ Method method,
+ String expression,
+ boolean dynamicMetalake) {
+ String currentUser = PrincipalUtils.getCurrentUserName();
+ try {
+ AuthorizationUtils.checkCurrentUser(
+ metalakeIdent.name(), currentUser, authorizationRequestContext);
+ } catch (NoSuchMetalakeException e) {
+ LOG.warn("Metalake {} does not exist when validating user {}",
metalakeIdent, currentUser);
+ if (dynamicMetalake) {
+ return Optional.of(
+ Utils.illegalArguments(
+ String.format(
+ "job.namespace must identify an existing metalake: %s",
metalakeIdent.name()),
+ e));
+ }
+ // Not a real authz denial — metalake is absent, not forbidden. Skip
event dispatch;
+ // HttpAuditFilter will emit a generic HttpRequestFailureEvent for
this 403.
+ return Optional.of(
+ buildNoAuthResponse(expressionAnnotation, metadataContext, method,
expression));
+ } catch (ForbiddenException ex) {
+ LOG.warn(
+ "User validation failed - User: {}, Metalake: {}, Reason: {}",
+ currentUser,
+ metalakeIdent.name(),
+ ex.getMessage());
+ dispatchAuthzDenialEvent(currentUser, metalakeIdent, method.getName(),
expression);
+ return Optional.of(Utils.forbidden(ex.getMessage(), ex));
+ } catch (Exception ex) {
+ LOG.error(
+ "Unexpected error during user validation - User: {}, Metalake: {}",
+ currentUser,
+ metalakeIdent.name(),
+ ex);
+ return Optional.of(Utils.internalError("Failed to validate user", ex));
+ }
+
+ return Optional.empty();
+ }
+
private Response buildNoAuthResponse(
AuthorizationExpression expressionAnnotation,
Map<Entity.EntityType, NameIdentifier> metadataContext,
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizationExecutor.java
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizationExecutor.java
index d98d448a10..50111c58a9 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizationExecutor.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizationExecutor.java
@@ -17,9 +17,19 @@
package org.apache.gravitino.server.web.filter.authorization;
+import java.util.Optional;
import org.apache.gravitino.authorization.AuthorizationRequestContext;
public interface AuthorizationExecutor {
+ /**
+ * Returns the metalake dynamically resolved from the authorization request.
+ *
+ * @return the metalake that requires user and active-role validation
+ */
+ default Optional<String> getAuthorizationMetalake() {
+ return Optional.empty();
+ }
+
boolean execute(AuthorizationRequestContext authorizationRequestContext)
throws Exception;
}
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
index 4d2f4c63aa..027f949abc 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/AuthorizeExecutorFactory.java
@@ -46,6 +46,7 @@ public class AuthorizeExecutorFactory {
expression, parameters, args, metadataContext, pathParams,
entityType);
case RUN_JOB -> new RunJobAuthorizationExecutor(
parameters, args, expression, metadataContext, pathParams,
entityType);
+ case LINEAGE -> new LineageAuthorizationExecutor(parameters, args,
expression);
case LOAD_TABLE -> new LoadTableAuthorizationExecutor(
parameters,
args,
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LineageAuthorizationExecutor.java
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LineageAuthorizationExecutor.java
new file mode 100644
index 0000000000..5af3a60d62
--- /dev/null
+++
b/server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LineageAuthorizationExecutor.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.server.web.filter.authorization;
+
+import static
org.apache.gravitino.server.web.filter.ParameterUtil.extractFromParameters;
+
+import com.google.common.base.Preconditions;
+import io.openlineage.server.OpenLineage.Dataset;
+import io.openlineage.server.OpenLineage.DatasetFacet;
+import io.openlineage.server.OpenLineage.DatasetFacets;
+import io.openlineage.server.OpenLineage.RunEvent;
+import java.lang.reflect.Parameter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.authorization.AuthorizationRequestContext;
+import org.apache.gravitino.exceptions.BadRequestException;
+import org.apache.gravitino.lineage.source.rest.LineageEventValidator;
+import
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionEvaluator;
+import org.apache.gravitino.utils.MetadataObjectUtil;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/** Authorization executor for every input and output dataset in an
OpenLineage event. */
+public class LineageAuthorizationExecutor implements AuthorizationExecutor {
+
+ private static final String DATASET_TYPE_FACET = "datasetType";
+
+ private final Parameter[] parameters;
+ private final Object[] args;
+ private final String expression;
+ private List<AuthorizationTarget> authorizationTargets = List.of();
+ private boolean authorizationTargetsResolved;
+
+ /**
+ * Creates an authorization executor for an OpenLineage event.
+ *
+ * @param parameters parameters of the intercepted REST method
+ * @param args arguments passed to the intercepted REST method
+ * @param expression authorization expression to evaluate for every dataset
+ */
+ public LineageAuthorizationExecutor(Parameter[] parameters, Object[] args,
String expression) {
+ this.parameters = parameters;
+ this.args = args;
+ this.expression = expression;
+ }
+
+ @Override
+ public Optional<String> getAuthorizationMetalake() {
+ RunEvent event = extractRunEvent();
+ Preconditions.checkArgument(event.getJob() != null, "job is required");
+ Preconditions.checkArgument(
+ StringUtils.isNotBlank(event.getJob().getNamespace()), "job.namespace
is required");
+ return Optional.of(event.getJob().getNamespace());
+ }
+
+ @Override
+ public boolean execute(AuthorizationRequestContext context) {
+ if (!authorizationTargetsResolved) {
+ try {
+ resolveAuthorizationTargets(extractRunEvent());
+ } catch (IllegalArgumentException e) {
+ throw new BadRequestException(e, "%s", e.getMessage());
+ }
+ }
+
+ AuthorizationExpressionEvaluator evaluator = new
AuthorizationExpressionEvaluator(expression);
+ context.setOriginalAuthorizationExpression(expression);
+
+ // Dataset-less OpenLineage events are valid; the interceptor has already
validated metalake
+ // membership before this executor runs.
+ for (AuthorizationTarget target : authorizationTargets) {
+ if (!evaluator.evaluate(
+ target.metadataContext, Map.of(), context,
Optional.of(target.entityType.name()))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ static MetadataObject.Type getMetadataType(Dataset dataset) {
+ DatasetFacets facets = dataset.getFacets();
+ if (facets == null) {
+ return MetadataObject.Type.TABLE;
+ }
+
+ DatasetFacet datasetTypeFacet =
facets.getAdditionalProperties().get(DATASET_TYPE_FACET);
+ if (datasetTypeFacet == null) {
+ return MetadataObject.Type.TABLE;
+ }
+
+ Object datasetType =
datasetTypeFacet.getAdditionalProperties().get(DATASET_TYPE_FACET);
+ Preconditions.checkArgument(
+ datasetType instanceof String && StringUtils.isNotBlank((String)
datasetType),
+ "The datasetType facet must contain a non-blank datasetType");
+
+ return switch (((String) datasetType).toUpperCase(Locale.ROOT)) {
+ case "TABLE" -> MetadataObject.Type.TABLE;
+ case "VIEW" -> MetadataObject.Type.VIEW;
+ case "FILE", "FILESET" -> MetadataObject.Type.FILESET;
+ case "MODEL" -> MetadataObject.Type.MODEL;
+ case "TOPIC" -> MetadataObject.Type.TOPIC;
+ default -> throw new IllegalArgumentException("Unsupported dataset type:
" + datasetType);
+ };
+ }
+
+ private RunEvent extractRunEvent() {
+ Object request = extractFromParameters(parameters, args);
+ Preconditions.checkArgument(request instanceof RunEvent, "Lineage request
must be a RunEvent");
+ return (RunEvent) request;
+ }
+
+ private void resolveAuthorizationTargets(RunEvent event) {
+ if (authorizationTargetsResolved) {
+ return;
+ }
+
+ LineageEventValidator.validate(event);
+ String metalake = event.getJob().getNamespace();
+ List<AuthorizationTarget> targets = new ArrayList<>();
+ resolveAuthorizationTargets(event.getInputs(), "inputs", metalake,
targets);
+ resolveAuthorizationTargets(event.getOutputs(), "outputs", metalake,
targets);
+ authorizationTargets = List.copyOf(targets);
+ authorizationTargetsResolved = true;
+ }
+
+ private static void resolveAuthorizationTargets(
+ List<? extends Dataset> datasets,
+ String fieldName,
+ String metalake,
+ List<AuthorizationTarget> targets) {
+ if (datasets == null) {
+ return;
+ }
+
+ for (int index = 0; index < datasets.size(); index++) {
+ Dataset dataset = datasets.get(index);
+ Preconditions.checkArgument(
+ metalake.equals(dataset.getNamespace()),
+ "%s[%s].namespace must match job.namespace '%s'",
+ fieldName,
+ index,
+ metalake);
+
+ MetadataObject.Type metadataType = getMetadataType(dataset);
+ MetadataObject metadataObject = MetadataObjects.parse(dataset.getName(),
metadataType);
+ Entity.EntityType entityType =
MetadataObjectUtil.toEntityType(metadataType);
+ NameIdentifier identifier = MetadataObjectUtil.toEntityIdent(metalake,
metadataObject);
+ Map<Entity.EntityType, NameIdentifier> metadataContext =
+ NameIdentifierUtil.splitNameIdentifier(metalake, entityType,
identifier);
+ targets.add(new AuthorizationTarget(metadataContext, entityType));
+ }
+ }
+
+ private static class AuthorizationTarget {
+ private final Map<Entity.EntityType, NameIdentifier> metadataContext;
+ private final Entity.EntityType entityType;
+
+ private AuthorizationTarget(
+ Map<Entity.EntityType, NameIdentifier> metadataContext,
Entity.EntityType entityType) {
+ this.metadataContext = metadataContext;
+ this.entityType = entityType;
+ }
+ }
+}
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
index 69d6b17f15..93f3681170 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/filter/TestGravitinoInterceptionService.java
@@ -47,6 +47,7 @@ import org.apache.gravitino.authorization.AuthorizationUtils;
import org.apache.gravitino.authorization.GravitinoAuthorizer;
import org.apache.gravitino.authorization.Privilege;
import org.apache.gravitino.catalog.ViewDispatcher;
+import org.apache.gravitino.dto.requests.SchemaCreateRequest;
import org.apache.gravitino.dto.responses.ErrorResponse;
import org.apache.gravitino.exceptions.ForbiddenException;
import org.apache.gravitino.exceptions.NoSuchMetalakeException;
@@ -57,6 +58,7 @@ import
org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationExpression;
import
org.apache.gravitino.server.authorization.annotations.AuthorizationMetadata;
import org.apache.gravitino.server.web.Utils;
+import org.apache.gravitino.server.web.rest.SchemaOperations;
import org.apache.gravitino.server.web.rest.ViewOperations;
import org.apache.gravitino.utils.PrincipalUtils;
import org.apache.gravitino.utils.RequestContext;
@@ -77,6 +79,40 @@ public class TestGravitinoInterceptionService {
RequestContext.clear();
}
+ @Test
+ public void testPathMetalakeValidationPrecedesExecutorConstruction() throws
Throwable {
+ Method method =
+ SchemaOperations.class.getMethod(
+ "createSchema", String.class, String.class,
SchemaCreateRequest.class);
+ MethodInvocation invocation = mock(MethodInvocation.class);
+ SchemaCreateRequest malformedRequest = mock(SchemaCreateRequest.class);
+ when(invocation.getMethod()).thenReturn(method);
+ when(invocation.getArguments())
+ .thenReturn(new Object[] {"metalake", "catalog", malformedRequest});
+
+ try (MockedStatic<PrincipalUtils> principalUtils =
mockStatic(PrincipalUtils.class);
+ MockedStatic<AuthorizationUtils> authorizationUtils =
+ mockStatic(AuthorizationUtils.class)) {
+
principalUtils.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+ authorizationUtils
+ .when(
+ () ->
+ AuthorizationUtils.checkCurrentUser(
+ ArgumentMatchers.eq("metalake"),
+ ArgumentMatchers.eq("tester"),
+ any(AuthorizationRequestContext.class)))
+ .thenThrow(new ForbiddenException("User tester is not a member"));
+
+ MethodInterceptor interceptor =
+ new
GravitinoInterceptionService().getMethodInterceptors(method).get(0);
+ Response response = (Response) interceptor.invoke(invocation);
+
+ assertEquals(Response.Status.FORBIDDEN.getStatusCode(),
response.getStatus());
+ verify(malformedRequest, never()).getName();
+ verify(invocation, never()).proceed();
+ }
+ }
+
@Test
public void testViewOperationsIsRegisteredForInterception() {
Descriptor descriptor = mock(Descriptor.class);
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLineageAuthorizationExecutor.java
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLineageAuthorizationExecutor.java
new file mode 100644
index 0000000000..2829baf2ca
--- /dev/null
+++
b/server/src/test/java/org/apache/gravitino/server/web/filter/authorization/TestLineageAuthorizationExecutor.java
@@ -0,0 +1,478 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.server.web.filter.authorization;
+
+import static
org.apache.gravitino.server.authorization.expression.AuthorizationExpressionConstants.CAN_ACCESS_METADATA;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import io.openlineage.server.OpenLineage.DatasetFacet;
+import io.openlineage.server.OpenLineage.DatasetFacets;
+import io.openlineage.server.OpenLineage.InputDataset;
+import io.openlineage.server.OpenLineage.Job;
+import io.openlineage.server.OpenLineage.OutputDataset;
+import io.openlineage.server.OpenLineage.Run;
+import io.openlineage.server.OpenLineage.RunEvent;
+import io.openlineage.server.OpenLineage.RunEvent.EventType;
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import javax.ws.rs.core.Response;
+import org.aopalliance.intercept.MethodInterceptor;
+import org.aopalliance.intercept.MethodInvocation;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.UserPrincipal;
+import org.apache.gravitino.authorization.AuthorizationRequestContext;
+import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.GravitinoAuthorizer;
+import org.apache.gravitino.authorization.Privilege;
+import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.exceptions.BadRequestException;
+import org.apache.gravitino.exceptions.ForbiddenException;
+import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.lineage.source.rest.LineageOperations;
+import org.apache.gravitino.server.authorization.GravitinoAuthorizerProvider;
+import
org.apache.gravitino.server.authorization.annotations.AuthorizationRequest;
+import
org.apache.gravitino.server.authorization.annotations.ExpressionCondition;
+import org.apache.gravitino.server.web.filter.GravitinoInterceptionService;
+import org.apache.gravitino.utils.PrincipalUtils;
+import org.glassfish.hk2.api.Descriptor;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
+
+class TestLineageAuthorizationExecutor {
+
+ private static final String METALAKE = "metalake";
+ private static final String DATASET_NAME = "catalog.schema.object";
+ private static final URI PRODUCER =
URI.create("https://gravitino.apache.org/test");
+ private static final URI SCHEMA_URL =
+
URI.create("https://openlineage.io/spec/2-0-2/OpenLineage.json#/$defs/RunEvent");
+
+ @Test
+ void testRegisterWithStandardInterceptionFlow() throws Exception {
+ GravitinoInterceptionService service = new GravitinoInterceptionService();
+ Descriptor descriptor = mock(Descriptor.class);
+
when(descriptor.getImplementation()).thenReturn(LineageOperations.class.getName());
+ Method method = lineageMethod();
+
+ Assertions.assertTrue(service.getDescriptorFilter().matches(descriptor));
+ Assertions.assertEquals(1, service.getMethodInterceptors(method).size());
+ Assertions.assertTrue(
+ service
+ .getMethodInterceptors(method)
+ .get(0)
+ .getClass()
+ .getSimpleName()
+ .contains("MetadataAuthorization"));
+ Assertions.assertInstanceOf(LineageAuthorizationExecutor.class,
createWithFactory(event()));
+ }
+
+ @Test
+ void testExposeEventOrganizationAsAuthorizationMetalake() throws Exception {
+ Assertions.assertEquals(Optional.of(METALAKE),
executor(event()).getAuthorizationMetalake());
+ }
+
+ @Test
+ void testInterceptorRejectsUserOutsideDynamicMetalake() throws Throwable {
+ MethodInvocation invocation = invocation(event());
+
+ try (MockedStatic<PrincipalUtils> principalUtils =
mockStatic(PrincipalUtils.class);
+ MockedStatic<AuthorizationUtils> authorizationUtils =
+ mockStatic(AuthorizationUtils.class)) {
+
principalUtils.when(PrincipalUtils::getCurrentPrincipal).thenReturn(principal());
+
principalUtils.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+ authorizationUtils
+ .when(
+ () ->
+ AuthorizationUtils.checkCurrentUser(
+ eq(METALAKE), eq("tester"),
any(AuthorizationRequestContext.class)))
+ .thenThrow(new ForbiddenException("User tester is not a member"));
+
+ Response response = (Response) lineageInterceptor().invoke(invocation);
+
+ Assertions.assertEquals(Response.Status.FORBIDDEN.getStatusCode(),
response.getStatus());
+ verify(invocation, never()).proceed();
+ }
+ }
+
+ @Test
+ void testRejectNonexistentDynamicMetalakeAsBadRequest() throws Throwable {
+ MethodInvocation invocation = invocation(event());
+
+ try (MockedStatic<PrincipalUtils> principalUtils =
mockStatic(PrincipalUtils.class);
+ MockedStatic<AuthorizationUtils> authorizationUtils =
+ mockStatic(AuthorizationUtils.class)) {
+
principalUtils.when(PrincipalUtils::getCurrentPrincipal).thenReturn(principal());
+
principalUtils.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+ authorizationUtils
+ .when(
+ () ->
+ AuthorizationUtils.checkCurrentUser(
+ eq(METALAKE), eq("tester"),
any(AuthorizationRequestContext.class)))
+ .thenThrow(new NoSuchMetalakeException("Metalake does not exist"));
+
+ Response response = (Response) lineageInterceptor().invoke(invocation);
+
+ Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(),
response.getStatus());
+ ErrorResponse errorResponse = (ErrorResponse) response.getEntity();
+ Assertions.assertTrue(
+ errorResponse.getMessage().contains("job.namespace"),
+ "The response should identify the invalid field");
+ verify(invocation, never()).proceed();
+ }
+ }
+
+ @ParameterizedTest
+ @CsvSource({"inputs,0", "inputs,1", "outputs,0", "outputs,1"})
+ void testRejectCrossOrganizationDatasetAtEveryPosition(String field, int
index) throws Throwable {
+ List<InputDataset> inputs =
+ new ArrayList<>(List.of(input("catalog.schema.input0"),
input("catalog.schema.input1")));
+ List<OutputDataset> outputs =
+ new ArrayList<>(
+ List.of(output("catalog.schema.output0"),
output("catalog.schema.output1")));
+ if (field.equals("inputs")) {
+ inputs.set(
+ index, new InputDataset("anotherMetalake",
inputs.get(index).getName(), null, null));
+ } else {
+ outputs.set(
+ index, new OutputDataset("anotherMetalake",
outputs.get(index).getName(), null, null));
+ }
+
+ assertBadRequest(event(METALAKE, inputs, outputs));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"UNKNOWN", "MODEL_VERSION"})
+ void testRejectUnsupportedDatasetTypeAsBadRequest(String datasetType) throws
Throwable {
+ assertBadRequest(
+ event(METALAKE, List.of(dataset(datasetType, METALAKE, DATASET_NAME)),
List.of()));
+ }
+
+ @Test
+ void testRejectMalformedDatasetNameAsBadRequest() throws Throwable {
+ assertBadRequest(
+ event(METALAKE, List.of(dataset(null, METALAKE, "catalog.object")),
List.of()));
+ }
+
+ @Test
+ void testRejectMalformedEventAsBadRequest() throws Throwable {
+ assertBadRequest(null);
+ }
+
+ @Test
+ void testResolveTargetsOnlyDuringExecution() throws Exception {
+ LineageAuthorizationExecutor executor =
+ executor(event(METALAKE, List.of(input("catalog.object")), List.of()));
+
+ Assertions.assertEquals(Optional.of(METALAKE),
executor.getAuthorizationMetalake());
+ Assertions.assertThrows(
+ BadRequestException.class,
+ () -> execute(executor, mock(GravitinoAuthorizer.class), principal()));
+ }
+
+ @Test
+ void testAllowDatasetlessEvent() throws Exception {
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+ LineageAuthorizationExecutor executor = executor(event(METALAKE,
List.of(), List.of()));
+
+ Assertions.assertEquals(Optional.of(METALAKE),
executor.getAuthorizationMetalake());
+ Assertions.assertTrue(execute(executor, authorizer, principal()));
+ verifyNoInteractions(authorizer);
+ }
+
+ @Test
+ void testOutputRequiresMetadataVisibilityOnly() throws Exception {
+ MetadataObject outputObject = MetadataObjects.parse(DATASET_NAME,
MetadataObject.Type.TABLE);
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+ when(authorizer.authorize(
+ any(),
+ eq(METALAKE),
+ any(),
+ eq(Privilege.Name.USE_CATALOG),
+ any(AuthorizationRequestContext.class)))
+ .thenReturn(true);
+ when(authorizer.authorize(
+ any(),
+ eq(METALAKE),
+ any(),
+ eq(Privilege.Name.USE_SCHEMA),
+ any(AuthorizationRequestContext.class)))
+ .thenReturn(true);
+ when(authorizer.authorize(
+ any(),
+ eq(METALAKE),
+ eq(outputObject),
+ eq(Privilege.Name.SELECT_TABLE),
+ any(AuthorizationRequestContext.class)))
+ .thenReturn(true);
+
+ Assertions.assertTrue(
+ execute(
+ executor(event(METALAKE, List.of(),
List.of(output(DATASET_NAME)))),
+ authorizer,
+ principal()));
+ verify(authorizer)
+ .authorize(
+ any(),
+ eq(METALAKE),
+ eq(outputObject),
+ eq(Privilege.Name.SELECT_TABLE),
+ any(AuthorizationRequestContext.class));
+ }
+
+ @Test
+ void testPermissionDenialReturnsForbidden() throws Throwable {
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+
+ Response response = intercept(event(), authorizer);
+
+ Assertions.assertEquals(Response.Status.FORBIDDEN.getStatusCode(),
response.getStatus());
+ }
+
+ @Test
+ void testAuthorizerIllegalArgumentExceptionReturnsInternalError() throws
Throwable {
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+ when(authorizer.isOwner(any(), any(), any(), any()))
+ .thenThrow(new IllegalArgumentException("Authorizer backend failure"));
+
+ Response response = intercept(event(), authorizer);
+
+ Assertions.assertEquals(
+ Response.Status.INTERNAL_SERVER_ERROR.getStatusCode(),
response.getStatus());
+ }
+
+ @Test
+ void testAuthorizeAllInputAndOutputDatasets() throws Exception {
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+ when(authorizer.isOwner(any(), any(), any(), any())).thenReturn(true);
+ RunEvent runEvent =
+ event(
+ METALAKE,
+ List.of(input("catalog.schema.input")),
+ List.of(output("catalog.schema.output")));
+
+ Assertions.assertTrue(execute(executor(runEvent, "TABLE::OWNER"),
authorizer, principal()));
+ }
+
+ @ParameterizedTest
+ @CsvSource({"inputs,0", "inputs,1", "outputs,0", "outputs,1"})
+ void testPermissionFailureAtEveryDatasetPosition(String field, int index)
throws Exception {
+ List<InputDataset> inputs =
+ List.of(input("catalog.schema.input0"),
input("catalog.schema.input1"));
+ List<OutputDataset> outputs =
+ List.of(output("catalog.schema.output0"),
output("catalog.schema.output1"));
+ String deniedName =
+ field.equals("inputs") ? inputs.get(index).getName() :
outputs.get(index).getName();
+ MetadataObject denied = MetadataObjects.parse(deniedName,
MetadataObject.Type.TABLE);
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+ when(authorizer.isOwner(any(), eq(METALAKE), any(), any()))
+ .thenAnswer(call -> !denied.equals(call.getArgument(2)));
+
+ Assertions.assertFalse(
+ execute(
+ executor(event(METALAKE, inputs, outputs), "TABLE::OWNER"),
authorizer, principal()));
+ verify(authorizer).isOwner(any(), eq(METALAKE), eq(denied), any());
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "DEFAULT,TABLE",
+ "TABLE,TABLE",
+ "VIEW,VIEW",
+ "FILE,FILESET",
+ "Fileset,FILESET",
+ "MODEL,MODEL",
+ "TOPIC,TOPIC"
+ })
+ void testResolveExactAuthorizationTarget(String datasetType,
MetadataObject.Type expectedType)
+ throws Exception {
+ String facetType = datasetType.equals("DEFAULT") ? null : datasetType;
+ InputDataset dataset = dataset(facetType, METALAKE, DATASET_NAME);
+ MetadataObject expected = MetadataObjects.parse(DATASET_NAME,
expectedType);
+ GravitinoAuthorizer authorizer = mock(GravitinoAuthorizer.class);
+ when(authorizer.isOwner(any(), eq(METALAKE), any(), any()))
+ .thenAnswer(call -> expected.equals(call.getArgument(2)));
+
+ Assertions.assertEquals(expectedType,
LineageAuthorizationExecutor.getMetadataType(dataset));
+ Assertions.assertTrue(
+ execute(
+ executor(event(METALAKE, List.of(dataset), List.of()),
expectedType.name() + "::OWNER"),
+ authorizer,
+ principal()));
+ verify(authorizer).isOwner(any(), eq(METALAKE), eq(expected), any());
+ }
+
+ private static void assertBadRequest(RunEvent event) throws Throwable {
+ MethodInvocation invocation = invocation(event);
+ try (MockedStatic<PrincipalUtils> principalUtils =
mockStatic(PrincipalUtils.class);
+ MockedStatic<AuthorizationUtils> authorizationUtils =
+ mockStatic(AuthorizationUtils.class)) {
+
principalUtils.when(PrincipalUtils::getCurrentPrincipal).thenReturn(principal());
+
principalUtils.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+
+ Response response = (Response) lineageInterceptor().invoke(invocation);
+
+ Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(),
response.getStatus());
+ verify(invocation, never()).proceed();
+ if (event == null) {
+ authorizationUtils.verifyNoInteractions();
+ } else {
+ authorizationUtils.verify(
+ () ->
+ AuthorizationUtils.checkCurrentUser(
+ eq(METALAKE), eq("tester"),
any(AuthorizationRequestContext.class)));
+ }
+ }
+ }
+
+ private static Response intercept(RunEvent event, GravitinoAuthorizer
authorizer)
+ throws Throwable {
+ MethodInvocation invocation = invocation(event);
+ try (MockedStatic<PrincipalUtils> principalUtils =
mockStatic(PrincipalUtils.class);
+ MockedStatic<AuthorizationUtils> authorizationUtils =
mockStatic(AuthorizationUtils.class);
+ MockedStatic<GravitinoAuthorizerProvider> providerStatic =
+ mockStatic(GravitinoAuthorizerProvider.class)) {
+
principalUtils.when(PrincipalUtils::getCurrentPrincipal).thenReturn(principal());
+
principalUtils.when(PrincipalUtils::getCurrentUserName).thenReturn("tester");
+ GravitinoAuthorizerProvider provider =
mock(GravitinoAuthorizerProvider.class);
+
providerStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+ when(provider.getGravitinoAuthorizer()).thenReturn(authorizer);
+
+ Response response = (Response) lineageInterceptor().invoke(invocation);
+ verify(invocation, never()).proceed();
+ return response;
+ }
+ }
+
+ private static boolean execute(
+ LineageAuthorizationExecutor executor,
+ GravitinoAuthorizer authorizer,
+ UserPrincipal principal)
+ throws Exception {
+ try (MockedStatic<GravitinoAuthorizerProvider> providerStatic =
+ mockStatic(GravitinoAuthorizerProvider.class)) {
+ GravitinoAuthorizerProvider provider =
mock(GravitinoAuthorizerProvider.class);
+
providerStatic.when(GravitinoAuthorizerProvider::getInstance).thenReturn(provider);
+ when(provider.getGravitinoAuthorizer()).thenReturn(authorizer);
+ return PrincipalUtils.doAs(
+ principal, () -> executor.execute(new
AuthorizationRequestContext()));
+ }
+ }
+
+ private static LineageAuthorizationExecutor executor(RunEvent event) throws
Exception {
+ return executor(event, CAN_ACCESS_METADATA);
+ }
+
+ private static LineageAuthorizationExecutor executor(RunEvent event, String
expression)
+ throws Exception {
+ return new LineageAuthorizationExecutor(
+ lineageMethod().getParameters(), new Object[] {event}, expression);
+ }
+
+ private static AuthorizationExecutor createWithFactory(RunEvent event)
throws Exception {
+ Map<Entity.EntityType, NameIdentifier> metadataContext = new HashMap<>();
+ return AuthorizeExecutorFactory.create(
+ CAN_ACCESS_METADATA,
+ AuthorizationRequest.RequestType.LINEAGE,
+ metadataContext,
+ Map.of(),
+ Optional.empty(),
+ lineageMethod().getParameters(),
+ new Object[] {event},
+ "",
+ ExpressionCondition.NEVER);
+ }
+
+ private static Method lineageMethod() throws NoSuchMethodException {
+ return LineageOperations.class.getMethod("postLineage", RunEvent.class);
+ }
+
+ private static MethodInterceptor lineageInterceptor() throws
NoSuchMethodException {
+ return new
GravitinoInterceptionService().getMethodInterceptors(lineageMethod()).get(0);
+ }
+
+ private static MethodInvocation invocation(RunEvent event) throws
NoSuchMethodException {
+ MethodInvocation invocation = mock(MethodInvocation.class);
+ when(invocation.getMethod()).thenReturn(lineageMethod());
+ when(invocation.getArguments()).thenReturn(new Object[] {event});
+ return invocation;
+ }
+
+ private static UserPrincipal principal() {
+ return new UserPrincipal("tester");
+ }
+
+ private static InputDataset input(String name) {
+ return new InputDataset(METALAKE, name, null, null);
+ }
+
+ private static OutputDataset output(String name) {
+ return new OutputDataset(METALAKE, name, null, null);
+ }
+
+ private static InputDataset dataset(String datasetType, String namespace,
String name) {
+ if (datasetType == null) {
+ return new InputDataset(namespace, name, null, null);
+ }
+
+ DatasetFacets facets = new DatasetFacets();
+ DatasetFacet typeFacet = new DatasetFacet(PRODUCER, SCHEMA_URL);
+ typeFacet.getAdditionalProperties().put("datasetType", datasetType);
+ facets.getAdditionalProperties().put("datasetType", typeFacet);
+ return new InputDataset(namespace, name, facets, null);
+ }
+
+ private static RunEvent event() {
+ return event(METALAKE, List.of(input(DATASET_NAME)), List.of());
+ }
+
+ private static RunEvent event(
+ String jobNamespace, List<InputDataset> inputs, List<OutputDataset>
outputs) {
+ return new RunEvent(
+ ZonedDateTime.now(ZoneOffset.UTC),
+ PRODUCER,
+ SCHEMA_URL,
+ EventType.START,
+ new Run(UUID.randomUUID(), null),
+ new Job(jobNamespace, "job", null),
+ inputs,
+ outputs);
+ }
+}