diff --git a/wrangler-proto/src/main/java/io/cdap/wrangler/proto/id/DirectiveConfigEntityId.java b/wrangler-proto/src/main/java/io/cdap/wrangler/proto/id/DirectiveConfigEntityId.java new file mode 100644 index 000000000..5a9c34cb7 --- /dev/null +++ b/wrangler-proto/src/main/java/io/cdap/wrangler/proto/id/DirectiveConfigEntityId.java @@ -0,0 +1,29 @@ +/* + * 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.NamespaceId; +import io.cdap.cdap.proto.id.SystemAppEntityId; + +/** + * Uniquely identifies the system-level dataprep directive config entity. + */ +public class DirectiveConfigEntityId extends SystemAppEntityId { + + public DirectiveConfigEntityId() { + super(NamespaceId.SYSTEM.getNamespace(), "dataprep", "config", "directives"); + } +} 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..78ab70d3a 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,7 +31,12 @@ 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.cdap.spi.data.transaction.TransactionRunners; import io.cdap.wrangler.PropertyIds; import io.cdap.wrangler.RequestExtractor; @@ -42,7 +47,6 @@ import io.cdap.wrangler.api.DirectiveParseException; import io.cdap.wrangler.api.Row; import io.cdap.wrangler.datamodel.DataModelGlossary; -import io.cdap.wrangler.dataset.workspace.ConfigStore; import io.cdap.wrangler.dataset.workspace.DataType; import io.cdap.wrangler.dataset.workspace.Workspace; import io.cdap.wrangler.dataset.workspace.WorkspaceDataset; @@ -56,6 +60,8 @@ import io.cdap.wrangler.proto.ServiceResponse; import io.cdap.wrangler.proto.WorkspaceIdentifier; import io.cdap.wrangler.proto.connection.ConnectionType; +import io.cdap.wrangler.proto.id.DirectiveConfigEntityId; +import io.cdap.wrangler.proto.id.WorkspaceEntityId; import io.cdap.wrangler.proto.workspace.DataModelInfo; import io.cdap.wrangler.proto.workspace.DirectiveArtifact; import io.cdap.wrangler.proto.workspace.DirectiveDescriptor; @@ -115,6 +121,9 @@ public class DirectivesHandler extends AbstractDirectiveHandler { private static final String DATA_MODEL_MODEL_PROPERTY = "dataModelModel"; private DirectiveRegistry composite; + private ContextAccessEnforcer contextAccessEnforcer; + private boolean isWorkspaceAuthEnforcementEnabled; + private boolean isDirectiveConfigAuthEnforcementEnabled; @Override public void initialize(SystemHttpServiceContext context) throws Exception { @@ -123,6 +132,9 @@ public void initialize(SystemHttpServiceContext context) throws Exception { SystemDirectiveRegistry.INSTANCE, new UserDirectiveRegistry(context) ); + contextAccessEnforcer = context.getContextAccessEnforcer(); + isWorkspaceAuthEnforcementEnabled = Feature.WRANGLER_WORKSPACE_AUTH_CHECK.isEnabled(context); + isDirectiveConfigAuthEnforcementEnabled = Feature.WRANGLER_DIRECTIVE_CONFIG_AUTH_CHECK.isEnabled(context); } /** @@ -168,6 +180,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 -> { + enforceWorkspacePermission(ns.getName(), id, StandardPermission.CREATE); String workspaceName = name == null || name.isEmpty() ? id : name; Map properties = new HashMap<>(); @@ -211,6 +224,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 -> { + enforceOnParentNamespace(ns.getName(), StandardPermission.LIST); List workspaces = TransactionRunners.run(getContext(), context -> { WorkspaceDataset ws = WorkspaceDataset.get(context); return ws.listWorkspaces(ns, scope); @@ -239,6 +253,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 -> { + enforceWorkspacePermission(ns.getName(), id, StandardPermission.DELETE); TransactionRunners.run(getContext(), context -> { WorkspaceDataset ws = WorkspaceDataset.get(context); ws.deleteWorkspace(new NamespacedId(ns, id)); @@ -267,6 +282,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 -> { + enforceNamespacePermission(ns.getName(), StandardPermission.DELETE); TransactionRunners.run(getContext(), context -> { WorkspaceDataset ws = WorkspaceDataset.get(context); ws.deleteScope(ns, group); @@ -310,6 +326,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 -> { + enforceWorkspacePermission(ns.getName(), id, StandardPermission.GET); Workspace workspace = getWorkspace(new NamespacedId(ns, id)); String name = workspace.getName(); Request workspaceReq = workspace.getRequest(); @@ -367,6 +384,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)); + enforceWorkspacePermission(ns.getName(), id.getId(), StandardPermission.CREATE); return TransactionRunners.run(getContext(), context -> { // if workspace doesn't exist, then we create the workspace before @@ -456,6 +474,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 -> { + enforceWorkspacePermission(ns.getName(), id, StandardPermission.USE); RequestExtractor handler = new RequestExtractor(request); // Extract charset, if not specified, default it to UTF-8. @@ -547,6 +566,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 -> { + enforceWorkspacePermission(ns.getName(), id, StandardPermission.USE); composite.reload(namespace); try { RequestExtractor handler = new RequestExtractor(request); @@ -598,6 +618,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 -> { + enforceWorkspacePermission(ns.getName(), id, StandardPermission.USE); try { composite.reload(namespace); RequestExtractor handler = new RequestExtractor(request); @@ -628,6 +649,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 -> { + enforceWorkspacePermission(ns.getName(), id, StandardPermission.USE); composite.reload(namespace); RequestExtractor handler = new RequestExtractor(request); Request user = handler.getContent("UTF-8", Request.class); @@ -672,6 +694,7 @@ public void schema(HttpServiceRequest request, HttpServiceResponder responder, public void addDataModel(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace, @PathParam("id") String id) { respond(request, responder, namespace, ns -> { + enforceWorkspacePermission(ns.getName(), id, StandardPermission.UPDATE); NamespacedId namespacedId = new NamespacedId(ns, id); Workspace workspace = getWorkspace(namespacedId); RequestExtractor handler = new RequestExtractor(request); @@ -720,6 +743,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 -> { + enforceWorkspacePermission(ns.getName(), id, StandardPermission.UPDATE); NamespacedId namespacedId = new NamespacedId(ns, id); Workspace workspace = getWorkspace(namespacedId); Map properties = new HashMap<>(workspace.getProperties()); @@ -748,6 +772,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 -> { + enforceWorkspacePermission(ns.getName(), id, StandardPermission.UPDATE); NamespacedId namespacedId = new NamespacedId(ns, id); Workspace workspace = getWorkspace(namespacedId); RequestExtractor handler = new RequestExtractor(request); @@ -808,6 +833,7 @@ public void removeModels(HttpServiceRequest request, HttpServiceResponder respon @PathParam("context") String namespace, @PathParam("id") String id, @PathParam("modelid") String modelId) { respond(request, responder, namespace, ns -> { + enforceWorkspacePermission(ns.getName(), id, StandardPermission.UPDATE); NamespacedId namespacedId = new NamespacedId(ns, id); Workspace workspace = getWorkspace(namespacedId); Map properties = new HashMap<>(workspace.getProperties()); @@ -837,7 +863,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, () -> { + enforceNamespacePermission(NamespaceId.SYSTEM.getNamespace(), StandardPermission.GET); + return new ServiceResponse<>(ProjectInfo.getProperties()); + }); } /** @@ -866,6 +895,7 @@ public void capabilities(HttpServiceRequest request, HttpServiceResponder respon public void usage(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace) { respond(request, responder, namespace, ns -> { + enforceNamespacePermission(namespace, StandardPermission.USE); // CDAP-15397 - reload must be called before it can be safely used composite.reload(namespace); DirectiveConfig config = configStore.getConfig(); @@ -908,6 +938,7 @@ public void usage(HttpServiceRequest request, HttpServiceResponder responder, public void artifacts(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace) { respond(request, responder, namespace, ns -> { + enforceOnParentNamespace(ns.getName(), StandardPermission.LIST); List values = new ArrayList<>(); List artifacts = getContext().listArtifacts(namespace); for (ArtifactInfo artifact : artifacts) { @@ -935,6 +966,7 @@ public void artifacts(HttpServiceRequest request, HttpServiceResponder responder public void directives(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace) { respond(request, responder, namespace, ns -> { + enforceOnParentNamespace(ns.getName(), StandardPermission.LIST); List values = new ArrayList<>(); List artifacts = getContext().listArtifacts(namespace); for (ArtifactInfo artifact : artifacts) { @@ -962,6 +994,7 @@ public void directives(HttpServiceRequest request, HttpServiceResponder responde public void directivesReload(HttpServiceRequest request, HttpServiceResponder responder, @PathParam("context") String namespace) { respond(request, responder, namespace, ns -> { + enforceNamespacePermission(ns.getName(), StandardPermission.USE); composite.reload(namespace); return new ServiceResponse("Successfully reloaded all user defined directives."); }); @@ -977,7 +1010,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, () -> { + enforceNamespacePermission(NamespaceId.SYSTEM.getNamespace(), StandardPermission.GET); + return new ServiceResponse<>(Charset.availableCharsets().keySet()); + }); } /** @@ -991,6 +1027,7 @@ public void charsets(HttpServiceRequest request, HttpServiceResponder responder) @TransactionPolicy(value = TransactionControl.EXPLICIT) public void uploadConfig(HttpServiceRequest request, HttpServiceResponder responder) { respond(request, responder, () -> { + enforceDirectiveConfigPermission(StandardPermission.UPDATE); // Read the request body RequestExtractor handler = new RequestExtractor(request); DirectiveConfig config = handler.getContent("UTF-8", DirectiveConfig.class); @@ -1012,7 +1049,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, () -> { + enforceDirectiveConfigPermission(StandardPermission.GET); + return new ServiceResponse<>(configStore.getConfig()); + }); } /** @@ -1089,4 +1129,29 @@ private List executeDirectives(NamespacedId id, List< grammarVisitor); }); } + + private void enforceWorkspacePermission(String namespace, String workspaceId, StandardPermission permission) { + if (isWorkspaceAuthEnforcementEnabled) { + contextAccessEnforcer.enforce(new WorkspaceEntityId(namespace, workspaceId), permission); + } + } + + private void enforceNamespacePermission(String namespace, StandardPermission permission) { + if (isWorkspaceAuthEnforcementEnabled) { + contextAccessEnforcer.enforce(new NamespaceId(namespace), permission); + } + } + + private void enforceOnParentNamespace(String namespace, StandardPermission permission) { + if (isWorkspaceAuthEnforcementEnabled) { + contextAccessEnforcer.enforceOnParent(EntityType.SYSTEM_APP_ENTITY, new NamespaceId(namespace), permission); + } + } + + private void enforceDirectiveConfigPermission(StandardPermission permission) { + if (isDirectiveConfigAuthEnforcementEnabled) { + contextAccessEnforcer.enforce(new DirectiveConfigEntityId(), 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..69ae24c07 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_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(); + enforceRecipePermission(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(); + enforceRecipePermission(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)); + enforceRecipePermission(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(); + enforceRecipePermission(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(); + enforceRecipePermission(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 enforceRecipePermission(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");