From 876d3c5ba2a1fb5d947ac4031e80701a34fd6104 Mon Sep 17 00:00:00 2001 From: adilburaksen Date: Mon, 22 Jun 2026 23:42:48 +0300 Subject: [PATCH 1/2] Enforce access on program instances and live-info endpoints in ProgramRuntimeHttpHandler The batch instances endpoint and the live-info endpoint read program instance counts and live runtime info using the namespace from the request path without access enforcement, while the single-program instances endpoint enforces via ensureProgramExists. Add accessEnforcer.enforce(programId, principal, StandardPermission.GET) before those reads (parity with the enforced paths), plus a regression test verifying an unauthorized principal is denied before runtime access. --- .../handlers/ProgramRuntimeHttpHandler.java | 2 + ...amRuntimeHttpHandlerAuthorizationTest.java | 126 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 cdap-app-fabric/src/test/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandlerAuthorizationTest.java diff --git a/cdap-app-fabric/src/main/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandler.java b/cdap-app-fabric/src/main/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandler.java index a6cc26a7d0ce..342c63e663a1 100644 --- a/cdap-app-fabric/src/main/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandler.java +++ b/cdap-app-fabric/src/main/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandler.java @@ -174,6 +174,7 @@ public void getInstances(FullHttpRequest request, HttpResponder responder, ApplicationSpecification spec = appSpecs.get(appId); ProgramId programId = appId.program(runnable.getProgramType(), runnable.getProgramId()); + accessEnforcer.enforce(programId, authenticationContext.getPrincipal(), StandardPermission.GET); output.add(getProgramInstances(runnable, spec, programId)); } responder.sendJson(HttpResponseStatus.OK, ProgramHandlerUtil.toJson(output)); @@ -305,6 +306,7 @@ public void liveInfo(HttpRequest request, HttpResponder responder, ProgramType type = ProgramType.valueOfCategoryName(programCategory, BadRequestException::new); ProgramId program = store.getLatestApp(new ApplicationReference(namespaceId, appId)) .program(type, programId); + accessEnforcer.enforce(program, authenticationContext.getPrincipal(), StandardPermission.GET); getLiveInfo(responder, program, runtimeService); } diff --git a/cdap-app-fabric/src/test/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandlerAuthorizationTest.java b/cdap-app-fabric/src/test/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandlerAuthorizationTest.java new file mode 100644 index 000000000000..a9ee37c73f5b --- /dev/null +++ b/cdap-app-fabric/src/test/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandlerAuthorizationTest.java @@ -0,0 +1,126 @@ +/* + * 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.cdap.gateway.handlers; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.cdap.cdap.app.runtime.ProgramRuntimeService; +import io.cdap.cdap.app.store.Store; +import io.cdap.cdap.common.ApplicationNotFoundException; +import io.cdap.cdap.common.BadRequestException; +import io.cdap.cdap.common.namespace.NamespaceQueryAdmin; +import io.cdap.cdap.internal.app.services.ProgramLifecycleService; +import io.cdap.cdap.proto.NotRunningProgramLiveInfo; +import io.cdap.cdap.proto.ProgramType; +import io.cdap.cdap.proto.id.ApplicationId; +import io.cdap.cdap.proto.id.ApplicationReference; +import io.cdap.cdap.proto.id.NamespaceId; +import io.cdap.cdap.proto.id.ProgramId; +import io.cdap.cdap.proto.security.Authorizable; +import io.cdap.cdap.proto.security.Principal; +import io.cdap.cdap.proto.security.StandardPermission; +import io.cdap.cdap.security.auth.context.AuthenticationTestContext; +import io.cdap.cdap.security.authorization.InMemoryAccessController; +import io.cdap.cdap.security.spi.authentication.AuthenticationContext; +import io.cdap.cdap.security.spi.authorization.UnauthorizedException; +import io.cdap.http.HttpResponder; +import io.netty.handler.codec.http.HttpRequest; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.Matchers; + +public class ProgramRuntimeHttpHandlerAuthorizationTest { + private static final Principal MASTER_PRINCIPAL = new Principal("master", Principal.PrincipalType.USER); + private static final Principal UNPRIVILEGED_PRINCIPAL = new Principal("unprivileged", + Principal.PrincipalType.USER); + private static final NamespaceId NAMESPACE_ID = new NamespaceId("ns"); + private static final ApplicationId APP_ID = NAMESPACE_ID.app("app"); + private static final ProgramId PROGRAM_ID = APP_ID.service("service"); + + private static ProgramRuntimeHttpHandler programRuntimeHttpHandler; + private static ProgramRuntimeService runtimeService; + + HttpRequest request; + HttpResponder responder; + Exception exceptionThrown; + + @BeforeClass + public static void setup() throws ApplicationNotFoundException { + StandardPermission[] requiredPermissions = new StandardPermission[] {StandardPermission.GET}; + + InMemoryAccessController inMemoryAccessController = new InMemoryAccessController(); + inMemoryAccessController.grant(Authorizable.fromEntityId(PROGRAM_ID), MASTER_PRINCIPAL, + Collections.unmodifiableSet(new HashSet<>(Arrays.asList(requiredPermissions)))); + AuthenticationContext authenticationContext = new AuthenticationTestContext(); + + ProgramLifecycleService lifecycleService = mock(ProgramLifecycleService.class); + Store store = mock(Store.class); + runtimeService = mock(ProgramRuntimeService.class); + NamespaceQueryAdmin namespaceQueryAdmin = mock(NamespaceQueryAdmin.class); + + when(store.getLatestApp(Matchers.any(ApplicationReference.class))).thenReturn(APP_ID); + when(runtimeService.getLiveInfo(PROGRAM_ID)).thenReturn(new NotRunningProgramLiveInfo(PROGRAM_ID)); + + programRuntimeHttpHandler = new ProgramRuntimeHttpHandler(lifecycleService, store, runtimeService, + namespaceQueryAdmin, inMemoryAccessController, + authenticationContext); + } + + @Before + public void initializeVariables() { + request = mock(HttpRequest.class); + responder = mock(HttpResponder.class); + exceptionThrown = null; + reset(runtimeService); + when(runtimeService.getLiveInfo(PROGRAM_ID)).thenReturn(new NotRunningProgramLiveInfo(PROGRAM_ID)); + } + + @Test + public void testLiveInfoUnauthorized() throws BadRequestException, ApplicationNotFoundException { + AuthenticationTestContext.actAsPrincipal(UNPRIVILEGED_PRINCIPAL); + try { + programRuntimeHttpHandler.liveInfo(request, responder, NAMESPACE_ID.getNamespace(), APP_ID.getApplication(), + ProgramType.SERVICE.getCategoryName(), PROGRAM_ID.getProgram()); + } catch (UnauthorizedException e) { + exceptionThrown = e; + } + Assert.assertNotNull(exceptionThrown); + verify(runtimeService, never()).getLiveInfo(Matchers.any(ProgramId.class)); + } + + @Test + public void testLiveInfoAuthorized() throws BadRequestException, ApplicationNotFoundException { + AuthenticationTestContext.actAsPrincipal(MASTER_PRINCIPAL); + try { + programRuntimeHttpHandler.liveInfo(request, responder, NAMESPACE_ID.getNamespace(), APP_ID.getApplication(), + ProgramType.SERVICE.getCategoryName(), PROGRAM_ID.getProgram()); + } catch (UnauthorizedException e) { + exceptionThrown = e; + } + Assert.assertNull(exceptionThrown); + verify(runtimeService).getLiveInfo(PROGRAM_ID); + } +} From f7dd3bb3b32ba6ba2d31a9a4d594c03ec4edfe32 Mon Sep 17 00:00:00 2001 From: adilburaksen Date: Tue, 23 Jun 2026 00:03:21 +0300 Subject: [PATCH 2/2] Handle batch instances authorization per-runnable Return a 403 entry for an unauthorized program in the batch /instances request instead of failing the whole batch, matching the endpoint's existing per-entry error handling, while still not reading instance data for the unauthorized program. Extends the test with a batch case. --- .../handlers/ProgramRuntimeHttpHandler.java | 7 +- ...amRuntimeHttpHandlerAuthorizationTest.java | 75 ++++++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/cdap-app-fabric/src/main/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandler.java b/cdap-app-fabric/src/main/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandler.java index 342c63e663a1..e6a4ccd2cec8 100644 --- a/cdap-app-fabric/src/main/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandler.java +++ b/cdap-app-fabric/src/main/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandler.java @@ -174,7 +174,12 @@ public void getInstances(FullHttpRequest request, HttpResponder responder, ApplicationSpecification spec = appSpecs.get(appId); ProgramId programId = appId.program(runnable.getProgramType(), runnable.getProgramId()); - accessEnforcer.enforce(programId, authenticationContext.getPrincipal(), StandardPermission.GET); + try { + accessEnforcer.enforce(programId, authenticationContext.getPrincipal(), StandardPermission.GET); + } catch (UnauthorizedException e) { + output.add(new BatchRunnableInstances(runnable, HttpResponseStatus.FORBIDDEN.code(), e.getMessage())); + continue; + } output.add(getProgramInstances(runnable, spec, programId)); } responder.sendJson(HttpResponseStatus.OK, ProgramHandlerUtil.toJson(output)); diff --git a/cdap-app-fabric/src/test/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandlerAuthorizationTest.java b/cdap-app-fabric/src/test/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandlerAuthorizationTest.java index a9ee37c73f5b..d6c24043b82b 100644 --- a/cdap-app-fabric/src/test/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandlerAuthorizationTest.java +++ b/cdap-app-fabric/src/test/java/io/cdap/cdap/gateway/handlers/ProgramRuntimeHttpHandlerAuthorizationTest.java @@ -19,15 +19,23 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.common.reflect.TypeToken; +import io.cdap.cdap.api.app.ApplicationSpecification; +import io.cdap.cdap.api.service.ServiceSpecification; import io.cdap.cdap.app.runtime.ProgramRuntimeService; import io.cdap.cdap.app.store.Store; import io.cdap.cdap.common.ApplicationNotFoundException; import io.cdap.cdap.common.BadRequestException; import io.cdap.cdap.common.namespace.NamespaceQueryAdmin; +import io.cdap.cdap.gateway.handlers.util.ProgramHandlerUtil; +import io.cdap.cdap.internal.MockResponder; import io.cdap.cdap.internal.app.services.ProgramLifecycleService; +import io.cdap.cdap.proto.BatchRunnable; +import io.cdap.cdap.proto.BatchRunnableInstances; import io.cdap.cdap.proto.NotRunningProgramLiveInfo; import io.cdap.cdap.proto.ProgramType; import io.cdap.cdap.proto.id.ApplicationId; @@ -42,10 +50,19 @@ import io.cdap.cdap.security.spi.authentication.AuthenticationContext; import io.cdap.cdap.security.spi.authorization.UnauthorizedException; import io.cdap.http.HttpResponder; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.DefaultFullHttpRequest; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.List; import org.junit.Assert; import org.junit.Before; import org.junit.BeforeClass; @@ -59,9 +76,12 @@ public class ProgramRuntimeHttpHandlerAuthorizationTest { private static final NamespaceId NAMESPACE_ID = new NamespaceId("ns"); private static final ApplicationId APP_ID = NAMESPACE_ID.app("app"); private static final ProgramId PROGRAM_ID = APP_ID.service("service"); + private static final ApplicationId UNAUTHORIZED_APP_ID = NAMESPACE_ID.app("unauthorizedApp"); + private static final ProgramId UNAUTHORIZED_PROGRAM_ID = UNAUTHORIZED_APP_ID.service("unauthorizedService"); private static ProgramRuntimeHttpHandler programRuntimeHttpHandler; private static ProgramRuntimeService runtimeService; + private static Store store; HttpRequest request; HttpResponder responder; @@ -77,7 +97,7 @@ public static void setup() throws ApplicationNotFoundException { AuthenticationContext authenticationContext = new AuthenticationTestContext(); ProgramLifecycleService lifecycleService = mock(ProgramLifecycleService.class); - Store store = mock(Store.class); + store = mock(Store.class); runtimeService = mock(ProgramRuntimeService.class); NamespaceQueryAdmin namespaceQueryAdmin = mock(NamespaceQueryAdmin.class); @@ -94,7 +114,9 @@ public void initializeVariables() { request = mock(HttpRequest.class); responder = mock(HttpResponder.class); exceptionThrown = null; + reset(store); reset(runtimeService); + when(store.getLatestApp(Matchers.any(ApplicationReference.class))).thenReturn(APP_ID); when(runtimeService.getLiveInfo(PROGRAM_ID)).thenReturn(new NotRunningProgramLiveInfo(PROGRAM_ID)); } @@ -123,4 +145,55 @@ public void testLiveInfoAuthorized() throws BadRequestException, ApplicationNotF Assert.assertNull(exceptionThrown); verify(runtimeService).getLiveInfo(PROGRAM_ID); } + + @Test + public void testBatchInstancesAuthorizationPerRunnable() throws Exception { + AuthenticationTestContext.actAsPrincipal(MASTER_PRINCIPAL); + when(store.getLatestApp(Matchers.any(ApplicationReference.class))).thenAnswer(invocation -> { + ApplicationReference appReference = (ApplicationReference) invocation.getArguments()[0]; + if (UNAUTHORIZED_APP_ID.getApplication().equals(appReference.getApplication())) { + return UNAUTHORIZED_APP_ID; + } + return APP_ID; + }); + when(store.getApplication(APP_ID)).thenReturn(createAppSpec(PROGRAM_ID)); + when(store.getApplication(UNAUTHORIZED_APP_ID)).thenReturn(createAppSpec(UNAUTHORIZED_PROGRAM_ID)); + when(store.getServiceInstances(PROGRAM_ID)).thenReturn(3); + + List runnables = Arrays.asList( + new BatchRunnable(APP_ID.getApplication(), ProgramType.SERVICE, PROGRAM_ID.getProgram(), null), + new BatchRunnable(UNAUTHORIZED_APP_ID.getApplication(), ProgramType.SERVICE, + UNAUTHORIZED_PROGRAM_ID.getProgram(), null) + ); + FullHttpRequest batchRequest = new DefaultFullHttpRequest( + HttpVersion.HTTP_1_1, HttpMethod.POST, "/instances", + Unpooled.copiedBuffer(ProgramHandlerUtil.toJson(runnables), StandardCharsets.UTF_8)); + MockResponder batchResponder = new MockResponder(); + + programRuntimeHttpHandler.getInstances(batchRequest, batchResponder, NAMESPACE_ID.getNamespace()); + + Type responseType = new TypeToken>() { }.getType(); + List response = batchResponder.decodeResponseContent(responseType); + Assert.assertEquals(HttpResponseStatus.OK, batchResponder.getStatus()); + Assert.assertEquals(2, response.size()); + Assert.assertEquals(HttpResponseStatus.OK.code(), response.get(0).getStatusCode()); + Assert.assertEquals(Integer.valueOf(3), response.get(0).getProvisioned()); + Assert.assertEquals(Integer.valueOf(2), response.get(0).getRequested()); + Assert.assertNull(response.get(0).getError()); + Assert.assertEquals(HttpResponseStatus.FORBIDDEN.code(), response.get(1).getStatusCode()); + Assert.assertNotNull(response.get(1).getError()); + Assert.assertNull(response.get(1).getProvisioned()); + Assert.assertNull(response.get(1).getRequested()); + verify(store, times(1)).getServiceInstances(PROGRAM_ID); + verify(store, never()).getServiceInstances(UNAUTHORIZED_PROGRAM_ID); + } + + private static ApplicationSpecification createAppSpec(ProgramId programId) { + ServiceSpecification serviceSpec = mock(ServiceSpecification.class); + when(serviceSpec.getInstances()).thenReturn(2); + ApplicationSpecification spec = mock(ApplicationSpecification.class); + when(spec.getServices()).thenReturn( + Collections.singletonMap(programId.getProgram(), serviceSpec)); + return spec; + } }