Skip to main content
← Back to list
01Issue
BugClosedSwamp CLIPublic
AssigneesNone

Relationships

#2436 serve: boot token-secret migration deletes the source vault item

Opened by swamp_lord · 9/23/2026

Summary

On every boot, migrateTokenSecrets copies a server token's secret from a user vault into _token-secrets, then deletes it from the user vault. Anything else reading that vault item (CI, another service) breaks at the next restart, with nothing in the logs about the deletion.

Environment

  • swamp main @ 1da85ec8, unmodified. migrateTokenSecrets is exercised directly with in-memory vault and data fakes.
  • Seen in production on swamp 20260910 (core 375d7eca) with a @swamp/1password@2026.09.10.0 user vault.

Steps to reproduce

  1. A server token ci-deploy whose record has vaultName: team-vault, secretKey: server-token-ci-deploy (a legacy / user-vault-backed token).
  2. An external system (e.g. CI) reads team-vault/server-token-ci-deploy.
  3. Start or restart serve → log Migrated token secrets for "ci-deploy".
  4. The item is gone from team-vault, and CI fails because the item doesn't exist.

A self-contained test calling migrateTokenSecrets from a swamp checkout (migration_deletes_vault_item_test.ts):

// serve's boot-time token secret migration deletes the source vault item.
//
// Runs swamp main's own migrateTokenSecrets() (src/serve/token_secret_migration.ts)
// with in-memory fakes: a "legacy" server token whose secret lives in a user vault
// ("team-vault", e.g. a 1Password vault that CI also reads). After migration the
// secret is copied to _token-secrets AND deleted from the user vault.
//
//   SWAMP_SRC=<swamp checkout> deno test -A migration_deletes_vault_item_test.ts
import { assertEquals } from "jsr:@std/assert@1";

const SRC = Deno.env.get("SWAMP_SRC")!;
const { migrateTokenSecrets } = await import(`${SRC}/src/serve/token_secret_migration.ts`);
const { TOKEN_SECRETS_VAULT_NAME } = await import(`${SRC}/src/domain/vaults/control_plane_vault_provider.ts`);

class FakeVaults {
  store = new Map<string, Map<string, string>>();
  log: string[] = [];
  constructor(init: Record<string, Record<string, string>>) {
    for (const [v, kv] of Object.entries(init)) this.store.set(v, new Map(Object.entries(kv)));
  }
  get(vault: string, key: string) {
    const v = this.store.get(vault)?.get(key);
    if (v === undefined) return Promise.reject(new Error(`missing ${vault}/${key}`));
    return Promise.resolve(v);
  }
  put(vault: string, key: string, value: string) {
    if (!this.store.has(vault)) this.store.set(vault, new Map());
    this.store.get(vault)!.set(key, value);
    this.log.push(`put ${vault}/${key}`);
    return Promise.resolve();
  }
  supportsDelete(_vault: string) { return true; }
  delete(vault: string, key: string) {
    this.store.get(vault)?.delete(key);
    this.log.push(`delete ${vault}/${key}`);
    return Promise.resolve();
  }
}

Deno.test("migrateTokenSecrets deletes the user-vault item after copying it", async () => {
  const vaults = new FakeVaults({ "team-vault": { "server-token-ci-deploy": "s3cr3t" } });
  const now = new Date().toISOString();
  const later = new Date(Date.now() + 86_400_000).toISOString();
  const record = {
    attributes: {
      name: "ci-deploy", state: "active", principalId: "user:ci", principalEmail: "ci@example.com",
      collectives: [], groups: [], createdAt: now, expiresAt: later,
      vaultName: "team-vault", secretKey: "server-token-ci-deploy",
    },
  };
  const result = await migrateTokenSecrets({
    tokenSecretsVaultName: TOKEN_SECRETS_VAULT_NAME,
    // deno-lint-ignore no-explicit-any
    vaultService: vaults as any,
    // deno-lint-ignore no-explicit-any
    dataQueryService: { query: () => Promise.resolve([record]) } as any,
    updateTokenVaultName: () => Promise.resolve(),
  });

  console.log("result:", JSON.stringify(result));
  console.log("vault operations:", vaults.log.join(" | "));
  console.log("team-vault still has server-token-ci-deploy:", vaults.store.get("team-vault")!.has("server-token-ci-deploy"));

  assertEquals(result.migrated, 1);
  assertEquals(vaults.store.get(TOKEN_SECRETS_VAULT_NAME)!.get("server-token-ci-deploy"), "s3cr3t");
  // The behaviour this issue is about: the external consumer's item is gone.
  assertEquals(vaults.store.get("team-vault")!.has("server-token-ci-deploy"), false);
});

Run it with:

SWAMP_SRC=<swamp checkout> deno test -A --config $SWAMP_SRC/deno.json migration_deletes_vault_item_test.ts

Expected

The source item is left in place by default. Or deletion is opt-in, and it's logged when an external item is removed.

Evidence

result: {"migrated":1,"skipped":0,"failed":0}
vault operations: put _token-secrets/server-token-ci-deploy | delete team-vault/server-token-ci-deploy | delete team-vault/oauth-access-token-ci-deploy
team-vault still has server-token-ci-deploy: false

Root cause (on main)

src/serve/token_secret_migration.ts (≈L112–121): after the copy, if vaultService.supportsDelete(token.vaultName), it calls delete(token.vaultName, token.secretKey) and delete(token.vaultName, oauthKey) unconditionally.

Also

When a token name has duplicate records and one still references a vault item that was already deleted, L81 logs Token secret missing from vault for <name>, skipping, even though another record with that name is valid.

#2113 (shipped, consistency-sweep false positives), #2235 (shipped, token data durability).

Reproduction configuration

Test environment

swamp built from main @ 1da85ec847ef6a8a0ce0297563b2a9fab1f4b2b8, unmodified (20260923.175528.0-sha.1da85ec8)
Datastore @swamp/s3-datastore@2026.09.23.1 on local MinIO (http://127.0.0.1:19000, bucket swamp-repro)
Vault none configured; control-plane _token-secrets only
Instances each serve instance starts with an empty HOME and a repo containing only .swamp.yaml

Exercised directly: migrateTokenSecrets from src/serve/token_secret_migration.ts with in-memory vault and data fakes (user vault team-vault holding server-token-ci-deploy).

02Bog Flow
✓OPEN○TRIAGED○IN PROGRESS◉CLOSED

Closed

9/23/2026, 7:48:28 PM

No activity in this phase yet.

03Sludge Pulse
Editable. Press Enter to edit.

stack72 commented 9/23/2026, 7:48:27 PM

This is expected behavior, not a defect.

serve owns server access-token credentials. The startup migration only applies to legacy tokens whose credentials still live outside _token-secrets; it moves the credentials to the encrypted control-plane vault, updates the token record, and removes the legacy copies.

For each migrated token, the only source-vault keys removed are the token’s server-token- secret and its paired oauth-access-token- credential, if present. It does not enumerate or delete unrelated vault items.

A user vault was a previous storage location for these serve-owned credentials, not a supported shared-secret interface. External systems should not read those server-token-* or oauth-access-token-* entries directly. CI requiring its own credential should use a separately managed secret.

Sign in to post a ripple.