yuqi1129 commented on code in PR #12850:
URL: https://github.com/apache/gravitino/pull/12850#discussion_r3924722588
##########
lineage/src/main/java/org/apache/gravitino/lineage/source/rest/LineageOperations.java:
##########
@@ -53,16 +56,25 @@ public LineageOperations(LineageDispatcher
lineageDispatcher) {
@Produces(MediaType.APPLICATION_JSON)
Review Comment:
**Blocking, please verify before merge.** With this `@Produces`, I could not
reach this method at all. I ran `POST /api/lineage` against a real server on
this branch and on `main`, with three different `Accept` values, and every
single one came back **406**:
```
[PR] Accept: application/vnd.gravitino.v1+json -> 406
[MAIN] Accept: application/vnd.gravitino.v1+json -> 406
[MAIN] Accept: application/json -> 406 {"message":"Not
Acceptable","url":"/api/lineage"}
[MAIN] Accept: */* -> 406
```
`VersioningFilter` is registered on `/api/*` (`GravitinoServer.java:203`).
When the request carries no Gravitino version it **replaces** `Accept` with
`application/vnd.gravitino.v1+json` (`MutableHttpServletRequest.putHeader`
overwrites, it does not append). This method produces plain `application/json`,
so the two never match. Every other REST resource in the repo declares
`@Produces("application/vnd.gravitino.v1+json")`, this one does not.
The 406 comes from Jersey's method matching, before the resource method
runs, so the hk2 interceptor never fires either, which means the whole
authorization path this PR adds is unreachable as the server is currently
wired. It returns 406 rather than 404, so registration itself is fine, it is
purely content negotiation.
This is pre-existing, not introduced here, but two things follow:
1. It is one line in a file this PR already touches, so I would fix
`@Produces` here and add an integration test, otherwise nothing proves this fix
works.
2. I could not reproduce #12840's stated repro (both requests returning `201
Created`). Could you confirm how you reached 201? If the issue's premise does
not hold, the scope may need revisiting.
##########
lineage/src/main/java/org/apache/gravitino/lineage/source/rest/LineageOperations.java:
##########
@@ -53,16 +56,25 @@ public LineageOperations(LineageDispatcher
lineageDispatcher) {
@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)
Review Comment:
`CAN_ACCESS_METADATA` resolves to `LOAD_TABLE` for a table, i.e. SELECT or
MODIFY or owner. So a user holding only SELECT can list a table under
`outputs`, that is, assert "this job wrote this table".
The description calls this out as intentional ("metadata-visibility
permissions"), but it does not explain the asymmetry. Lineage feeds impact
analysis and governance, so a write-claim is arguably not the same as a read.
Worth either requiring MODIFY-level rights for `outputs`, or writing down why
visibility is enough.
##########
server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LineageAuthorizationExecutor.java:
##########
@@ -0,0 +1,179 @@
+/*
+ * 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.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();
+ resolveAuthorizationTargets(event);
+ return Optional.of(event.getJob().getNamespace());
+ }
+
+ @Override
+ public boolean execute(AuthorizationRequestContext context) {
+ if (!authorizationTargetsResolved) {
+ resolveAuthorizationTargets(extractRunEvent());
+ }
+
+ AuthorizationExpressionEvaluator evaluator = new
AuthorizationExpressionEvaluator(expression);
+ context.setOriginalAuthorizationExpression(expression);
+
+ 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", "MODEL_VERSION" -> MetadataObject.Type.MODEL;
Review Comment:
`MODEL_VERSION` maps to `MetadataObject.Type.MODEL`, whose full name is
three parts (`catalog.schema.model`), while a model version identifier
naturally has four. So `MetadataObjects.parse` below will reject any name a
producer would actually emit for a model version, and the caller gets a 400
they cannot act on.
Either drop `MODEL_VERSION` from this switch, or handle the four-part form
explicitly.
Separately: `datasetType` is not a standard OpenLineage facet, and reading
it as `facets["datasetType"].additionalProperties["datasetType"]` is a
Gravitino-specific convention. Documenting it in the OpenAPI spec is the right
move, it just means producers must be taught to emit it before anything other
than `TABLE` can be authorized.
##########
server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LineageAuthorizationExecutor.java:
##########
@@ -0,0 +1,179 @@
+/*
+ * 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.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() {
Review Comment:
Small readability point: the name says "get metalake", but this also
validates the event and resolves every authorization target as a side effect,
and it is the first thing the interceptor calls. Doing the resolution lazily in
`execute()` and returning `event.getJob().getNamespace()` directly here would
make the contract match the name.
##########
server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java:
##########
@@ -267,6 +256,65 @@ public Object invoke(MethodInvocation methodInvocation)
throws Throwable {
}
}
+ private Optional<Response> validateCurrentUserAndActiveRoles(
+ NameIdentifier metalakeIdent,
+ AuthorizationRequestContext authorizationRequestContext,
+ AuthorizationExpression expressionAnnotation,
+ Map<Entity.EntityType, NameIdentifier> metadataContext,
+ Method method,
+ String expression) {
+ String currentUser = PrincipalUtils.getCurrentUserName();
+ try {
+ AuthorizationUtils.checkCurrentUser(
+ metalakeIdent.name(), currentUser, authorizationRequestContext);
+ } catch (NoSuchMetalakeException e) {
Review Comment:
For lineage this branch is the most likely real-world failure, and its
output is not usable.
When `job.namespace` is not a metalake, `checkCurrentUser` throws
`NoSuchMetalakeException` and we land here, returning a **403**. The lineage
annotation leaves `accessMetadataType` at its default `METALAKE`, and
`metadataContext` is empty for that endpoint, so `accessMetadataName` is null
and the message renders as `User 'x' is not authorized to perform operation
'postLineage' ` with a trailing space and no indication of which namespace was
wrong.
Since this PR redefines `job.namespace` to mean "metalake", getting it wrong
will be the number one support question. It should be a 400 that names the
field, not an anonymous 403.
##########
server/src/main/java/org/apache/gravitino/server/web/filter/authorization/LineageAuthorizationExecutor.java:
##########
@@ -0,0 +1,179 @@
+/*
+ * 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.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();
+ resolveAuthorizationTargets(event);
+ return Optional.of(event.getJob().getNamespace());
+ }
+
+ @Override
+ public boolean execute(AuthorizationRequestContext context) {
+ if (!authorizationTargetsResolved) {
+ resolveAuthorizationTargets(extractRunEvent());
+ }
+
+ AuthorizationExpressionEvaluator evaluator = new
AuthorizationExpressionEvaluator(expression);
+ context.setOriginalAuthorizationExpression(expression);
+
+ for (AuthorizationTarget target : authorizationTargets) {
+ if (!evaluator.evaluate(
+ target.metadataContext, Map.of(), context,
Optional.of(target.entityType.name()))) {
+ return false;
+ }
+ }
+ return true;
Review Comment:
When the event carries no inputs and no outputs, `authorizationTargets` is
empty, the loop body never runs, and this returns `true`. So a well-formed but
dataset-less event is dispatched to the sinks with **no privilege check at
all**, the only gate left is `checkCurrentUser`, i.e. "are you a member of this
metalake".
The `{}` case from the issue is now caught by validation, but this one is
not. Whether that is acceptable is a product call, but it should be explicit
and covered by a test, right now nothing pins it.
##########
server/src/main/java/org/apache/gravitino/server/web/filter/GravitinoInterceptionService.java:
##########
@@ -238,6 +193,40 @@ public Object invoke(MethodInvocation methodInvocation)
throws Throwable {
secondaryExpression,
secondaryExpressionCondition,
expressionAnnotation.allowCheckExistence());
+ try {
+ Optional<String> 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()));
+ }
+ if (dynamicMetalake.isPresent()) {
+ authorizationMetalake = dynamicMetalake;
+ }
+ } catch (IllegalArgumentException exception) {
+ LOG.warn("Invalid authorization request", exception);
+ return Utils.illegalArguments(exception.getMessage(), exception);
+ }
+ }
+
+ if (authorizationMetalake.isPresent()) {
+ Optional<Response> validationFailure =
+ validateCurrentUserAndActiveRoles(
Review Comment:
This moves user and active-role validation from **before** executor
construction to **after** it, and that affects every endpoint, not only lineage.
Executor construction is not side-effect free:
`CreateSchemaAuthorizationExecutor` calls `injectParentSchema` in its
constructor, and `LoadTableAuthorizationExecutor` does `(String)
ParameterUtil.extractFromParameters(...)`. I went through both and they are
defensive enough that I could not build a failing case today, so this is not a
live bug. But the invariant "authenticate and authorize before parsing the
request body" is gone for all endpoints, and a caller who is not a member of
the metalake can now receive a 400/500 shaped by the request body where they
previously got a 403.
Minimal fix that keeps the new capability: validate the **path** metalake
where it was before, and only run the validation for a **dynamically resolved**
metalake after the executor is built. Existing endpoints then keep their exact
ordering.
##########
docs/open-api/lineage.yaml:
##########
@@ -21,21 +21,34 @@ 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`, `MODEL_VERSION`, and
`TOPIC`.
+ Inputs and outputs both require metadata visibility. Unsupported or
external
+ dataset identifiers are rejected when authorization is enabled. When
authorization
+ is disabled, generic OpenLineage namespaces remain supported.
Review Comment:
This paragraph is the real design decision in the PR and I think it deserves
more than a doc note.
Standard OpenLineage producers (the Spark, Airflow and dbt integrations)
emit `dataset.namespace` as a data source URI such as `hive://host:9083` or
`postgres://...`, and `job.namespace` as the scheduler namespace. Under this
contract, turning authorization on converts all of those into 400/403. The
sentence "When authorization is disabled, generic OpenLineage namespaces remain
supported" says exactly that: the same deployment speaks two different wire
protocols depending on a flag that looks unrelated, and the failure mode is
lineage silently stopping.
Options worth weighing, roughly in increasing cost:
- make the strict identifier contract its own opt-in config instead of
something `enableAuthorization` implies, so operators see what they are
switching;
- add a pluggable dataset-identifier resolver so a deployment can map its
producers' naming onto Gravitino identifiers;
- authorize only the datasets that resolve to a Gravitino object and pass
the rest through, which I would not recommend since it is fail-open.
I would do the first here and leave the second as follow-up. Whichever you
pick, the trade-off belongs in the PR description, today it is invisible to
whoever flips the flag.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]