diff --git a/wrangler-proto/src/main/java/io/cdap/wrangler/proto/id/RecipeEntityId.java b/wrangler-proto/src/main/java/io/cdap/wrangler/proto/id/RecipeEntityId.java new file mode 100644 index 000000000..ff1fc9207 --- /dev/null +++ b/wrangler-proto/src/main/java/io/cdap/wrangler/proto/id/RecipeEntityId.java @@ -0,0 +1,27 @@ +/* + * Copyright © 2026 Cask Data, Inc. + * + * Licensed 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 io.cdap.wrangler.proto.id; + +import io.cdap.cdap.proto.id.SystemAppEntityId; + +/** + * Uniquely identifies a dataprep.recipe entity + */ +public class RecipeEntityId extends SystemAppEntityId { + public RecipeEntityId(String namespace, String recipeId) { + super(namespace, "dataprep", "recipe", recipeId); + } +} diff --git a/wrangler-service/src/main/java/io/cdap/wrangler/service/directive/DirectivesHandler.java b/wrangler-service/src/main/java/io/cdap/wrangler/service/directive/DirectivesHandler.java index dbd3c3a64..1fdb2ae3b 100644 --- a/wrangler-service/src/main/java/io/cdap/wrangler/service/directive/DirectivesHandler.java +++ b/wrangler-service/src/main/java/io/cdap/wrangler/service/directive/DirectivesHandler.java @@ -31,8 +31,15 @@ import io.cdap.cdap.api.service.http.HttpServiceRequest; import io.cdap.cdap.api.service.http.HttpServiceResponder; import io.cdap.cdap.api.service.http.SystemHttpServiceContext; +import io.cdap.cdap.features.Feature; import io.cdap.cdap.internal.io.SchemaTypeAdapter; +import io.cdap.cdap.proto.element.EntityType; +import io.cdap.cdap.proto.id.NamespaceId; +import io.cdap.cdap.proto.id.SystemAppEntityId; +import io.cdap.cdap.proto.security.StandardPermission; +import io.cdap.cdap.security.spi.authorization.ContextAccessEnforcer; import io.cdap.cdap.spi.data.transaction.TransactionRunners; +import io.cdap.wrangler.proto.id.WorkspaceEntityId; import io.cdap.wrangler.PropertyIds; import io.cdap.wrangler.RequestExtractor; import io.cdap.wrangler.SamplingMethod; @@ -115,6 +122,8 @@ public class DirectivesHandler extends AbstractDirectiveHandler { private static final String DATA_MODEL_MODEL_PROPERTY = "dataModelModel"; private DirectiveRegistry composite; + private ContextAccessEnforcer contextAccessEnforcer; + private boolean authEnforcementEnabled; @Override public void initialize(SystemHttpServiceContext context) throws Exception { @@ -123,6 +132,8 @@ public void initialize(SystemHttpServiceContext context) throws Exception { SystemDirectiveRegistry.INSTANCE, new UserDirectiveRegistry(context) ); + contextAccessEnforcer = context.getContextAccessEnforcer(); + authEnforcementEnabled = Feature.WRANGLER_DIRECTIVE_RECIPE_AUTH_CHECK.isEnabled(context); } /** @@ -168,6 +179,7 @@ public void create(HttpServiceRequest request, HttpServiceResponder responder, @ @PathParam("id") String id, @QueryParam("name") String name, @QueryParam("scope") @DefaultValue(WorkspaceDataset.DEFAULT_SCOPE) String scope) { respond(request, responder, namespace, ns -> { + enforceWorkspace(ns.getName(), id, StandardPermission.CREATE); String workspaceName = name == null || name.isEmpty() ? id : name; Map properties = new HashMap<>(); @@ -211,6 +223,7 @@ public void create(HttpServiceRequest request, HttpServiceResponder responder, @ public void list(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace, @QueryParam("scope") @DefaultValue("default") String scope) { respond(request, responder, namespace, ns -> { + enforceNamespaceParent(ns.getName(), StandardPermission.LIST); List workspaces = TransactionRunners.run(getContext(), context -> { WorkspaceDataset ws = WorkspaceDataset.get(context); return ws.listWorkspaces(ns, scope); @@ -239,6 +252,7 @@ public void list(HttpServiceRequest request, HttpServiceResponder responder, public void delete(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace, @PathParam("id") String id) { respond(request, responder, namespace, ns -> { + enforceWorkspace(ns.getName(), id, StandardPermission.DELETE); TransactionRunners.run(getContext(), context -> { WorkspaceDataset ws = WorkspaceDataset.get(context); ws.deleteWorkspace(new NamespacedId(ns, id)); @@ -267,6 +281,7 @@ public void delete(HttpServiceRequest request, HttpServiceResponder responder, public void deleteGroup(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace, @QueryParam("group") String group) { respond(request, responder, namespace, ns -> { + enforceNamespaceParent(ns.getName(), StandardPermission.DELETE); TransactionRunners.run(getContext(), context -> { WorkspaceDataset ws = WorkspaceDataset.get(context); ws.deleteScope(ns, group); @@ -310,6 +325,7 @@ public void deleteGroup(HttpServiceRequest request, HttpServiceResponder respond public void get(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace, @PathParam("id") String id) { respond(request, responder, namespace, ns -> { + enforceWorkspace(ns.getName(), id, StandardPermission.GET); Workspace workspace = getWorkspace(new NamespacedId(ns, id)); String name = workspace.getName(); Request workspaceReq = workspace.getRequest(); @@ -367,6 +383,7 @@ public void upload(HttpServiceRequest request, HttpServiceResponder responder, throw new BadRequestException("Name must be provided in the 'file' header"); } NamespacedId id = new NamespacedId(ns, ServiceUtils.generateMD5(name)); + enforceWorkspace(ns.getName(), id.getId(), StandardPermission.CREATE); return TransactionRunners.run(getContext(), context -> { // if workspace doesn't exist, then we create the workspace before @@ -456,6 +473,7 @@ public void upload(HttpServiceRequest request, HttpServiceResponder responder, public void uploadData(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace, @PathParam("id") String id) { respond(request, responder, namespace, ns -> { + enforceWorkspace(ns.getName(), id, StandardPermission.USE); RequestExtractor handler = new RequestExtractor(request); // Extract charset, if not specified, default it to UTF-8. @@ -547,6 +565,7 @@ public void uploadData(HttpServiceRequest request, HttpServiceResponder responde public void execute(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace, @PathParam("id") String id) { respond(request, responder, namespace, ns -> { + enforceWorkspace(ns.getName(), id, StandardPermission.USE); composite.reload(namespace); try { RequestExtractor handler = new RequestExtractor(request); @@ -598,6 +617,7 @@ public void execute(HttpServiceRequest request, HttpServiceResponder responder, public void summary(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace, @PathParam("id") String id) { respond(request, responder, namespace, ns -> { + enforceWorkspace(ns.getName(), id, StandardPermission.GET); try { composite.reload(namespace); RequestExtractor handler = new RequestExtractor(request); @@ -628,6 +648,7 @@ public void summary(HttpServiceRequest request, HttpServiceResponder responder, public void schema(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace, @PathParam("id") String id) { respond(request, responder, namespace, ns -> { + enforceWorkspace(ns.getName(), id, StandardPermission.USE); composite.reload(namespace); RequestExtractor handler = new RequestExtractor(request); Request user = handler.getContent("UTF-8", Request.class); @@ -666,12 +687,12 @@ public void schema(HttpServiceRequest request, HttpServiceResponder responder, * @param request Handler for incoming request. * @param responder Responder for data going out. * @param id Workspace to associate a data model. - */ - @POST + */ @POST @Path("contexts/{context}/workspaces/{id}/datamodels") public void addDataModel(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace, @PathParam("id") String id) { respond(request, responder, namespace, ns -> { + enforceWorkspace(ns.getName(), id, StandardPermission.UPDATE); NamespacedId namespacedId = new NamespacedId(ns, id); Workspace workspace = getWorkspace(namespacedId); RequestExtractor handler = new RequestExtractor(request); @@ -720,6 +741,7 @@ public void addDataModel(HttpServiceRequest request, HttpServiceResponder respon public void removeDataModel(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace, @PathParam("id") String id) { respond(request, responder, namespace, ns -> { + enforceWorkspace(ns.getName(), id, StandardPermission.UPDATE); NamespacedId namespacedId = new NamespacedId(ns, id); Workspace workspace = getWorkspace(namespacedId); Map properties = new HashMap<>(workspace.getProperties()); @@ -748,6 +770,7 @@ public void removeDataModel(HttpServiceRequest request, HttpServiceResponder res public void addModels(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace, @PathParam("id") String id) { respond(request, responder, namespace, ns -> { + enforceWorkspace(ns.getName(), id, StandardPermission.UPDATE); NamespacedId namespacedId = new NamespacedId(ns, id); Workspace workspace = getWorkspace(namespacedId); RequestExtractor handler = new RequestExtractor(request); @@ -805,9 +828,10 @@ public void addModels(HttpServiceRequest request, HttpServiceResponder responder @Path("contexts/{context}/workspaces/{id}/models/{modelid}") @TransactionPolicy(value = TransactionControl.EXPLICIT) public void removeModels(HttpServiceRequest request, HttpServiceResponder responder, - @PathParam("context") String namespace, @PathParam("id") String id, - @PathParam("modelid") String modelId) { + @PathParam("context") String namespace, @PathParam("id") String id, + @PathParam("modelid") String modelId) { respond(request, responder, namespace, ns -> { + enforceWorkspace(ns.getName(), id, StandardPermission.UPDATE); NamespacedId namespacedId = new NamespacedId(ns, id); Workspace workspace = getWorkspace(namespacedId); Map properties = new HashMap<>(workspace.getProperties()); @@ -837,7 +861,10 @@ public void removeModels(HttpServiceRequest request, HttpServiceResponder respon @Path("info") @TransactionPolicy(value = TransactionControl.EXPLICIT) public void capabilities(HttpServiceRequest request, HttpServiceResponder responder) { - respond(request, responder, () -> new ServiceResponse<>(ProjectInfo.getProperties())); + respond(request, responder, () -> { + enforceSystemApp("dataprep", "service", "info", StandardPermission.GET); + return new ServiceResponse<>(ProjectInfo.getProperties()); + }); } /** @@ -908,6 +935,7 @@ public void usage(HttpServiceRequest request, HttpServiceResponder responder, public void artifacts(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace) { respond(request, responder, namespace, ns -> { + enforceNamespaceParent(ns.getName(), StandardPermission.LIST); List values = new ArrayList<>(); List artifacts = getContext().listArtifacts(namespace); for (ArtifactInfo artifact : artifacts) { @@ -935,6 +963,7 @@ public void artifacts(HttpServiceRequest request, HttpServiceResponder responder public void directives(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace) { respond(request, responder, namespace, ns -> { + enforceNamespaceParent(ns.getName(), StandardPermission.LIST); List values = new ArrayList<>(); List artifacts = getContext().listArtifacts(namespace); for (ArtifactInfo artifact : artifacts) { @@ -962,6 +991,7 @@ public void directives(HttpServiceRequest request, HttpServiceResponder responde public void directivesReload(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace) { respond(request, responder, namespace, ns -> { + enforceNamespaceParent(ns.getName(), StandardPermission.UPDATE); composite.reload(namespace); return new ServiceResponse("Successfully reloaded all user defined directives."); }); @@ -977,7 +1007,10 @@ public void directivesReload(HttpServiceRequest request, HttpServiceResponder re @Path("charsets") @TransactionPolicy(value = TransactionControl.EXPLICIT) public void charsets(HttpServiceRequest request, HttpServiceResponder responder) { - respond(request, responder, () -> new ServiceResponse<>(Charset.availableCharsets().keySet())); + respond(request, responder, () -> { + enforceSystemApp("dataprep", "service", "charsets", StandardPermission.GET); + return new ServiceResponse<>(Charset.availableCharsets().keySet()); + }); } /** @@ -991,6 +1024,7 @@ public void charsets(HttpServiceRequest request, HttpServiceResponder responder) @TransactionPolicy(value = TransactionControl.EXPLICIT) public void uploadConfig(HttpServiceRequest request, HttpServiceResponder responder) { respond(request, responder, () -> { + enforceSystemApp("dataprep", "service", "config", StandardPermission.UPDATE); // Read the request body RequestExtractor handler = new RequestExtractor(request); DirectiveConfig config = handler.getContent("UTF-8", DirectiveConfig.class); @@ -1012,7 +1046,10 @@ public void uploadConfig(HttpServiceRequest request, HttpServiceResponder respon @Path("config") @TransactionPolicy(value = TransactionControl.EXPLICIT) public void getConfig(HttpServiceRequest request, HttpServiceResponder responder) { - respond(request, responder, () -> new ServiceResponse<>(configStore.getConfig())); + respond(request, responder, () -> { + enforceSystemApp("dataprep", "service", "config", StandardPermission.GET); + return new ServiceResponse<>(configStore.getConfig()); + }); } /** @@ -1089,4 +1126,22 @@ private List executeDirectives(NamespacedId id, List< grammarVisitor); }); } + + private void enforceWorkspace(String namespace, String workspaceId, StandardPermission permission) { + if (authEnforcementEnabled) { + contextAccessEnforcer.enforce(new WorkspaceEntityId(namespace, workspaceId), permission); + } + } + + private void enforceNamespaceParent(String namespace, StandardPermission permission) { + if (authEnforcementEnabled) { + contextAccessEnforcer.enforceOnParent(EntityType.SYSTEM_APP_ENTITY, new NamespaceId(namespace), permission); + } + } + + private void enforceSystemApp(String appName, String type, String name, StandardPermission permission) { + if (authEnforcementEnabled) { + contextAccessEnforcer.enforce(new SystemAppEntityId(NamespaceId.SYSTEM.getNamespace(), appName, type, name), permission); + } + } } diff --git a/wrangler-service/src/main/java/io/cdap/wrangler/service/directive/RecipeHandler.java b/wrangler-service/src/main/java/io/cdap/wrangler/service/directive/RecipeHandler.java index 584e803ab..0d4ff6bc1 100644 --- a/wrangler-service/src/main/java/io/cdap/wrangler/service/directive/RecipeHandler.java +++ b/wrangler-service/src/main/java/io/cdap/wrangler/service/directive/RecipeHandler.java @@ -25,10 +25,16 @@ import io.cdap.cdap.api.service.http.HttpServiceRequest; import io.cdap.cdap.api.service.http.HttpServiceResponder; import io.cdap.cdap.api.service.http.SystemHttpServiceContext; +import io.cdap.cdap.features.Feature; import io.cdap.cdap.internal.io.SchemaTypeAdapter; +import io.cdap.cdap.proto.element.EntityType; +import io.cdap.cdap.proto.id.NamespaceId; +import io.cdap.cdap.proto.security.StandardPermission; +import io.cdap.cdap.security.spi.authorization.ContextAccessEnforcer; import io.cdap.wrangler.dataset.recipe.RecipePageRequest; import io.cdap.wrangler.dataset.recipe.RecipeRow; import io.cdap.wrangler.proto.BadRequestException; +import io.cdap.wrangler.proto.id.RecipeEntityId; import io.cdap.wrangler.proto.recipe.v2.Recipe; import io.cdap.wrangler.proto.recipe.v2.RecipeCreationRequest; import io.cdap.wrangler.proto.recipe.v2.RecipeId; @@ -56,6 +62,8 @@ public class RecipeHandler extends AbstractWranglerHandler { private static final Pattern RECIPE_NAME_PATTERN = Pattern.compile("[a-zA-Z0-9 ]*"); private RecipeStore recipeStore; + private ContextAccessEnforcer contextAccessEnforcer; + private boolean authEnforcementEnabled; // Injected by CDAP @SuppressWarnings("unused") @@ -67,6 +75,8 @@ public class RecipeHandler extends AbstractWranglerHandler { public void initialize(SystemHttpServiceContext context) throws Exception { super.initialize(context); recipeStore = new RecipeStore(context); + contextAccessEnforcer = context.getContextAccessEnforcer(); + authEnforcementEnabled = Feature.WRANGLER_DIRECTIVE_RECIPE_AUTH_CHECK.isEnabled(context); } @POST @@ -76,6 +86,7 @@ public void createRecipe(HttpServiceRequest request, HttpServiceResponder respon @PathParam("context") String namespace) { respond(responder, namespace, ns -> { RecipeId recipeId = RecipeId.builder(ns).build(); + enforceRecipe(recipeId.getNamespace().getName(), recipeId.getRecipeId(), StandardPermission.CREATE); Recipe recipe = buildRecipeFromRequest(request, recipeId); recipeStore.createRecipe(recipeId, RecipeRow.builder(recipe).build()); metrics.gauge(NUMBER_RECIPE_SAVED_METRIC, recipe.getDirectives().size()); @@ -91,6 +102,7 @@ public void getRecipeById(HttpServiceRequest request, HttpServiceResponder respo @PathParam("recipe-id") String recipeId) { respond(responder, namespace, ns -> { RecipeId id = RecipeId.builder(ns).setRecipeId(recipeId).build(); + enforceRecipe(id.getNamespace().getName(), id.getRecipeId(), StandardPermission.GET); responder.sendJson(recipeStore.getRecipeById(id)); }); } @@ -102,7 +114,9 @@ public void getRecipeByName(HttpServiceRequest request, HttpServiceResponder res @PathParam("context") String namespace, @PathParam("recipe-name") String recipeName) { respond(responder, namespace, ns -> { - responder.sendJson(recipeStore.getRecipeByName(ns, recipeName)); + enforceRecipe(ns.getName(), recipeName, StandardPermission.GET); + Recipe recipe = recipeStore.getRecipeByName(ns, recipeName); + responder.sendJson(recipe); }); } @@ -110,12 +124,16 @@ public void getRecipeByName(HttpServiceRequest request, HttpServiceResponder res @TransactionPolicy(value = TransactionControl.EXPLICIT) @Path("v2/contexts/{context}/recipes") public void listRecipes(HttpServiceRequest request, HttpServiceResponder responder, - @PathParam("context") String namespace, - @QueryParam("pageSize") Integer pageSize, - @QueryParam("pageToken") String pageToken, - @QueryParam("sortBy")String sortBy, - @QueryParam("sortOrder") String sortOrder) { + @PathParam("context") String namespace, + @QueryParam("pageSize") Integer pageSize, + @QueryParam("pageToken") String pageToken, + @QueryParam("sortBy")String sortBy, + @QueryParam("sortOrder") String sortOrder) { respond(responder, namespace, ns -> { + if (authEnforcementEnabled) { + contextAccessEnforcer.enforceOnParent(EntityType.SYSTEM_APP_ENTITY, new NamespaceId(ns.getName()), + StandardPermission.LIST); + } RecipePageRequest pageRequest = RecipePageRequest.builder(ns) .setPageSize(pageSize) .setPageToken(pageToken) @@ -133,7 +151,9 @@ public void deleteRecipe(HttpServiceRequest request, HttpServiceResponder respon @PathParam("context") String namespace, @PathParam("recipe-id") String recipeId) { respond(responder, namespace, ns-> { - recipeStore.deleteRecipe(RecipeId.builder(ns).setRecipeId(recipeId).build()); + RecipeId id = RecipeId.builder(ns).setRecipeId(recipeId).build(); + enforceRecipe(id.getNamespace().getName(), id.getRecipeId(), StandardPermission.DELETE); + recipeStore.deleteRecipe(id); responder.sendStatus(HttpURLConnection.HTTP_OK); }); } @@ -146,12 +166,19 @@ public void updateRecipe(HttpServiceRequest request, HttpServiceResponder respon @PathParam("recipe-id") String recipeIdString) { respond(responder, namespace, ns-> { RecipeId recipeId = RecipeId.builder(ns).setRecipeId(recipeIdString).build(); + enforceRecipe(recipeId.getNamespace().getName(), recipeId.getRecipeId(), StandardPermission.UPDATE); Recipe recipe = buildRecipeFromRequest(request, recipeId); recipeStore.updateRecipe(recipeId, RecipeRow.builder(recipe).build()); responder.sendStatus(HttpURLConnection.HTTP_OK); }); } + private void enforceRecipe(String namespace, String recipeId, StandardPermission permission) { + if (authEnforcementEnabled) { + contextAccessEnforcer.enforce(new RecipeEntityId(namespace, recipeId), permission); + } + } + private void validateRecipeRequest(Recipe recipe) { if (recipe == null) { throw new BadRequestException("Request is empty");