Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/open_api_core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16134,6 +16134,7 @@ components:
key:
type: string
writeOnly: true
description: "Write-only secret, required on create. On PUT update an omitted key is preserved from the stored entity; an explicit null is rejected with 400."
project:
type: string
role:
Expand Down Expand Up @@ -17738,11 +17739,15 @@ components:
key:
type: string
writeOnly: true
nullable: true
description: "Write-only secret. On PUT update: omitted - preserved from the stored entity; explicit null - erased; a value (including the empty string) - replaces the secret."
responsesEndpoint:
type: string
secretExtraData:
type: string
writeOnly: true
nullable: true
description: "Write-only secret. On PUT update: omitted - preserved from the stored entity; explicit null - erased; a value (including the empty string) - replaces the secret."
tier:
type: integer
weight:
Expand All @@ -17761,9 +17766,13 @@ components:
key:
type: string
writeOnly: true
nullable: true
description: "Write-only secret. On PUT update: omitted - preserved from the stored entity; explicit null - erased; a value (including the empty string) - replaces the secret."
secretExtraData:
type: string
writeOnly: true
nullable: true
description: "Write-only secret. On PUT update: omitted - preserved from the stored entity; explicit null - erased; a value (including the empty string) - replaces the secret."
UserInfoResponse:
type: object
properties:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,9 @@ private static void applyStrip(ObjectNode target, Class<?> entityClass) {
}
}

public ObjectNode mergePreservingOmittedSecrets(JsonNode existingBlobNode,
JsonNode requestNode,
Class<?> entityClass) {
public ObjectNode mergeUpdateSecrets(JsonNode existingBlobNode,
JsonNode requestNode,
Class<?> entityClass) {
if (!(requestNode instanceof ObjectNode)) {
throw new IllegalArgumentException("requestNode must be an object");
}
Expand All @@ -125,11 +125,10 @@ private void mergeInto(ObjectNode target, JsonNode source, Class<?> entityClass)
String name = field.getName();
if (field.isAnnotationPresent(EncryptedField.class)) {
JsonNode current = target.get(name);
// Preserve-on-omit: a null or absent secret in the request body keeps the prior
// ciphertext from the stored blob. Without the retired "***" mask sentinel, only
// null / missing signals "omitted" — a literal string in the request is treated as
// a real value and re-encrypted.
if (current == null || current.isNull()) {
// Update intents: an absent field preserves the prior ciphertext from the stored
// blob; explicit null erases the secret (null flows into the entity and the blob
// omits the field); a literal string — empty string included — is the new value.
if (current == null) {
JsonNode existing = source.get(name);
if (existing != null && !existing.isNull()) {
target.set(name, existing.deepCopy());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1578,8 +1578,9 @@ private Future<?> handlePut() {
}
JsonNode source;
if (spec.hasEncryptedFields() && existingBody != null) {
// Update arm with secret fields — preserve omitted/sentinel-masked
// ciphertext from the prior blob (see SecretFieldProcessor).
// Update arm with secret fields — an omitted secret keeps the prior
// ciphertext from the blob, explicit null erases it, a literal value
// replaces it (see SecretFieldProcessor).
JsonNode existingBlobNode;
try {
existingBlobNode = BLOB_MAPPER.readTree(existingBody);
Expand All @@ -1603,7 +1604,7 @@ private Future<?> handlePut() {
+ "proceeding with new secret as authoritative", descriptor.getUrl());
}
}
source = secretFieldProcessor.mergePreservingOmittedSecrets(
source = secretFieldProcessor.mergeUpdateSecrets(
existingBlobNode, requestNode, spec.entityClass());
} else {
// Create arm (no prior blob) or no encrypted fields — use the request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,34 @@ void testKeyPut200PreserveKeyOnOmit() {
verify(put, 200);
}

@Test
void testKeyPutExplicitNullKeyRejected400() {
String body = """
{
"key": "secret-erase",
"project": "projA",
"roles": ["admin"]
}
""";
verify(send(HttpMethod.PUT, "/v1/keys/platform/test-key-erase", null,
body, "authorization", "admin", "If-None-Match", "*"), 200);

// Explicit null is the erase signal for upstream secrets, but a Key without its secret is
// meaningless: the merge leaves Key.key null and the explicit-key validation rejects it.
String eraseKey = """
{
"key": null,
"project": "projB",
"roles": ["admin"]
}
""";
Response put = send(HttpMethod.PUT, "/v1/keys/platform/test-key-erase", null,
eraseKey, "authorization", "admin");
verify(put, 400);
assertTrue(put.body().contains("must be provided explicitly"),
() -> "Expected explicit-key rejection: " + put.body());
}

@Test
void testKeyPutBareUpsertCreatesOnMissing() {
// Bare PUT against missing — upsert creates (was 404 pre-U.0).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package com.epam.aidial.core.server;

import io.vertx.core.http.HttpMethod;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.Test;

import java.util.concurrent.atomic.AtomicReference;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
Expand All @@ -15,8 +20,8 @@
*
* <p>Slice U.4 (2026-05-25) retired the {@code ?reveal_secrets=true} reveal flow, the
* {@code security-admin} role, and the {@code "***"} mask sentinel. Secret fields drop from
* GET responses via {@code @JsonProperty(WRITE_ONLY)}; preserve-on-omit signals are
* null/absent only.
* GET responses via {@code @JsonProperty(WRITE_ONLY)}; on update, an omitted secret is
* preserved, an explicit null erases it, a literal value replaces it.
*
* <p>Slice 2S.14: write controllers call {@code MergedConfigStore.rebuildNow()} on the writer pod,
* making post-write GETs immediately consistent — no polling helpers needed.
Expand Down Expand Up @@ -164,6 +169,57 @@ void testPutPreservesOmittedSecret() {
() -> "Upstream key must be absent on GET: " + get.body());
}

@Test
void testPutExplicitNullUpstreamKeyErasesSecret() {
// The model endpoint targets the local TestWebServer ("adapter" position); the upstream key
// reaches it as X-UPSTREAM-KEY, so the header's presence/absence is the direct runtime
// observable of preserve vs erase — no GET surface can show it (secrets drop everywhere).
String answer = "{\"id\":\"chatcmpl-1\",\"object\":\"chat.completion\",\"created\":1,\"model\":\"m\","
+ "\"choices\":[{\"index\":0,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"hi\"}}]}";
String chatBody = "{\"model\":\"test-model-erase\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}";
String withSecret = """
{
"type": "chat",
"endpoint": "http://localhost:4849/chat/completions",
"upstreams": [
{"endpoint": "http://vendor.example/v1/chat/completions", "key": "real-secret"}
]
}
""";
String eraseKey = """
{
"type": "chat",
"endpoint": "http://localhost:4849/chat/completions",
"upstreams": [
{"endpoint": "http://vendor.example/v1/chat/completions", "key": null}
]
}
""";
AtomicReference<RecordedRequest> captured = new AtomicReference<>();
try (TestWebServer server = new TestWebServer(4849)) {
server.map(HttpMethod.POST, "/chat/completions", request -> {
captured.set(request);
return TestWebServer.createResponse(200, answer, "Content-Type", "application/json");
});

verify(send(HttpMethod.PUT, "/v1/models/platform/test-model-erase", null, withSecret,
"authorization", "admin", "If-None-Match", "*"), 200);

// Positive control: the create-time secret rides the header before the erase.
verify(send(HttpMethod.POST, "/openai/deployments/test-model-erase/chat/completions", null,
chatBody, "content-type", "application/json"), 200);
assertEquals("real-secret", captured.get().getHeader("X-UPSTREAM-KEY"));

verify(send(HttpMethod.PUT, "/v1/models/platform/test-model-erase", null, eraseKey,
"authorization", "admin"), 200);

verify(send(HttpMethod.POST, "/openai/deployments/test-model-erase/chat/completions", null,
chatBody, "content-type", "application/json"), 200);
assertNull(captured.get().getHeader("X-UPSTREAM-KEY"),
"Erased secret must not reach the upstream");
}
}

@Test
void testExtraDataVisibleOnGet() {
String body = """
Expand Down
Loading