Skip to content

Commit 04665a4

Browse files
committed
wip of encrypted payloads
1 parent 9bd2d70 commit 04665a4

11 files changed

Lines changed: 178 additions & 11 deletions

File tree

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# AI Agents Style Guide
2+
3+
When modifying Java code in this repository, please adhere to the following guidelines:
4+
5+
## Code Style
6+
7+
- **Avoid Fully Qualified Names (FQNs)**: Do not use fully qualified class names directly in code (e.g., `java.util.Base64`). Always use standard `import` statements at the top of the file to import the necessary classes and refer to them by their simple names.
8+
- **Dependency Injection**: Favor Dependency Injection (Dagger) over static singletons or utility classes. Create injectable services with `@Singleton` and `@Inject` constructors where applicable.
9+
- **Constants**: Extract reused string literals and configuration keys into `public static final String` constants rather than hardcoding them multiple times.
10+
- **Validation**: Ensure settings and inputs are appropriately validated, providing standard exception types (`IllegalArgumentException`) for malformed inputs, such as GCP resource names.
11+
- **Documentation**: Provide examples for complex configuration values or properties (like GCP KMS keys or URIs) inside JavaDocs.

docs/cmek-encryption.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Customer-Managed Encryption Keys (CMEK) Support
2+
3+
## Overview
4+
This design document describes the support for encrypting potentially sensitive job parameters using Customer-Managed Encryption Keys (CMEK) via Google Cloud KMS within the pipeline framework.
5+
6+
The primary use case is to ensure that sensitive job parameters are encrypted with customer-specific keys, even when the pipelines are running in shared task queues or when arbitrary content for the task is stored in the datastore records for the job/pipeline.
7+
8+
## Design
9+
10+
### Job Settings
11+
12+
A new job setting called `EncryptionKey` has been added. It is a `StringValuedSetting` that holds the full Google Cloud KMS Key Name.
13+
When creating a pipeline or a job, developers can pass the `EncryptionKey` setting:
14+
15+
```java
16+
JobSetting[] settings = new JobSetting[] {
17+
new JobSetting.EncryptionKey("projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key")
18+
};
19+
```
20+
21+
This setting is stored within the JobRecord's `QueueSettings` and passed along to any spawned pipeline tasks.
22+
23+
### Encryption at Enqueue Time
24+
25+
When a job creates tasks that need to be enqueued (via `PipelineTask.toTaskSpec()`), it checks if an `EncryptionKey` is present in the `QueueSettings`.
26+
27+
If present, the framework:
28+
1. Translates the task's properties into a JSON object.
29+
2. Encrypts the JSON representation using the configured GCP KMS key using a utility class (`CmekUtils`).
30+
3. Base64 encodes the ciphertext.
31+
4. Uses a single parameter `_encrypted_payload` to hold the encrypted Base64 string for the `POST` request payload.
32+
5. Adds an HTTP Header `X-Pipeline-EncryptionKey` carrying the KMS key name so the receiver knows which key to use for decryption.
33+
34+
### Decryption at Task Execution Time
35+
36+
When the task is received by `TaskHandler.java`:
37+
1. It checks for the `X-Pipeline-EncryptionKey` header.
38+
2. If the header and the `_encrypted_payload` parameter exist, it decrypts the Base64 decoded payload using the specified key via `CmekUtils`.
39+
3. The resulting decrypted JSON is then parsed back into standard task parameters and the pipeline framework execution proceeds normally without any need to alter internal logic.
40+
41+
## Datastore Consideration
42+
43+
The current CMEK support directly encrypts the task payload (POST parameters) going into Cloud Tasks or App Engine Task Queues.
44+
For encrypting parameters saved to the Datastore, developers can implement an `EncryptionSerializationStrategy` or explicitly encrypt their `Value`s before feeding them into the pipeline, to ensure data stored at rest in the Datastore uses CMEK. The current explicit encryption guarantees that any data placed in the Task Queues is securely encrypted under the provided CMEK.
45+
46+
## Dependencies
47+
48+
The implementation binds to the `google-cloud-kms` library to perform KMS operations.

java/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@
120120
<groupId>com.google.cloud</groupId>
121121
<artifactId>google-cloud-storage</artifactId>
122122
</dependency>
123+
<dependency>
124+
<groupId>com.google.cloud</groupId>
125+
<artifactId>google-cloud-kms</artifactId>
126+
</dependency>
123127
<dependency>
124128
<groupId>com.google.guava</groupId>
125129
<artifactId>guava</artifactId>

java/src/main/java/com/google/appengine/tools/pipeline/JobSetting.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,22 @@ public DatastoreNamespace(String datastoreNameSpace) {
221221
}
222222
}
223223

224+
/**
225+
* A setting specifying a CMEK for encrypting task parameters.
226+
* Example: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key"
227+
*/
228+
final class EncryptionKey extends StringValuedSetting {
229+
@Serial
230+
private static final long serialVersionUID = -2L;
231+
232+
public EncryptionKey(String encryptionKey) {
233+
super(encryptionKey);
234+
if (encryptionKey != null && !encryptionKey.matches("projects/[^/]+/locations/[^/]+/keyRings/[^/]+/cryptoKeys/[^/]+")) {
235+
throw new IllegalArgumentException("EncryptionKey must match the format: projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}");
236+
}
237+
}
238+
}
239+
224240

225241
static <E extends StringValuedSetting> Optional<String> getSettingValue(Class<E> clazz, JobSetting[] settings) {
226242
return Arrays.stream(settings)

java/src/main/java/com/google/appengine/tools/pipeline/impl/QueueSettings.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ public final class QueueSettings implements Cloneable {
3737
*/
3838
private Long delayInSeconds;
3939

40+
/**
41+
* KMS Key Name for CMEK encryption of task payload
42+
* Example: "projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key"
43+
*/
44+
private String encryptionKey;
45+
4046
/**
4147
* Merge will override any {@code null} setting with a matching setting from {@code other}.
4248
* Note, delay value is not being merged.
@@ -49,6 +55,9 @@ public QueueSettings merge(QueueSettings other) {
4955
if (onQueue == null) {
5056
onQueue = other.getOnQueue();
5157
}
58+
if (encryptionKey == null) {
59+
encryptionKey = other.getEncryptionKey();
60+
}
5261
return this;
5362
}
5463

java/src/main/java/com/google/appengine/tools/pipeline/impl/backend/AppEngineTaskQueue.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,22 @@ public class AppEngineTaskQueue implements PipelineTaskQueue {
5959

6060
final AppEngineEnvironment environment;
6161
final AppEngineServicesService servicesService;
62+
final com.google.appengine.tools.pipeline.impl.util.KmsService kmsService;
6263

6364
final String taskHandlerUrl;
6465

6566
public AppEngineTaskQueue(AppEngineServicesService appEngineServicesService) {
6667
this.environment = new AppEngineStandardGen2();
6768
this.servicesService = appEngineServicesService;
69+
this.kmsService = null;
6870
this.taskHandlerUrl = TaskHandler.handleTaskUrl();
6971
}
7072

7173
@Inject
72-
public AppEngineTaskQueue(AppEngineEnvironment environment, AppEngineServicesService servicesService) {
74+
public AppEngineTaskQueue(AppEngineEnvironment environment, AppEngineServicesService servicesService, com.google.appengine.tools.pipeline.impl.util.KmsService kmsService) {
7375
this.environment = environment;
7476
this.servicesService = servicesService;
77+
this.kmsService = kmsService;
7578
this.taskHandlerUrl = TaskHandler.handleTaskUrl();
7679
}
7780

@@ -159,7 +162,7 @@ public Collection<TaskReference> enqueue(final Collection<PipelineTask> pipeline
159162
public Multimap<String, TaskSpec> asTaskSpecs(Collection<PipelineTask> pipelineTasks) {
160163
Multimap<String, TaskSpec> taskSpecs = HashMultimap.create();
161164
pipelineTasks.forEach( pipelineTask -> {
162-
taskSpecs.put(getQueueForTask(pipelineTask), pipelineTask.toTaskSpec(servicesService, taskHandlerUrl));
165+
taskSpecs.put(getQueueForTask(pipelineTask), pipelineTask.toTaskSpec(servicesService, taskHandlerUrl, kmsService));
163166
});
164167
return taskSpecs;
165168
}

java/src/main/java/com/google/appengine/tools/pipeline/impl/backend/CloudTasksTaskQueue.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ public String getPropertyName() {
5757
@NonNull
5858
AppEngineServicesService appEngineServicesService;
5959

60+
@NonNull
61+
KmsService kmsService;
62+
6063
// GAE location -> Cloud Tasks location name
6164
Cache<String, String> locationCache =
6265
CacheBuilder.newBuilder().initialCapacity(1).build();
@@ -76,7 +79,7 @@ public Collection<TaskReference> enqueue(Collection<PipelineTask> pipelineTasks)
7679
.map(tasksForQueue -> {
7780
Stream<TaskSpec> specs = tasksForQueue.getValue().stream()
7881
.map(pipelineTask -> {
79-
return pipelineTask.toTaskSpec(appEngineServicesService, TaskHandler.handleTaskUrl());
82+
return pipelineTask.toTaskSpec(appEngineServicesService, TaskHandler.handleTaskUrl(), kmsService);
8083
});
8184
return enqueue(tasksForQueue.getKey(), specs.collect(Collectors.toList()));
8285
})
@@ -89,7 +92,7 @@ public Multimap<String, TaskSpec> asTaskSpecs(Collection<PipelineTask> pipelineT
8992
Multimap<String, TaskSpec> taskSpecs = HashMultimap.create();
9093
pipelineTasks
9194
.forEach(pipelineTask -> {
92-
taskSpecs.put(getQueueForTask(pipelineTask), pipelineTask.toTaskSpec(appEngineServicesService, TaskHandler.handleTaskUrl()));
95+
taskSpecs.put(getQueueForTask(pipelineTask), pipelineTask.toTaskSpec(appEngineServicesService, TaskHandler.handleTaskUrl(), kmsService));
9396
});
9497
return taskSpecs;
9598
}

java/src/main/java/com/google/appengine/tools/pipeline/impl/model/JobRecord.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ public enum InflationType {
160160
private static final String CHILD_GRAPH_GUID_PROPERTY = "childGraphGuid";
161161
private static final String STATUS_CONSOLE_URL = "statusConsoleUrl";
162162
public static final String ROOT_JOB_DISPLAY_NAME = "rootJobDisplayName";
163-
164163
public static final String IS_ROOT_JOB_PROPERTY = "isRootJob";
164+
private static final String ENCRYPTION_KEY_PROPERTY = "encryptionKey";
165165

166166
/**
167167
* projectId for job; must be set
@@ -287,6 +287,7 @@ public JobRecord(Entity entity) {
287287
queueSettings.setOnService(EntityUtils.getString(entity, ON_SERVICE_PROPERTY));
288288
queueSettings.setOnServiceVersion(EntityUtils.getString(entity, ON_SERVICE_VERSION_PROPERTY));
289289
queueSettings.setOnQueue(EntityUtils.getString(entity, ON_QUEUE_PROPERTY));
290+
queueSettings.setEncryptionKey(EntityUtils.getString(entity, ENCRYPTION_KEY_PROPERTY));
290291

291292
statusConsoleUrl = EntityUtils.getString(entity, STATUS_CONSOLE_URL);
292293
rootJobDisplayName = EntityUtils.getString(entity, ROOT_JOB_DISPLAY_NAME);
@@ -356,6 +357,9 @@ public Entity toEntity() {
356357
if (queueSettings.getOnQueue() != null) {
357358
builder.set(ON_QUEUE_PROPERTY, StringValue.newBuilder(queueSettings.getOnQueue()).setExcludeFromIndexes(true).build());
358359
}
360+
if (queueSettings.getEncryptionKey() != null) {
361+
builder.set(ENCRYPTION_KEY_PROPERTY, StringValue.newBuilder(queueSettings.getEncryptionKey()).setExcludeFromIndexes(true).build());
362+
}
359363

360364
if (statusConsoleUrl != null) {
361365
builder.set(STATUS_CONSOLE_URL, StringValue.newBuilder(statusConsoleUrl).setExcludeFromIndexes(true).build());
@@ -518,6 +522,8 @@ private void applySetting(JobSetting setting) {
518522
statusConsoleUrl = ((StatusConsoleUrl) setting).getValue();
519523
} else if (setting instanceof JobSetting.DatastoreNamespace) {
520524
//ignore; applied in constructor, bc it's final
525+
} else if (setting instanceof JobSetting.EncryptionKey) {
526+
queueSettings.setEncryptionKey(((JobSetting.EncryptionKey) setting).getValue());
521527
} else {
522528
throw new RuntimeException("Unrecognized JobSetting class " + setting.getClass().getName());
523529
}

java/src/main/java/com/google/appengine/tools/pipeline/impl/servlets/TaskHandler.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,12 @@
2727
import java.time.Duration;
2828
import java.util.*;
2929
import java.util.logging.Level;
30-
import java.util.logging.Logger;
3130
import java.util.stream.Stream;
3231

32+
import org.json.JSONObject;
33+
import com.google.appengine.tools.pipeline.impl.util.KmsService;
34+
import java.nio.charset.StandardCharsets;
35+
3336
import javax.servlet.ServletException;
3437
import javax.servlet.http.HttpServletRequest;
3538
import lombok.extern.java.Log;
@@ -48,6 +51,7 @@
4851
public class TaskHandler {
4952

5053
final JobRunServiceComponent component;
54+
final KmsService kmsService;
5155

5256
public static final String PATH_COMPONENT = "handleTask";
5357

@@ -110,8 +114,19 @@ Integer parseTaskRetryCount(HttpServletRequest req) {
110114

111115
private PipelineTask reconstructTask(HttpServletRequest request) {
112116
Properties properties = new Properties();
113-
Streams.stream(request.getParameterNames().asIterator())
114-
.forEach(name -> properties.setProperty(name, request.getParameter(name)));
117+
String encryptionKey = request.getHeader(PipelineTask.ENCRYPTION_KEY_HEADER);
118+
if (encryptionKey != null && request.getParameter("_encrypted_payload") != null) {
119+
String base64Encrypted = request.getParameter("_encrypted_payload");
120+
byte[] encrypted = Base64.getDecoder().decode(base64Encrypted);
121+
byte[] decrypted = kmsService.decrypt(encryptionKey, encrypted);
122+
JSONObject jsonParams = new JSONObject(new String(decrypted, StandardCharsets.UTF_8));
123+
for (String key : jsonParams.keySet()) {
124+
properties.setProperty(key, jsonParams.getString(key));
125+
}
126+
} else {
127+
Streams.stream(request.getParameterNames().asIterator())
128+
.forEach(name -> properties.setProperty(name, request.getParameter(name)));
129+
}
115130

116131
String taskName = parseTaskName(request);
117132
PipelineTask pipelineTask = PipelineTask.fromProperties(taskName, properties);

java/src/main/java/com/google/appengine/tools/pipeline/impl/tasks/PipelineTask.java

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,17 @@
2222

2323
import java.lang.reflect.Constructor;
2424
import java.lang.reflect.InvocationTargetException;
25+
import java.nio.charset.StandardCharsets;
2526
import java.time.Instant;
27+
import java.util.Base64;
2628
import java.util.EnumSet;
2729
import java.util.Optional;
2830
import java.util.Properties;
2931
import java.util.Set;
3032

33+
import org.json.JSONObject;
34+
import com.google.appengine.tools.pipeline.impl.util.KmsService;
35+
3136
/**
3237
* A Pipeline Framework task to be executed asynchronously This is the abstract base class for all
3338
* Pipeline task types.
@@ -55,6 +60,7 @@
5560
public abstract class PipelineTask {
5661

5762
protected static final String TASK_TYPE_PARAMETER = "taskType";
63+
public static final String ENCRYPTION_KEY_HEADER = "X-Pipeline-EncryptionKey";
5864

5965
@Getter @NonNull
6066
private final Type type;
@@ -200,14 +206,25 @@ public final Properties toProperties() {
200206
}
201207

202208

203-
public PipelineTaskQueue.TaskSpec toTaskSpec(AppEngineServicesService appEngineServicesService, String callback) {
209+
public PipelineTaskQueue.TaskSpec toTaskSpec(AppEngineServicesService appEngineServicesService, String callback, KmsService kmsService) {
204210
PipelineTaskQueue.TaskSpec.TaskSpecBuilder spec = PipelineTaskQueue.TaskSpec.builder()
205211
.name(this.getTaskName())
206212
.callbackPath(callback)
207213
.method(PipelineTaskQueue.TaskSpec.Method.POST);
208214

209-
this.toProperties().entrySet()
210-
.forEach(p -> spec.param((String) p.getKey(), (String) p.getValue()));
215+
if (this.getQueueSettings().getEncryptionKey() != null) {
216+
spec.header(ENCRYPTION_KEY_HEADER, this.getQueueSettings().getEncryptionKey());
217+
JSONObject jsonParams = new JSONObject();
218+
this.toProperties().forEach((k, v) -> jsonParams.put((String) k, (String) v));
219+
byte[] encrypted = kmsService.encrypt(
220+
this.getQueueSettings().getEncryptionKey(),
221+
jsonParams.toString().getBytes(StandardCharsets.UTF_8));
222+
String base64Encrypted = Base64.getEncoder().encodeToString(encrypted);
223+
spec.param("_encrypted_payload", base64Encrypted);
224+
} else {
225+
this.toProperties().entrySet()
226+
.forEach(p -> spec.param((String) p.getKey(), (String) p.getValue()));
227+
}
211228

212229
if (this.getQueueSettings().getDelayInSeconds() != null) {
213230
spec.scheduledExecutionTime(Instant.now().plusSeconds(this.getQueueSettings().getDelayInSeconds()));

0 commit comments

Comments
 (0)