Skip to content

refactor(server): unify credential storage on Conductor's SecretsDAO#314

Merged
v1r3n merged 9 commits into
mainfrom
pr/embedded-secrets-server-refactor
Jul 13, 2026
Merged

refactor(server): unify credential storage on Conductor's SecretsDAO#314
v1r3n merged 9 commits into
mainfrom
pr/embedded-secrets-server-refactor

Conversation

@v1r3n

@v1r3n v1r3n commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Splits the server-module changes out of feat/embedded-secrets-sdk-taskdef into a standalone PR (the SDK/python-submodule changes are not included here).

This is a follow-up to #312 (already on main). It collapses AgentSpan's bespoke credential SPI into Conductor's SecretsDAO, so the Credentials UI, credential resolution, env seeding, and Conductor's own ${workflow.secrets.NAME} substitution all go through one interface.

Changes

  • New CredentialsDAO extends com.netflix.conductor.dao.SecretsDAO — adds only listWithMeta() (name + partial value + timestamps for the Credentials UI). Replaces the CredentialStoreProvider SPI.
  • AgentspanSecretsDAO becomes the encrypted store itself — AES-256-GCM ([12-byte IV][ciphertext+tag]) over the credentials_store table via NamedParameterJdbcTemplate, with an atomic ON CONFLICT(user_id, name) DO UPDATE upsert (fixes an earlier UPDATE-then-INSERT first-write race). EncryptedDbCredentialStoreProvider is deleted.
  • Masking layer removedSecretOutputMasker SPI, NoOpSecretOutputMasker, and CredentialMaskingResponseAdvice (all OSS no-ops).
  • Consumers rewiredSecretController, CredentialResolutionService, CredentialEnvSeeder now depend on SecretsDAO / CredentialsDAO directly.

v1r3n added 5 commits July 12, 2026 19:43
Follow-up to #312 (already on main). Collapses AgentSpan's bespoke
credential SPI into Conductor's SecretsDAO so the Credentials UI,
credential resolution, env seeding, and Conductor's own
${workflow.secrets.NAME} substitution all read/write one interface.

- Add CredentialsDAO extends com.netflix.conductor.dao.SecretsDAO
  (adds only listWithMeta() for the Credentials UI); remove the
  CredentialStoreProvider SPI.
- AgentspanSecretsDAO becomes the encrypted store itself: AES-256-GCM
  over credentials_store via JDBC, with an atomic ON CONFLICT DO UPDATE
  upsert (fixes an earlier first-write race). Delete
  EncryptedDbCredentialStoreProvider.
- Remove the OSS no-op masking layer (SecretOutputMasker SPI,
  NoOpSecretOutputMasker, CredentialMaskingResponseAdvice).
- Rewire SecretController / CredentialResolutionService /
  CredentialEnvSeeder onto SecretsDAO / CredentialsDAO.
- Build: bump conductor 3.32.0-rc.5 -> rc.6; order mavenLocal before
  Central; add micrometer-registry-prometheus; HTTP/LLM worker threads.

Known issue (to be addressed separately): the agentspan.embedded default
(false->true) and four @ConditionalOnProperty gates were flipped
together, inverting the flag vs. its documentation. NativeSecretGatingTest
and ProviderStatusEndpointTest fail as a result (both pass on main).
@v1r3n v1r3n requested review from manan164 and shaileshpadave July 13, 2026 04:21
ResponseEntity<?> err = validateKey(key);
if (err != null) return ResponseEntity.status(err.getStatusCode()).build();
String value = storeProvider.get(key);
log.info("AUDIT get-secret: userId={} key={} found={}", currentUserId(), key, value != null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three operations - get, put, delete lost their audit lines, is this intentional?

@manan164

manan164 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Update / correction: after checking the branch further — post-PR, nothing reads RequestContextHolder (the only remaining references are AuthFilter's own set/clear), and the OSS AuthFilter assigns the same fixed anonymous UUID to every request. So the leak below is mechanically real but has no consumer and no per-user identity to misattribute — not a live bug in this PR. It only becomes one if an embedding host supplies a real principal adapter into this holder later. Fine to ship as-is; consider either deleting the now-unread holder entirely or reverting the one line so the seam is safe if it's ever wired up.

RequestContextHolder: ThreadLocalInheritableThreadLocal introduces a cross-request identity leak

This PR changes HOLDER to an InheritableThreadLocal. Inheritance happens at thread construction: any pooled thread created while a request is in flight permanently snapshots that request's principal. AuthFilter's finally { clear() } only clears the Tomcat thread — nothing ever clears the pool worker — so every later task landing on that worker (another user's request, a background job) observes the wrong identity.

I validated this end-to-end against this branch (pr/embedded-secrets-server-refactor): real server boot, real HTTP, real AuthFilter, an executor hand-off mid-request (the same pattern the SSE/streaming handlers use).

Run 1 — this branch, unmodified:

RequestContextLeakE2ETest > pooledThread_neverObservesAnotherRequestsIdentity() FAILED
    [a pooled thread must observe no request context; observing request
     '459fb227-c002-4abd-b266-44cedc2d729f' (Alice's) means her identity leaked into Bob's work]
    expected: "none"
     but was: "459fb227-c002-4abd-b266-44cedc2d729f"

Request 2's pooled work carries request 1's exact requestId, after request 1 completed and was cleared.

Run 2 — identical branch with only line 17 reverted to new ThreadLocal<>(): BUILD SUCCESSFUL (same test, green). A unit-level variant with a control test (worker created outside any request never inherits) confirms the mechanism is creation-time inheritance specifically.

This never shows up in single-user testing — the pool warms under one identity, so the stale value happens to be "right" — and only misattributes under concurrent multi-user load, which is the worst time to discover it. Nothing in this PR appears to need the inheritance (the context consumers — masking advice, skill ownership, createdBy — are all deleted in the same diff). If an async path does need the caller's identity, the safe pattern is per-task propagation (capture at submit, set/clear around the task, e.g. a Spring TaskDecorator), never thread-construction inheritance.

Suggested fix: revert line 17 to new ThreadLocal<>().

E2E test (drop-in: conductor-agentspan-server/src/test/java/dev/agentspan/runtime/context/RequestContextLeakE2ETest.java)
/*
 * Copyright (c) 2025 AgentSpan
 * Licensed under the MIT License.
 */
package dev.agentspan.runtime.context;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import dev.agentspan.runtime.AgentRuntime;

/**
 * End-to-end guard for {@link RequestContextHolder}'s principal-isolation contract, driven over
 * real HTTP through the real server: real Tomcat worker threads, the real {@code AuthFilter}
 * setting and clearing the context, and an executor hand-off mid-request — the pattern the
 * production SSE/streaming handlers use.
 */
@SpringBootTest(classes = AgentRuntime.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(RequestContextLeakE2ETest.LeakProbeController.class)
@ActiveProfiles("test")
class RequestContextLeakE2ETest {

    @Autowired
    private TestRestTemplate http;

    /**
     * Stand-in for any production handler that hands work to a shared executor during a request
     * (SSE emitters, async fan-out). Test-only endpoint; the threading pattern is the real one.
     */
    @RestController
    static class LeakProbeController {

        /** Shared pool, cold until first use — like every lazily-grown server pool. */
        private final ExecutorService pool = Executors.newSingleThreadExecutor();

        /**
         * Runs on a Tomcat thread with the real AuthFilter's context set. Submitting to the cold
         * pool constructs the pool's worker thread HERE, mid-request. Returns this request's id.
         */
        @GetMapping("/api/test/leak/spawn")
        String spawn() throws Exception {
            pool.submit(() -> {}).get(5, TimeUnit.SECONDS);
            return RequestContextHolder.get().map(RequestContext::getRequestId).orElse("none");
        }

        /** Reports what request context the POOLED thread (not this Tomcat thread) observes. */
        @GetMapping("/api/test/leak/probe")
        String probe() throws Exception {
            return pool.submit(() -> RequestContextHolder.get()
                            .map(RequestContext::getRequestId)
                            .orElse("none"))
                    .get(5, TimeUnit.SECONDS);
        }
    }

    @Test
    void pooledThread_neverObservesAnotherRequestsIdentity() {
        // ── Request 1: "Alice". Real HTTP; AuthFilter sets her context on the Tomcat
        // thread; the handler's executor hand-off births the pool worker mid-request;
        // AuthFilter's finally clears the Tomcat thread when her request completes.
        String aliceRequestId = http.getForObject("/api/test/leak/spawn", String.class);
        assertThat(aliceRequestId).as("sanity: AuthFilter populated the context").isNotEqualTo("none");

        // ── Request 2: "Bob". A different request (different requestId) runs work on
        // the same pooled thread and asks what identity it carries.
        String observed = http.getForObject("/api/test/leak/probe", String.class);

        assertThat(observed)
                .as("a pooled thread must observe no request context; observing request '%s' "
                        + "(Alice's) means her identity leaked into Bob's work", aliceRequestId)
                .isEqualTo("none");
    }
}

Run with: ./gradlew :conductor-agentspan-server:test --tests 'dev.agentspan.runtime.context.RequestContextLeakE2ETest'

@v1r3n v1r3n merged commit fb359f1 into main Jul 13, 2026
3 checks passed
@v1r3n v1r3n deleted the pr/embedded-secrets-server-refactor branch July 13, 2026 06:54

@shaileshpadave shaileshpadave left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants