From 94bf514f62390d54de1d3dbd953a889ab35c2078 Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Fri, 21 Aug 2026 13:36:54 +0530 Subject: [PATCH 1/3] Rotate a credential in one transaction Rotation was two independent store calls: insert the new credential, then revoke the previous one. A failure on the revoke returned an error to the caller and left the new credential live in the vault, where nothing referenced it and nothing said it was there. Retrying wrote another one. Compensating for that in the caller does not work, and the first attempt at this tried. `revoke` can commit its UPDATE and still throw on the way back, from a statement timeout, a dropped connection, or the pool being torn down as the response returns; a compensating revoke then retires the new credential on top of a previous one that really was revoked, and the key is left with nothing live. The same fault that broke the first revoke is the one most likely to break the compensating one, so the recovery is least available exactly when it is needed. And nothing in the process survives the process: killed between the two writes, no compensation runs at all. So the two writes are now one. `CredentialStore.rotate` opens a single transaction, locks the previous credential `FOR UPDATE`, revokes it and inserts its replacement, following the shape already used for agent profiles in `agents/profile-store.ts`. Either both land or neither does, Postgres decides that rather than this code, and a process that dies mid-rotation leaves a database that rolls itself back. `credential.rotated` is recorded only once the transaction has returned, so a rotation that never happened leaves no row claiming it did. Two guards come with it. The lock reads the previous credential's kind, provider and keyId as well as its state, and refuses a rotation whose input names a different key: `POST /api/admin/credentials/:id/rotate` takes that id straight from the URL while the key it rotates into comes from the body, so without the check a mismatched pair would retire one key's only live credential and store the new secret under another. `revoke` now stamps only a row that is still live and says "not found or already revoked" otherwise, instead of overwriting an existing timestamp and reporting success, which is what let two callers each believe they were the one who retired a credential. Tests cover both layers: that a failed rotation writes no audit event, and against Postgres that a rotation retires the previous credential and stores the new one together, that an already-revoked or absent previous credential is refused with nothing written, that a mismatched key is refused with the previous credential left live, and that revoking twice is refused. --- server/src/credentials.ts | 140 ++++++++++++++++++--- server/tests/credentials.test.ts | 206 ++++++++++++++++++++++++++++++- 2 files changed, 330 insertions(+), 16 deletions(-) diff --git a/server/src/credentials.ts b/server/src/credentials.ts index af3cc901..cffc2048 100644 --- a/server/src/credentials.ts +++ b/server/src/credentials.ts @@ -35,14 +35,27 @@ type StoredCredential = { revokedAt: Date | null; }; +export type CredentialStoreValue = { + kind: CredentialKind; + provider: string; + keyId: string; + metadata: Record; + encryptedValue: string; +}; + export type CredentialStore = { - create: (value: { - kind: CredentialKind; - provider: string; - keyId: string; - metadata: Record; - encryptedValue: string; - }) => Promise; + create: (value: CredentialStoreValue) => Promise; + /** + * Replace one credential with another, as a single database transaction. + * + * Retiring the old secret and storing the new one are one decision, so they + * are one write. Two separate calls cannot be made safe from the outside: + * an `UPDATE` can commit and still throw on the way back, and no compensating + * revoke survives the process being killed between them. + */ + rotate: ( + input: CredentialStoreValue & { previousCredentialId: string }, + ) => Promise; revoke: (id: string) => Promise; }; @@ -175,16 +188,98 @@ export function createCredentialStore( } return credential; }, + rotate: async (input) => { + return database.transaction(async (transaction) => { + /** + * The previous credential, locked for the rest of the transaction. + * + * Two replicas rotating the same secret would otherwise both read it + * as live and both act; the second waits here and then finds it + * revoked. Its identity is read alongside its state so a rotation + * aimed at the wrong id is refused rather than silently retiring a + * secret the caller never named. + */ + const [previous] = await transaction + .select({ + revokedAt: credentials.revokedAt, + kind: credentials.kind, + provider: credentials.provider, + keyId: credentials.keyId, + }) + .from(credentials) + .where(eq(credentials.id, input.previousCredentialId)) + .for("update"); + if (!previous) { + throw new Error("Previous credential was not found"); + } + if (previous.revokedAt) { + throw new Error("Previous credential is already revoked"); + } + if ( + previous.kind !== input.kind || + previous.provider !== input.provider || + previous.keyId !== input.keyId + ) { + throw new Error( + "Previous credential does not match the input's kind, provider or keyId", + ); + } + + // Revoke, then insert. Both orders are invisible from outside the + // transaction, and this one never holds two live rows for one key even + // in the middle of it, which is the invariant a uniqueness constraint + // on live credentials would later depend on. + const revokedAt = new Date(); + const [revoked] = await transaction + .update(credentials) + .set({ revokedAt, updatedAt: revokedAt }) + .where( + and( + eq(credentials.id, input.previousCredentialId), + isNull(credentials.revokedAt), + ), + ) + .returning({ revokedAt: credentials.revokedAt }); + if (!revoked?.revokedAt) { + // Ruled out by the lock above under contention; reaching here means + // the row was deleted outright between the two statements, which + // aborts the transaction and leaves nothing committed. + throw new Error("Previous credential was not found"); + } + + const [inserted] = await transaction + .insert(credentials) + .values({ + kind: input.kind, + provider: input.provider, + keyId: input.keyId, + metadata: input.metadata, + encryptedValue: input.encryptedValue, + }) + .returning({ + id: credentials.id, + revokedAt: credentials.revokedAt, + }); + if (!inserted) { + throw new Error("Credential could not be stored"); + } + + return inserted; + }); + }, revoke: async (id) => { const revokedAt = new Date(); const [credential] = await database .update(credentials) .set({ revokedAt, updatedAt: revokedAt }) - .where(eq(credentials.id, id)) + // Only a live row is stamped. Without the guard a second revoke would + // overwrite the first one's timestamp and report success, so a caller + // could not tell retiring a credential from finding it already gone. + .where(and(eq(credentials.id, id), isNull(credentials.revokedAt))) .returning({ revokedAt: credentials.revokedAt }); if (!credential?.revokedAt) { - throw new Error("Credential was not found"); + throw new Error("Credential was not found or already revoked"); } return credential.revokedAt; }, @@ -310,14 +405,24 @@ export async function createCredential( export async function rotateCredential( service: CredentialService, input: CredentialInput & { previousCredentialId: string }, -) { - const credential = await persistCredential(service, input); - await service.store.revoke(input.previousCredentialId); +): Promise { + // Encryption happens before the transaction opens, so no database connection + // is held while it runs. The store then performs both writes atomically, and + // a failure leaves the vault as it was, which is why the audit event below is + // written only once that has returned. + const stored = await service.store.rotate({ + previousCredentialId: input.previousCredentialId, + kind: input.kind, + provider: input.provider, + keyId: input.keyId, + metadata: input.metadata, + encryptedValue: await encryptSecret(service.encryptionKey, input.plaintext), + }); await recordAuditEvent(service.auditStore, { eventType: "credential.rotated", targetType: "credential", - targetId: credential.id, + targetId: stored.id, actorUserId: input.actorUserId, payload: { previousCredentialId: input.previousCredentialId, @@ -327,7 +432,14 @@ export async function rotateCredential( }, }); - return credential; + return { + id: stored.id, + kind: input.kind, + provider: input.provider, + keyId: input.keyId, + metadata: input.metadata, + revokedAt: stored.revokedAt, + }; } export async function revokeCredential( diff --git a/server/tests/credentials.test.ts b/server/tests/credentials.test.ts index de7acd1a..7edc49d3 100644 --- a/server/tests/credentials.test.ts +++ b/server/tests/credentials.test.ts @@ -102,12 +102,17 @@ describe("credential encryption", () => { }); test("rotates then revokes credentials without returning plaintext", async () => { + const rotated: { previousCredentialId: string }[] = []; const revoked: string[] = []; const audited: unknown[] = []; const service = { encryptionKey: key, store: { - create: async () => ({ id: "credential-new", revokedAt: null }), + create: async () => ({ id: "credential-unused", revokedAt: null }), + rotate: async (input: { previousCredentialId: string }) => { + rotated.push(input); + return { id: "credential-new", revokedAt: null }; + }, revoke: async (id: string) => { revoked.push(id); return new Date("2026-08-13T12:00:00.000Z"); @@ -131,13 +136,59 @@ describe("credential encryption", () => { }); await revokeCredential(service, "credential-new", "admin"); - expect(revoked).toEqual(["credential-old", "credential-new"]); + // The rotation retires the previous credential inside the store's own + // transaction, so the service makes no separate revoke call of its own. + expect(rotated.map((call) => call.previousCredentialId)).toEqual([ + "credential-old", + ]); + expect(revoked).toEqual(["credential-new"]); + expect(JSON.stringify(rotated)).not.toContain("new-openai-secret"); expect(JSON.stringify(audited)).not.toContain("new-openai-secret"); expect( audited.map((event) => (event as { eventType: string }).eventType), ).toEqual(["credential.rotated", "credential.revoked"]); }); + test("writes no audit event when the rotation fails", async () => { + // The store's rotate is one transaction, so a failure commits nothing. The + // trail has to agree: a rotation that did not happen leaves no row saying + // it did, and the caller sees the original cause rather than a later one. + const audited: unknown[] = []; + const service = { + encryptionKey: key, + store: { + create: async () => { + throw new Error("create is not part of a rotation"); + }, + rotate: async () => { + throw new Error("Previous credential is already revoked"); + }, + revoke: async () => { + throw new Error("revoke is not part of a failed rotation"); + }, + }, + auditStore: { + insert: async (event: unknown) => { + audited.push(event); + }, + }, + }; + + await expect( + rotateCredential(service, { + previousCredentialId: "credential-old", + kind: "model", + provider: "openai", + keyId: "primary", + metadata: {}, + plaintext: "new-openai-secret", + actorUserId: "admin", + }), + ).rejects.toThrow("Previous credential is already revoked"); + + expect(audited).toEqual([]); + }); + test("decrypts only an active credential for server-side use", async () => { const encryptedValue = await encryptSecret(key, "connector-secret"); @@ -304,6 +355,157 @@ describe("model credential store lookup", () => { }); }); +describe("credential store rotation", () => { + test("retires the previous credential and stores the new one together", async () => { + const store = createCredentialStore(database); + const previousId = randomUUID(); + const keyId = `rotation-atomic-${previousId}`; + credentialIds.push(previousId); + await database.insert(credentials).values({ + id: previousId, + kind: "model", + provider: "openai", + keyId, + encryptedValue: "initial", + metadata: {}, + }); + + const rotated = await store.rotate({ + previousCredentialId: previousId, + kind: "model", + provider: "openai", + keyId, + metadata: {}, + encryptedValue: "rotated", + }); + credentialIds.push(rotated.id); + + const rows = await database + .select({ + id: credentials.id, + encryptedValue: credentials.encryptedValue, + revokedAt: credentials.revokedAt, + }) + .from(credentials) + .where(eq(credentials.keyId, keyId)); + const byId = Object.fromEntries(rows.map((row) => [row.id, row])); + + expect(byId[previousId]?.revokedAt).not.toBeNull(); + expect(byId[rotated.id]?.revokedAt).toBeNull(); + expect(byId[rotated.id]?.encryptedValue).toBe("rotated"); + }); + + test("refuses a previous credential that is already revoked, and stores nothing", async () => { + const store = createCredentialStore(database); + const previousId = randomUUID(); + const keyId = `rotation-revoked-${previousId}`; + credentialIds.push(previousId); + await database.insert(credentials).values({ + id: previousId, + kind: "model", + provider: "openai", + keyId, + encryptedValue: "initial", + metadata: {}, + revokedAt: new Date("2026-08-01T00:00:00.000Z"), + }); + + await expect( + store.rotate({ + previousCredentialId: previousId, + kind: "model", + provider: "openai", + keyId, + metadata: {}, + encryptedValue: "rotated", + }), + ).rejects.toThrow("already revoked"); + + const rows = await database + .select({ id: credentials.id }) + .from(credentials) + .where(eq(credentials.keyId, keyId)); + expect(rows.map((row) => row.id)).toEqual([previousId]); + }); + + test("refuses a previous credential that does not exist, and stores nothing", async () => { + const store = createCredentialStore(database); + const missing = randomUUID(); + const keyId = `rotation-missing-${missing}`; + + await expect( + store.rotate({ + previousCredentialId: missing, + kind: "model", + provider: "openai", + keyId, + metadata: {}, + encryptedValue: "orphan-if-broken", + }), + ).rejects.toThrow("not found"); + + // Nothing was written, so the failed rotation left no credential behind + // for this key at all. + const rows = await database + .select({ id: credentials.id }) + .from(credentials) + .where(eq(credentials.keyId, keyId)); + expect(rows).toEqual([]); + }); + + test("refuses a rotation aimed at a different kind, provider or keyId", async () => { + // Rotating one credential with another's identity would retire a secret + // the caller never named and leave its key with nothing live, which is a + // worse outcome than a raised error. + const store = createCredentialStore(database); + const previousId = randomUUID(); + const previousKey = `mismatch-previous-${previousId}`; + credentialIds.push(previousId); + await database.insert(credentials).values({ + id: previousId, + kind: "model", + provider: "openai", + keyId: previousKey, + encryptedValue: "previous", + metadata: {}, + }); + + await expect( + store.rotate({ + previousCredentialId: previousId, + kind: "model", + provider: "openai", + keyId: `mismatch-input-${previousId}`, + metadata: {}, + encryptedValue: "would-retire-the-wrong-secret", + }), + ).rejects.toThrow("does not match"); + + const [after] = await database + .select({ revokedAt: credentials.revokedAt }) + .from(credentials) + .where(eq(credentials.id, previousId)); + expect(after?.revokedAt).toBeNull(); + }); + + test("refuses to revoke a credential that is already revoked", async () => { + const store = createCredentialStore(database); + const id = randomUUID(); + credentialIds.push(id); + await database.insert(credentials).values({ + id, + kind: "model", + provider: "openai", + keyId: `revoke-guard-${id}`, + encryptedValue: "one", + metadata: {}, + revokedAt: new Date("2026-08-01T00:00:00.000Z"), + }); + + await expect(store.revoke(id)).rejects.toThrow("already revoked"); + }); +}); + describe("admin credential API", () => { test("returns only credential status and metadata", async () => { const app = createApp( From f2950aa9a3d2034606b14ca330eae64fc66d63af Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Fri, 21 Aug 2026 14:13:47 +0530 Subject: [PATCH 2/3] Hold one live credential per key, and let every caller keep to it A key was free to accumulate live credentials. Nothing said which of them a deployment meant, `readModelSecret` picked the newest and every other reader followed a stored id, and the failed rotations in #53 left exactly this behind: a live row nothing referenced, invisible until somebody went looking. Two replicas rotating the same secret could also both write one, since nothing serialised them. `credentials_active_key_idx` makes it a rule the database keeps: unique on (kind, provider, key_id) where revoked_at is null. Revoked rows are excluded, so history is untouched and only what is current is constrained. Existing databases have to be reconciled before that index can be built, and which duplicate survives decides whether a deployment comes back up working. The newest is the wrong answer: in the failure this cleans up it is the new row that nothing references, while the older one is still named by the connector, MCP server or agent that was using it, so keeping the newest revokes the credential actually in use. The backfill ranks a referenced row first and falls back to the newest only where nothing points at either. Three callers stored a new credential for a key without retiring what was there. All three leaked orphans already; under the index they would have failed outright. `storeAgentAuth` rotates when the agent has a live credential and inserts when it does not, checking liveness rather than trusting the reference: an administrator can revoke a key from the Credentials page, nothing repoints the agent that names it, and rotating onto a revoked row is refused, so trusting it would leave that agent's key impossible to replace. It also takes the caller's transaction now. Agent edits are a transaction over `agents` and `agent_profiles` and the credential belongs to that same change; written on a pooled connection of its own it would commit even where the edit rolled back, and could deadlock against the locks that edit is holding. `configureGoogleDrive` retires the credential a reconfigure abandons when the impersonation subject changes. That row keeps its own key and is referenced by nothing afterwards, so a subject set, changed, and set back again would meet it again on the index. `removeServer` revokes the token before deleting the server row, so adding the same server again does not meet its own leftover. The revoke goes first deliberately: these are two tables with no transaction spanning them, and a failure between them should leave a server that removing again will finish off rather than a live token nothing can reach. The fourth caller is the one the product uses most. The Credentials page offers Add and Revoke and no rotate control, and `rotate` has no client caller at all, so replacing a model key is done by adding one for the same provider and keyId. `createCredential` therefore treats a key that already holds a live credential as a replacement and rotates, which is atomic and records `credential.rotated` naming what it replaced, rather than raising a bare unique violation on the only path the page offers. Tests cover each caller and the rule itself: an agent key created, rotated, and created again over a revoked reference; a Google Drive reconfigure under the same subject and under a changed one, including setting the original back; an MCP server removal revoking its token; the index refusing a second live row; and adding a credential for an occupied key replacing what was there. The connector tests run against the real vault rather than a stand-in, which is what would have caught the drift here in the first place. --- .../drizzle/0012_credentials_one_live_key.sql | 53 +++++++ server/src/agents/auth-header.ts | 43 +++++- server/src/agents/profile-store.ts | 8 ++ server/src/credentials.ts | 109 +++++++++++++-- server/src/db/schema/core.ts | 33 +++-- server/src/plugins/store.ts | 61 ++++++++ server/tests/agent-auth-header.test.ts | 99 +++++++++++++ server/tests/credentials.test.ts | 131 +++++++++++++----- server/tests/plugin-store.integration.test.ts | 99 ++++++++++++- 9 files changed, 575 insertions(+), 61 deletions(-) create mode 100644 server/drizzle/0012_credentials_one_live_key.sql create mode 100644 server/tests/agent-auth-header.test.ts diff --git a/server/drizzle/0012_credentials_one_live_key.sql b/server/drizzle/0012_credentials_one_live_key.sql new file mode 100644 index 00000000..120ef123 --- /dev/null +++ b/server/drizzle/0012_credentials_one_live_key.sql @@ -0,0 +1,53 @@ +-- One live credential per (kind, provider, key_id). +-- +-- A database that ran the rotation this repository shipped before may already +-- hold more than one, because that rotation inserted the new credential and +-- then revoked the previous one as two separate statements: when the revoke +-- failed the caller saw an error and wrote nothing further, so the new row was +-- left live and nothing was repointed at it. CREATE UNIQUE INDEX would fail +-- outright on those rows, so they are reconciled first. +-- +-- Which duplicate survives matters, and the newest is the wrong answer. In the +-- failure above it is the new row that nothing references, while the older one +-- is still named by the MCP server, the connection or the agent that was using +-- it. Keeping the newest would revoke the credential actually in use and leave +-- the deployment authenticating with nothing. So a referenced row wins, and +-- only where nothing is referenced does the newest win. +-- +-- The three tables below are every one that names a credential: `mcp_servers` +-- for a server's own token and OAuth client, `mcp_user_credentials` for one +-- person's connection to a server, and an agent's `configuration`. The old +-- connector tables named one too and were dropped in `0011`. +-- +-- On a database with no duplicates this rewrites no rows. +WITH referenced AS ( + SELECT "credential_id"::text AS "id" + FROM "mcp_servers" + WHERE "credential_id" IS NOT NULL + UNION + SELECT "credential_id"::text + FROM "mcp_user_credentials" + WHERE "credential_id" IS NOT NULL + UNION + SELECT "configuration" -> 'auth' ->> 'credentialId' + FROM "agents" + WHERE "configuration" -> 'auth' ->> 'credentialId' IS NOT NULL +), +ranked AS ( + SELECT c."id", + row_number() OVER ( + PARTITION BY c."kind", c."provider", c."key_id" + ORDER BY (r."id" IS NOT NULL) DESC, c."created_at" DESC, c."id" DESC + ) AS "rank" + FROM "credentials" c + LEFT JOIN referenced r ON r."id" = c."id"::text + WHERE c."revoked_at" IS NULL +) +UPDATE "credentials" AS c + SET "revoked_at" = now(), + "updated_at" = now() + FROM ranked + WHERE ranked."id" = c."id" + AND ranked."rank" > 1; +--> statement-breakpoint +CREATE UNIQUE INDEX "credentials_active_key_idx" ON "credentials" USING btree ("kind","provider","key_id") WHERE "credentials"."revoked_at" IS NULL; diff --git a/server/src/agents/auth-header.ts b/server/src/agents/auth-header.ts index 83346903..9fab2095 100644 --- a/server/src/agents/auth-header.ts +++ b/server/src/agents/auth-header.ts @@ -1,4 +1,5 @@ import { + type CredentialExecutor, type CredentialSecretReader, type CredentialStore, decryptSecret, @@ -58,16 +59,52 @@ export async function storeAgentAuth(input: { agentId: string; header: string; value: string; + /** + * The credential the agent's configuration currently names, if any. Present + * on an edit that replaces the key, absent on first creation. + * + * Whether it is still live is checked here rather than assumed, because the + * configuration keeps naming a credential an administrator has revoked from + * the Credentials page. Rotating onto a revoked row is refused by the vault, + * so trusting the reference would leave that agent's key impossible to + * replace: every later edit would fail on the same stale id. + */ + previousCredentialId?: string; + /** + * The transaction to write in, when the caller has one. + * + * Agent edits are a transaction over `agents` and `agent_profiles`, and the + * credential is part of that same change. Written outside it, the key would + * commit even where the edit that asked for it rolled back. + */ + executor?: CredentialExecutor; }): Promise { - const credential = await input.store.create({ - kind: "agent", + const value = { + kind: "agent" as const, provider: "ag-ui", keyId: input.agentId, // The header name is metadata precisely because it is not a secret; keeping it here makes the // vault row self-describing for later audit. metadata: { header: input.header }, encryptedValue: await encryptSecret(input.encryptionKey, input.value), - }); + }; + + // A live previous credential is rotated, so the agent never holds two. Any + // other case inserts: the partial unique index permits it, because there is + // no live row for this agent to collide with. + const rotates = + input.previousCredentialId !== undefined && + (await input.store.isLive(input.previousCredentialId, input.executor)); + + const credential = rotates + ? await input.store.rotate( + { + ...value, + previousCredentialId: input.previousCredentialId as string, + }, + input.executor, + ) + : await input.store.create(value, input.executor); return { header: input.header, credentialId: credential.id }; } diff --git a/server/src/agents/profile-store.ts b/server/src/agents/profile-store.ts index 5b055fdd..c1953056 100644 --- a/server/src/agents/profile-store.ts +++ b/server/src/agents/profile-store.ts @@ -329,6 +329,7 @@ export function createAgentProfileStore( agentId: id, header: input.auth.header, value: input.auth.value, + executor: transaction, }), } : {}), @@ -387,6 +388,13 @@ export function createAgentProfileStore( agentId: id, header: input.auth.header, value: input.auth.value, + // An agent that already has a live key is being edited, not + // first-created, so the vault rotates rather than inserting + // a second live row for the same agent id. + previousCredentialId: authFromConfiguration( + row?.configuration, + )?.credentialId, + executor: transaction, }), } : {}), diff --git a/server/src/credentials.ts b/server/src/credentials.ts index cffc2048..39383f71 100644 --- a/server/src/credentials.ts +++ b/server/src/credentials.ts @@ -43,20 +43,56 @@ export type CredentialStoreValue = { encryptedValue: string; }; +type Transaction = Parameters[0]>[0]; + +/** + * Where a credential write runs. + * + * A caller already inside a transaction passes it here, so the write joins that + * transaction rather than opening one of its own on a second pooled connection. + * Two connections would mean the credential committing separately from the + * change that asked for it, and, since the caller is usually holding row locks + * by then, a pool with nothing spare to hand out deadlocks instead. See the + * note on `max` in `db/client.ts`. + */ +export type CredentialExecutor = + | Pick + | Pick; + export type CredentialStore = { - create: (value: CredentialStoreValue) => Promise; + create: ( + value: CredentialStoreValue, + executor?: CredentialExecutor, + ) => Promise; /** - * Replace one credential with another, as a single database transaction. + * Replace one credential with another, atomically. * * Retiring the old secret and storing the new one are one decision, so they * are one write. Two separate calls cannot be made safe from the outside: * an `UPDATE` can commit and still throw on the way back, and no compensating * revoke survives the process being killed between them. + * + * Without an executor this opens its own transaction. With one it runs inside + * the caller's, and is atomic with whatever else that transaction is doing. */ rotate: ( input: CredentialStoreValue & { previousCredentialId: string }, + executor?: CredentialExecutor, ) => Promise; - revoke: (id: string) => Promise; + revoke: (id: string, executor?: CredentialExecutor) => Promise; + /** Whether this credential exists and has not been revoked. */ + isLive: (id: string, executor?: CredentialExecutor) => Promise; + /** + * The live credential for a key, if this deployment holds one. + * + * At most one can exist, which is what `credentials_active_key_idx` + * enforces, so a caller about to store a secret for a key can ask whether it + * is replacing something rather than finding out from a failed insert. + */ + findLiveByKey: ( + key: { kind: CredentialKind; provider: string; keyId: string }, + executor?: CredentialExecutor, + ) => Promise<{ id: string } | null>; }; export type CredentialSecretReader = { @@ -177,8 +213,8 @@ export function createCredentialStore( CredentialStatusReader & ModelCredentialSecretReader { return { - create: async (value) => { - const [credential] = await database + create: async (value, executor = database) => { + const [credential] = await executor .insert(credentials) .values(value) .returning({ id: credentials.id, revokedAt: credentials.revokedAt }); @@ -188,8 +224,8 @@ export function createCredentialStore( } return credential; }, - rotate: async (input) => { - return database.transaction(async (transaction) => { + rotate: async (input, executor) => { + const write = async (transaction: CredentialExecutor) => { /** * The previous credential, locked for the rest of the transaction. * @@ -265,11 +301,15 @@ export function createCredentialStore( } return inserted; - }); + }; + + // A caller already in a transaction has its own atomicity to keep, and + // the credential belongs to it rather than to a transaction of its own. + return executor ? write(executor) : database.transaction(write); }, - revoke: async (id) => { + revoke: async (id, executor = database) => { const revokedAt = new Date(); - const [credential] = await database + const [credential] = await executor .update(credentials) .set({ revokedAt, updatedAt: revokedAt }) // Only a live row is stamped. Without the guard a second revoke would @@ -283,6 +323,29 @@ export function createCredentialStore( } return credential.revokedAt; }, + isLive: async (id, executor = database) => { + const [credential] = await executor + .select({ id: credentials.id }) + .from(credentials) + .where(and(eq(credentials.id, id), isNull(credentials.revokedAt))); + + return credential !== undefined; + }, + findLiveByKey: async ({ kind, provider, keyId }, executor = database) => { + const [credential] = await executor + .select({ id: credentials.id }) + .from(credentials) + .where( + and( + eq(credentials.kind, kind), + eq(credentials.provider, provider), + eq(credentials.keyId, keyId), + isNull(credentials.revokedAt), + ), + ); + + return credential ?? null; + }, readSecret: async (id) => { const [credential] = await database .select({ @@ -381,10 +444,36 @@ async function persistCredential( }; } +/** + * Store a credential for a key. + * + * A key holds one live credential, so storing a second one for a key that + * already has one is a replacement rather than an addition, and it is carried + * out as a rotation: the two writes are atomic and the trail records + * `credential.rotated` naming what was replaced. + * + * This is the shape the product asks for. The Credentials page offers Add and + * Revoke and has no rotate control, so replacing a model key is done by adding + * one for the same provider and keyId. Refusing that would leave no way to + * replace a key at all, and inserting it would raise a bare unique violation + * from `credentials_active_key_idx`. + */ export async function createCredential( service: CredentialService, input: CredentialInput, ): Promise { + const existing = await service.store.findLiveByKey({ + kind: input.kind, + provider: input.provider, + keyId: input.keyId, + }); + if (existing) { + return rotateCredential(service, { + ...input, + previousCredentialId: existing.id, + }); + } + const credential = await persistCredential(service, input); await recordAuditEvent(service.auditStore, { diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts index 570147ab..35dc9ac1 100644 --- a/server/src/db/schema/core.ts +++ b/server/src/db/schema/core.ts @@ -316,17 +316,28 @@ export const channelAgents = pgTable( (table) => [primaryKey({ columns: [table.channelId, table.agentId] })], ); -export const credentials = pgTable("credentials", { - id: uuid("id").primaryKey().defaultRandom(), - kind: credentialKind("kind").notNull(), - provider: text("provider").notNull(), - encryptedValue: text("encrypted_value").notNull(), - keyId: text("key_id").notNull(), - metadata: jsonb("metadata").notNull(), - revokedAt: timestamp("revoked_at", { withTimezone: true }), - createdAt: createdAt(), - updatedAt: updatedAt(), -}); +export const credentials = pgTable( + "credentials", + { + id: uuid("id").primaryKey().defaultRandom(), + kind: credentialKind("kind").notNull(), + provider: text("provider").notNull(), + encryptedValue: text("encrypted_value").notNull(), + keyId: text("key_id").notNull(), + metadata: jsonb("metadata").notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (table) => [ + // At most one live credential per (kind, provider, key_id). Revoked rows are + // excluded so history is preserved, and two replicas racing to rotate the + // same secret cannot both insert a live row. + uniqueIndex("credentials_active_key_idx") + .on(table.kind, table.provider, table.keyId) + .where(sql`${table.revokedAt} IS NULL`), + ], +); export const auditEvents = pgTable( "audit_events", diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 5e71844a..0936fa30 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -271,6 +271,11 @@ export type PluginStoreOptions = { * client belongs to the server registration and a refresh token belongs to a connection, so both * are written by the code that owns those acts — otherwise the first of two calls can succeed and * the second fail, leaving a secret in the vault that nothing points at and nobody knows to revoke. + * + * `revoke` is part of it because a key holds at most one live credential now. `removeServer` + * retires the server's token, and the two write paths here replace rather than add, so re-adding a + * server or re-authorizing a connection does not meet its own leftover on + * `credentials_active_key_idx`. */ credentials: CredentialSecretReader & CredentialStore; encryptionKey: string; @@ -603,7 +608,63 @@ export function createPluginStore(options: PluginStoreOptions) { return added; }, + /** + * Remove a server, and stop its token being live. + * + * The token is keyed `mcp-` and nothing else revokes it, so + * leaving it behind means re-adding the same server meets its own + * abandoned row on `credentials_active_key_idx`. It is revoked rather + * than deleted, because the vault keeps revoked rows for audit. + * + * The revoke goes first. These are two writes on two tables and the + * store exposes no transaction that spans both, so the order decides + * what a failure between them leaves: revoke-then-delete leaves a server + * whose token no longer works and which removing again will finish off, + * while delete-then-revoke leaves a live token no server references and + * no operation can reach. + */ async removeServer(serverId: string, by: string): Promise { + const [existing] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + + /** + * Whether that token is still live, read rather than inferred from a + * thrown error, so a token a previous attempt already revoked, or one + * whose row is gone entirely, is skipped while a database fault still + * propagates and leaves the server row in place to be removed again. + * + * Two queries rather than a join because `mcp_servers.credential_id` is + * `text` and `credentials.id` is `uuid`, so the two columns do not + * compare without a cast. + */ + const [live] = existing?.credentialId + ? await database + .select({ id: credentialRows.id }) + .from(credentialRows) + .where( + and( + eq(credentialRows.id, existing.credentialId), + isNull(credentialRows.revokedAt), + ), + ) + : []; + + if (live) { + await credentials.revoke(live.id); + await recordAuditEvent(auditStore, { + eventType: "credential.revoked", + targetType: "credential", + targetId: live.id, + payload: { + actor: by, + reason: "mcp_server_removed", + server: serverId, + }, + }); + } + await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); await recordAuditEvent(auditStore, { eventType: "configuration.changed", diff --git a/server/tests/agent-auth-header.test.ts b/server/tests/agent-auth-header.test.ts new file mode 100644 index 00000000..3c57a97a --- /dev/null +++ b/server/tests/agent-auth-header.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { storeAgentAuth } from "../src/agents/auth-header"; +import type { CredentialStore } from "../src/credentials"; + +const key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + +/** + * An agent already has a credential when its bearer token is being edited. + * `credentials_active_key_idx` refuses two live rows for the same agent, so + * the edit path has to rotate the vault row rather than insert a duplicate. + * These tests pin which method the module reaches for in each case. + */ + +describe("storeAgentAuth", () => { + function fakeStore(options: { + live?: Set; + calls: string[]; + rotated?: unknown[]; + }): CredentialStore { + const live = options.live ?? new Set(); + return { + create: async () => { + options.calls.push("create"); + return { id: "credential-new", revokedAt: null }; + }, + rotate: async (input) => { + options.calls.push("rotate"); + options.rotated?.push(input); + return { id: "credential-rotated", revokedAt: null }; + }, + revoke: async () => new Date(), + isLive: async (id) => live.has(id), + findLiveByKey: async () => null, + }; + } + + test("creates a fresh credential when the agent has none yet", async () => { + const calls: string[] = []; + + const auth = await storeAgentAuth({ + store: fakeStore({ calls }), + encryptionKey: key, + agentId: "agent-1", + header: "Authorization", + value: "Bearer abc", + }); + + expect(calls).toEqual(["create"]); + expect(auth).toEqual({ + header: "Authorization", + credentialId: "credential-new", + }); + }); + + test("rotates the credential the agent already holds", async () => { + const calls: string[] = []; + const rotated: unknown[] = []; + + const auth = await storeAgentAuth({ + store: fakeStore({ calls, rotated, live: new Set(["credential-old"]) }), + encryptionKey: key, + agentId: "agent-1", + header: "Authorization", + value: "Bearer new", + previousCredentialId: "credential-old", + }); + + expect(calls).toEqual(["rotate"]); + const [call] = rotated as [ + { previousCredentialId: string; kind: string; keyId: string }, + ]; + expect(call.previousCredentialId).toBe("credential-old"); + expect(call.kind).toBe("agent"); + expect(call.keyId).toBe("agent-1"); + expect(JSON.stringify(rotated)).not.toContain("Bearer new"); + expect(auth.credentialId).toBe("credential-rotated"); + }); + + test("creates when the credential the agent names has been revoked", async () => { + // An administrator can revoke an agent's key from the Credentials page, + // and nothing repoints the agent's configuration when they do. Rotating + // onto that revoked row is refused by the vault, so trusting the reference + // would leave the key impossible to replace: every later edit would fail + // on the same stale id. + const calls: string[] = []; + + const auth = await storeAgentAuth({ + store: fakeStore({ calls, live: new Set() }), + encryptionKey: key, + agentId: "agent-1", + header: "Authorization", + value: "Bearer replacement", + previousCredentialId: "credential-revoked", + }); + + expect(calls).toEqual(["create"]); + expect(auth.credentialId).toBe("credential-new"); + }); +}); diff --git a/server/tests/credentials.test.ts b/server/tests/credentials.test.ts index 7edc49d3..b47140e0 100644 --- a/server/tests/credentials.test.ts +++ b/server/tests/credentials.test.ts @@ -59,7 +59,12 @@ describe("credential encryption", () => { stored.push(value); return { id: "credential-1", revokedAt: null }; }, + rotate: async () => { + throw new Error("nothing live to replace, so create is the path"); + }, revoke: async () => new Date(), + isLive: async () => false, + findLiveByKey: async () => null, }, auditStore: { insert: async (event) => { @@ -266,44 +271,23 @@ describe("model credential resolution", () => { }); describe("model credential store lookup", () => { - test("selects the newest active matching model credential with id as the timestamp tie-breaker", async () => { - const matchingOldId = randomUUID(); - const matchingLowerId = "00000000-0000-4000-8000-000000000001"; - const matchingHigherId = "00000000-0000-4000-8000-000000000002"; + test("selects the live matching model credential and ignores the rest", async () => { + // A key holds one live credential, which `credentials_active_key_idx` + // enforces, so the lookup never has more than one candidate to choose + // between. What is under test is the filter: the rows that do not match on + // kind, provider or keyId, and the revoked row for this very key, all have + // to be passed over. + const activeId = randomUUID(); const ignoredIds = [randomUUID(), randomUUID(), randomUUID(), randomUUID()]; - const allIds = [ - matchingOldId, - matchingLowerId, - matchingHigherId, - ...ignoredIds, - ]; - credentialIds.push(...allIds); + credentialIds.push(activeId, ...ignoredIds); await database.insert(credentials).values([ { - id: matchingOldId, - kind: "model", - provider: "openai", - keyId: "openai-api-key", - encryptedValue: "old-matching-value", - metadata: {}, - createdAt: new Date("2026-01-01T00:00:00.000Z"), - }, - { - id: matchingLowerId, + id: activeId, kind: "model", provider: "openai", keyId: "openai-api-key", - encryptedValue: "lower-id-value", - metadata: {}, - createdAt: new Date("2026-02-01T00:00:00.000Z"), - }, - { - id: matchingHigherId, - kind: "model", - provider: "openai", - keyId: "openai-api-key", - encryptedValue: "higher-id-value", + encryptedValue: "active-value", metadata: {}, createdAt: new Date("2026-02-01T00:00:00.000Z"), }, @@ -342,7 +326,7 @@ describe("model credential store lookup", () => { encryptedValue: "revoked-value", metadata: {}, revokedAt: new Date("2026-03-01T00:00:00.000Z"), - createdAt: new Date("2026-03-01T00:00:00.000Z"), + createdAt: new Date("2026-01-01T00:00:00.000Z"), }, ]); @@ -351,7 +335,7 @@ describe("model credential store lookup", () => { provider: "openai", keyId: "openai-api-key", }), - ).resolves.toEqual({ encryptedValue: "higher-id-value" }); + ).resolves.toEqual({ encryptedValue: "active-value" }); }); }); @@ -506,6 +490,87 @@ describe("credential store rotation", () => { }); }); +describe("one live credential per key", () => { + test("the index refuses a second live credential for the same key", async () => { + const first = randomUUID(); + const second = randomUUID(); + credentialIds.push(first, second); + const keyId = `unique-active-${first}`; + await database.insert(credentials).values({ + id: first, + kind: "model", + provider: "openai", + keyId, + encryptedValue: "first", + metadata: {}, + }); + + await expect( + (async () => + database.insert(credentials).values({ + id: second, + kind: "model", + provider: "openai", + keyId, + encryptedValue: "second", + metadata: {}, + }))(), + ).rejects.toThrow(); + }); + + test("storing a credential for a key that has one replaces it", async () => { + // The Credentials page offers Add and Revoke and no rotate control, so + // replacing a key is done by adding one for the same provider and keyId. + // That has to retire what is there rather than raise a unique violation. + const audited: { eventType: string; targetId?: string }[] = []; + const service = { + encryptionKey: key, + store: createCredentialStore(database), + auditStore: { + insert: async (event: { eventType: string; targetId?: string }) => { + audited.push(event); + }, + }, + }; + const keyId = `replace-on-add-${randomUUID()}`; + + const first = await createCredential(service, { + kind: "model", + provider: "openai", + keyId, + metadata: {}, + plaintext: "first-secret", + actorUserId: "admin", + }); + credentialIds.push(first.id); + + const second = await createCredential(service, { + kind: "model", + provider: "openai", + keyId, + metadata: {}, + plaintext: "second-secret", + actorUserId: "admin", + }); + credentialIds.push(second.id); + + expect(second.id).not.toBe(first.id); + const rows = await database + .select({ id: credentials.id, revokedAt: credentials.revokedAt }) + .from(credentials) + .where(eq(credentials.keyId, keyId)); + const byId = Object.fromEntries(rows.map((row) => [row.id, row])); + expect(byId[first.id]?.revokedAt).not.toBeNull(); + expect(byId[second.id]?.revokedAt).toBeNull(); + + // The trail says what happened: an addition, then a replacement naming it. + expect(audited.map((event) => event.eventType)).toEqual([ + "credential.created", + "credential.rotated", + ]); + }); +}); + describe("admin credential API", () => { test("returns only credential status and metadata", async () => { const app = createApp( diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index bc84270a..8677b447 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -8,6 +8,7 @@ import { TEST_POOL } from "./support/database"; import { agents, auditEvents, + credentials as credentialRows, mcpServers, mcpTools, pluginGrants, @@ -55,19 +56,30 @@ let serverWasAlreadyConfigured = false; */ let toolWasAlreadyAdvertised = false; +const revokedCredentialIds: string[] = []; +const issuedCredentialIds: string[] = []; const store = createPluginStore({ database, auditStore: createAuditStore(database), credentials: { // No credential is ever read in these tests, because every call is refused before the vault. readSecret: async () => null, - // Nor written. Loud rather than absent: a call reaching either of these would mean this file had - // started exercising something it does not claim to, and a silent no-op would hide that. + // Nor created. Loud rather than absent: a call reaching this would mean this file had started + // exercising something it does not claim to, and a silent no-op would hide that. create: async () => { throw new Error("this suite does not write credentials"); }, - revoke: async () => { - throw new Error("this suite does not revoke credentials"); + // `removeServer` does revoke: it retires the token the server was configured with so a re-add + // does not collide on `credentials_active_key_idx`. The stamp goes to the real row, because + // `removeServer` reads liveness from the table before deciding whether to revoke at all. + revoke: async (id: string) => { + const revokedAt = new Date(); + await database + .update(credentialRows) + .set({ revokedAt, updatedAt: revokedAt }) + .where(eq(credentialRows.id, id)); + revokedCredentialIds.push(id); + return revokedAt; }, }, encryptionKey: "x".repeat(44), @@ -170,6 +182,9 @@ afterAll(async () => { } await database.delete(agents).where(eq(agents.id, holderId)); await database.delete(agents).where(eq(agents.id, strangerId)); + for (const id of issuedCredentialIds) { + await database.delete(credentialRows).where(eq(credentialRows.id, id)); + } }); describe("a grant is the permission", () => { @@ -378,6 +393,82 @@ describe("a boundary written about the browser does not refuse tool calls", () = }); }); +describe("removing an MCP server", () => { + test("revokes the credential the server was configured with", async () => { + // Without this, the credential row stays live after the server row is + // gone, and re-adding the same server would unique-violate on + // `credentials_active_key_idx`. The audit trail also carries the + // revocation with `reason: mcp_server_removed`. + const removalServerId = `removal-target-${suite}`; + revokedCredentialIds.length = 0; + const [credentialRow] = await database + .insert(credentialRows) + .values({ + kind: "mcp", + provider: removalServerId, + keyId: `mcp-${removalServerId}`, + encryptedValue: "{}", + metadata: {}, + }) + .returning({ id: credentialRows.id }); + const credentialId = credentialRow?.id; + if (!credentialId) throw new Error("credential row was not created"); + issuedCredentialIds.push(credentialId); + await database.insert(mcpServers).values({ + id: removalServerId, + title: "removal target", + vendor: "test", + url: "https://example.invalid/mcp", + credentialId, + provenance: "custom", + }); + + await store.removeServer(removalServerId, "admin@openbot.local"); + + expect(revokedCredentialIds).toEqual([credentialId]); + const [row] = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, removalServerId)); + expect(row).toBeUndefined(); + const audit = await database + .select({ + eventType: auditEvents.eventType, + payload: auditEvents.payload, + }) + .from(auditEvents) + .where( + and( + eq(auditEvents.targetType, "credential"), + eq(auditEvents.targetId, credentialId), + ), + ); + expect(audit).toHaveLength(1); + expect(audit[0]?.eventType).toBe("credential.revoked"); + expect((audit[0]?.payload as { reason?: string })?.reason).toBe( + "mcp_server_removed", + ); + // Audit is append-only in Postgres; leaving the row is fine because + // `credentialId` is suite-scoped, so re-runs never collide. + }); + + test("does not call revoke when the server had no credential", async () => { + const removalServerId = `removal-target-nocred-${suite}`; + revokedCredentialIds.length = 0; + await database.insert(mcpServers).values({ + id: removalServerId, + title: "removal target no cred", + vendor: "test", + url: "https://example.invalid/mcp", + provenance: "custom", + }); + + await store.removeServer(removalServerId, "admin@openbot.local"); + + expect(revokedCredentialIds).toEqual([]); + }); +}); + describe("the trail can be read by a second reader", () => { test("a refusal names the bot, the server and the tool in queryable JSON", async () => { const [row] = await database From f7626cd0ec5f5fc8058c671e4811205e68daa5dd Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Sat, 22 Aug 2026 06:40:24 +0530 Subject: [PATCH 3/3] Retire a key on the connection that holds its lock Editing a Bot's bearer token never returned. `storeAgentAuth` rotates inside the update's transaction, locking the previous credential and revoking it there, and `retireReplacedKey` then revoked the same row again from the pool. The pooled statement waited for a lock only that transaction could release, and the transaction could not commit while it was awaiting the call. Postgres saw one transaction and one waiting session rather than a cycle, so no deadlock detector fired and the edit hung to the statement timeout, which the surrounding catch then reported as a key still live. The suite missed it because the rotation tests hand `storeAgentAuth` a fake store, and a fake holds no locks. The call is gone from the update path rather than given the transaction. The rotation above it has already revoked that row, so a second revoke was redundant before it was unsafe. Deletion still needs it, and takes the transaction now: on its own connection the revoke would commit even where the delete rolled back, leaving a Bot that still exists and can no longer reach its endpoint. `agent-key-rotation.integration.test.ts` drives an edit and a deletion against a real database on one connection, which is where the hang is sharpest, and bounds the wait so a regression fails rather than hangs. It fails on the code before this commit with the deadline it was given. Two callers arrived on main since this branch was written, and both would have met the index rather than the orphan they used to leave. `registerOAuthClient` and `recordConnection` replace rather than add now, asking `findLiveByKey` rather than trusting a stored pointer, because a server row or a connection row keeps naming a credential an administrator has revoked while the key itself is free. A refused rotation records `credential.rotation_refused`. Aiming a rotation at a revoked credential, at one that does not exist, or at a key other than the credential's own is either a caller with a bug or an attempt to retire a key the caller was not asked to retire, and all three left nothing behind while only the successes were written. The test that asserted that absence now asserts the row. The migration is `0012`, and its backfill no longer reads `connector_instances`, which `0011` dropped. The tables that name a credential are `mcp_servers`, `mcp_user_credentials` and an agent's configuration. --- ....sql => 0013_credentials_one_live_key.sql} | 0 server/drizzle/meta/0013_snapshot.json | 2308 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/agents/auth-header.ts | 9 +- server/src/agents/profile-store.ts | 22 +- server/src/audit.ts | 9 + server/src/credentials.ts | 52 +- server/src/plugins/store.ts | 57 +- .../agent-key-rotation.integration.test.ts | 164 ++ server/tests/credentials.test.ts | 26 +- ...in-credential-rotation.integration.test.ts | 220 ++ ...plugin-user-credential.integration.test.ts | 28 + 12 files changed, 2856 insertions(+), 46 deletions(-) rename server/drizzle/{0012_credentials_one_live_key.sql => 0013_credentials_one_live_key.sql} (100%) create mode 100644 server/drizzle/meta/0013_snapshot.json create mode 100644 server/tests/agent-key-rotation.integration.test.ts create mode 100644 server/tests/plugin-credential-rotation.integration.test.ts diff --git a/server/drizzle/0012_credentials_one_live_key.sql b/server/drizzle/0013_credentials_one_live_key.sql similarity index 100% rename from server/drizzle/0012_credentials_one_live_key.sql rename to server/drizzle/0013_credentials_one_live_key.sql diff --git a/server/drizzle/meta/0013_snapshot.json b/server/drizzle/meta/0013_snapshot.json new file mode 100644 index 00000000..5daac600 --- /dev/null +++ b/server/drizzle/meta/0013_snapshot.json @@ -0,0 +1,2308 @@ +{ + "id": "8e4fc308-2e9b-4b30-ba94-16c002796cde", + "prevId": "2f106c72-9245-471b-bda8-1974a749990c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": ["channel_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": ["channel_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": ["last_message_agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": ["provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": ["user_id", "role"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": ["user_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": ["component_name", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": ["component_name", "function_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": ["server_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": ["server_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": ["kind", "ref", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": ["built_in", "remote_ag_ui"] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": ["admin", "user"] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index a6877854..f1697653 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1787444747113, "tag": "0012_truncate_is_not_a_way_around_append_only", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1787444747114, + "tag": "0013_credentials_one_live_key", + "breakpoints": true } ] } diff --git a/server/src/agents/auth-header.ts b/server/src/agents/auth-header.ts index 9fab2095..6c36a298 100644 --- a/server/src/agents/auth-header.ts +++ b/server/src/agents/auth-header.ts @@ -123,18 +123,25 @@ export async function storeAgentAuth(input: { * * Never throws. The new key is already stored and the Bot already works; a vault that would not * accept the revocation is worth saying loudly and is not worth failing an edit that has succeeded. + * + * Takes the caller's transaction where there is one, and must be given it whenever the caller holds + * a lock on the row being retired. On a pooled connection the revoke is a second session competing + * with the caller's own open transaction: it waits for a lock only that transaction can release, and + * the transaction cannot commit while it is awaiting this call. Nothing breaks that, so the edit + * hangs to the statement timeout and the timeout is then reported here as a key still live. */ export async function retireReplacedKey( store: Pick, previous: Record, next: Record, + executor?: CredentialExecutor, ): Promise { const before = credentialIdOf(previous); const after = credentialIdOf(next); if (!before || before === after) return; try { - await store.revoke(before); + await store.revoke(before, executor); } catch (error) { console.error( JSON.stringify({ diff --git a/server/src/agents/profile-store.ts b/server/src/agents/profile-store.ts index c1953056..8d14bdd4 100644 --- a/server/src/agents/profile-store.ts +++ b/server/src/agents/profile-store.ts @@ -401,19 +401,15 @@ export function createAgentProfileStore( }; /* - * The key this one replaces is retired. + * The key this one replaces is already retired, by the rotation above. * - * Rotating a key is the standard answer to a suspected leak, and without this it did not - * answer it: the old credential stayed in the vault, decryptable and still valid, and - * nothing listed it or could reach it. "Is that leaked key still live" was yes. The - * credentials table also grew one unrevoked secret per edit per Bot. - * - * After the new one is stored, so a failure here leaves the Bot working with a key too - * many rather than with none. + * `storeAgentAuth` locks the previous credential, revokes it and inserts the replacement + * inside this transaction, so there is nothing left here to retire. A second revoke from + * outside the transaction would wait on the row lock this transaction is holding and never + * be released, because the transaction cannot commit until the call it is awaiting + * returns: editing a Bot's key would hang until the statement timed out, and the timeout + * would then be reported as a key that is still live. */ - if (input.auth && vault) { - await retireReplacedKey(vault.store, previous, configuration); - } await transaction .update(agents) .set({ name: input.name, configuration, updatedAt }) @@ -521,6 +517,10 @@ export function createAgentProfileStore( vault.store, (row?.configuration ?? {}) as Record, {}, + // In this transaction, so the key is retired exactly when the deletion is. On its own + // connection the revoke would commit even where the delete rolled back, leaving a Bot + // that still exists and can no longer reach its endpoint. + transaction, ); } }, diff --git a/server/src/audit.ts b/server/src/audit.ts index 3be5e620..48d0638e 100644 --- a/server/src/audit.ts +++ b/server/src/audit.ts @@ -36,6 +36,15 @@ export const auditEventTypes = [ "configuration.changed", "credential.created", "credential.rotated", + /** + * A rotation the vault refused, and why. + * + * Recorded because the refusals are the interesting ones. A rotation aimed at a key other than the + * one the credential belongs to, or at a credential already revoked, is either a caller with a bug + * or somebody trying to retire a key they were not asked to retire, and neither left a trace while + * only the successes were written. + */ + "credential.rotation_refused", "credential.revoked", "connector.sync_succeeded", "connector.sync_failed", diff --git a/server/src/credentials.ts b/server/src/credentials.ts index 39383f71..ea14fd3a 100644 --- a/server/src/credentials.ts +++ b/server/src/credentials.ts @@ -497,16 +497,48 @@ export async function rotateCredential( ): Promise { // Encryption happens before the transaction opens, so no database connection // is held while it runs. The store then performs both writes atomically, and - // a failure leaves the vault as it was, which is why the audit event below is - // written only once that has returned. - const stored = await service.store.rotate({ - previousCredentialId: input.previousCredentialId, - kind: input.kind, - provider: input.provider, - keyId: input.keyId, - metadata: input.metadata, - encryptedValue: await encryptSecret(service.encryptionKey, input.plaintext), - }); + // a failure leaves the vault as it was, which is why the success event below + // is written only once that has returned. + let stored: StoredCredential; + try { + stored = await service.store.rotate({ + previousCredentialId: input.previousCredentialId, + kind: input.kind, + provider: input.provider, + keyId: input.keyId, + metadata: input.metadata, + encryptedValue: await encryptSecret( + service.encryptionKey, + input.plaintext, + ), + }); + } catch (error) { + /* + * A refused rotation is worth a row of its own. + * + * The vault refuses one aimed at a credential that is already revoked, one aimed at a credential + * that does not exist, and one whose key does not match the credential it names. Each of those is + * either a caller with a bug or an attempt to retire a key the caller was not asked to retire, + * and each of them left nothing behind while only successes were recorded. + * + * Written outside the transaction that has just rolled back, so the row survives the failure it + * describes. The reason is the vault's own message and never the secret, which never left this + * function. + */ + await recordAuditEvent(service.auditStore, { + eventType: "credential.rotation_refused", + targetType: "credential", + targetId: input.previousCredentialId, + actorUserId: input.actorUserId, + payload: { + kind: input.kind, + provider: input.provider, + keyId: input.keyId, + reason: error instanceof Error ? error.message : String(error), + }, + }); + throw error; + } await recordAuditEvent(service.auditStore, { eventType: "credential.rotated", diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 0936fa30..16b0426d 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -1067,29 +1067,39 @@ export function createPluginStore(options: PluginStoreOptions) { ); } - const stored = await credentials.create({ - kind: "mcp_oauth_client", + const key = { + kind: "mcp_oauth_client" as const, provider: input.serverId, keyId: `oauth-client-${input.serverId}`, + }; + const value = { + ...key, metadata: { server: input.serverId, clientId: input.client.clientId }, encryptedValue: await encryptSecret( encryptionKey, JSON.stringify(input.client), ), - }); + }; + + /* + * Re-registering a client replaces the one before it, in one transaction. + * + * A key holds at most one live credential, so inserting a second for this server would be + * refused by `credentials_active_key_idx` rather than leaving the orphan it used to leave. + * The question is asked of the key and not of `row.credentialId`, because the server row keeps + * naming a credential an administrator has revoked from the Credentials page: the pointer can + * be stale where the key is not, and it is the key the index constrains. + */ + const live = await credentials.findLiveByKey(key); + const stored = live + ? await credentials.rotate({ ...value, previousCredentialId: live.id }) + : await credentials.create(value); await database .update(mcpServers) .set({ credentialId: stored.id, updatedAt: new Date() }) .where(eq(mcpServers.id, input.serverId)); - if (row.credentialId) { - await credentials.revoke(row.credentialId).catch(() => { - // A previous client that cannot be revoked must not stop the new one taking effect. The - // pointer has already moved, so nothing reaches the old row; it is a tidiness failure. - }); - } - await recordAuditEvent(auditStore, { eventType: "mcp.oauth_client_registered", targetType: "mcp_server", @@ -1130,13 +1140,28 @@ export function createPluginStore(options: PluginStoreOptions) { ) .limit(1); - const stored = await credentials.create({ - kind: "mcp_user_token", + const key = { + kind: "mcp_user_token" as const, provider: input.serverId, keyId: input.userId, + }; + /* + * Reconnecting replaces this person's token for this server, in one transaction. + * + * `credentials_active_key_idx` holds one live credential per key, so a second insert for the + * same person and server would be refused. Asked of the key rather than of `previous`, because + * the connection row can name a credential that has already been revoked while the key itself + * is free, and it is the key the index constrains. + */ + const live = await credentials.findLiveByKey(key); + const value = { + ...key, metadata: { server: input.serverId, scope: input.scope }, encryptedValue: await encryptSecret(encryptionKey, input.refreshToken), - }); + }; + const stored = live + ? await credentials.rotate({ ...value, previousCredentialId: live.id }) + : await credentials.create(value); await database .insert(mcpUserCredentials) @@ -1155,12 +1180,6 @@ export function createPluginStore(options: PluginStoreOptions) { }, }); - if (previous) { - await credentials.revoke(previous.credentialId).catch(() => { - // Same reasoning as above: the pointer has moved, so this is tidiness rather than access. - }); - } - await recordAuditEvent(auditStore, { eventType: "mcp.account_connected", targetType: "mcp_server", diff --git a/server/tests/agent-key-rotation.integration.test.ts b/server/tests/agent-key-rotation.integration.test.ts new file mode 100644 index 00000000..24f81e6c --- /dev/null +++ b/server/tests/agent-key-rotation.integration.test.ts @@ -0,0 +1,164 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { createCredentialStore } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { agentProfiles, agents, credentials, users } from "../src/db/schema"; +import { createAgentProfileStore } from "../src/agents/profile-store"; +import type { AgentActor } from "../src/agents/profile-types"; + +/** + * Editing a Bot's key, against a real database. + * + * The rotation tests elsewhere hand `storeAgentAuth` a fake store, which can answer any call + * instantly and holds no locks. That is enough to prove which vault call is made and useless for + * proving the call can be made at all: every failure this file exists to catch is a lock taken by + * one connection and waited for by another, and a fake has neither. + * + * The pool is pinned to one connection deliberately. A second vault write on its own connection is + * a second session competing with the transaction the edit is already inside, and at `max: 1` it + * cannot even be handed a connection until that transaction ends — which it never will, because the + * transaction is awaiting the call. The edit hangs until something times it out. At the driver's + * default pool the same shape survives as a row-lock wait instead, slower to hit and identical in + * effect, so one connection is the honest setting for the question being asked. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + { max: 1 }, +); + +const encryptionKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const store = createCredentialStore(database); +const profiles = createAgentProfileStore(database, undefined, { + store, + encryptionKey, +}); + +const suite = randomUUID().slice(0, 8); +const actor: AgentActor = { id: `user_${suite}`, role: "admin" }; +const created: string[] = []; + +/** An edit that hangs is the failure, so the wait is bounded and the bound is the assertion. */ +const DEADLINE_MS = 5_000; + +async function within(label: string, work: Promise): Promise { + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `${label} did not return within ${DEADLINE_MS}ms, which is what a vault write on a second connection looks like from inside the transaction that is holding the only one`, + ), + ), + DEADLINE_MS, + ); + }); + try { + return await Promise.race([work, deadline]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function liveKeysFor(agentId: string) { + return database + .select({ id: credentials.id }) + .from(credentials) + .where( + and( + eq(credentials.kind, "agent"), + eq(credentials.keyId, agentId), + isNull(credentials.revokedAt), + ), + ); +} + +beforeAll(async () => { + await database.insert(users).values({ + id: actor.id, + email: `${actor.id}@openbot.test`, + name: "Key rotation tester", + emailVerified: true, + }); + + const profile = await profiles.create(actor, { + name: `key rotation ${suite}`, + title: "Tester", + roleDescription: "Holds a key that gets replaced.", + visibility: "private", + endpoint: "https://example.invalid/agent", + auth: { header: "Authorization", value: "first-secret" }, + }); + created.push(profile.id); +}); + +afterAll(async () => { + if (created.length) { + await database + .delete(agentProfiles) + .where(inArray(agentProfiles.agentId, created)); + await database.delete(agents).where(inArray(agents.id, created)); + await database + .delete(credentials) + .where(inArray(credentials.keyId, created)); + } + await database.delete(users).where(eq(users.id, actor.id)); + await database.$client.end(); +}); + +describe("editing a Bot's key", () => { + test("returns, and leaves exactly one live credential", async () => { + const [agentId] = created; + expect(await liveKeysFor(agentId)).toHaveLength(1); + const [before] = await liveKeysFor(agentId); + + await within( + "the edit", + profiles.update(actor, agentId, { + name: `key rotation ${suite}`, + title: "Tester", + roleDescription: "Holds a key that gets replaced.", + visibility: "private", + endpoint: "https://example.invalid/agent", + auth: { header: "Authorization", value: "second-secret" }, + }), + ); + + const live = await liveKeysFor(agentId); + expect(live).toHaveLength(1); + expect(live[0]?.id).not.toBe(before?.id); + }); + + test("the credential it replaced is revoked, not merely unreferenced", async () => { + const [agentId] = created; + const rows = await database + .select({ id: credentials.id, revokedAt: credentials.revokedAt }) + .from(credentials) + .where( + and(eq(credentials.kind, "agent"), eq(credentials.keyId, agentId)), + ); + + expect(rows).toHaveLength(2); + expect(rows.filter((row) => row.revokedAt === null)).toHaveLength(1); + expect(rows.filter((row) => row.revokedAt !== null)).toHaveLength(1); + }); + + test("deleting the Bot retires the key it was still holding", async () => { + const profile = await profiles.create(actor, { + name: `key deletion ${suite}`, + title: "Tester", + roleDescription: "Holds a key until it is deleted.", + visibility: "private", + endpoint: "https://example.invalid/agent", + auth: { header: "Authorization", value: "only-secret" }, + }); + created.push(profile.id); + + expect(await liveKeysFor(profile.id)).toHaveLength(1); + await within("the deletion", profiles.softDelete(actor, profile.id)); + expect(await liveKeysFor(profile.id)).toHaveLength(0); + }); +}); diff --git a/server/tests/credentials.test.ts b/server/tests/credentials.test.ts index b47140e0..b343f969 100644 --- a/server/tests/credentials.test.ts +++ b/server/tests/credentials.test.ts @@ -154,10 +154,13 @@ describe("credential encryption", () => { ).toEqual(["credential.rotated", "credential.revoked"]); }); - test("writes no audit event when the rotation fails", async () => { - // The store's rotate is one transaction, so a failure commits nothing. The - // trail has to agree: a rotation that did not happen leaves no row saying - // it did, and the caller sees the original cause rather than a later one. + test("records the refusal when the rotation fails, and never a success", async () => { + // The store's rotate is one transaction, so a failure commits nothing and + // no row may say a rotation happened. The refusal itself is recorded + // though: a rotation aimed at a revoked credential, at one that does not + // exist, or at a key other than the credential's own is either a caller + // with a bug or an attempt to retire somebody else's key, and it used to + // leave nothing behind. The caller still sees the original cause. const audited: unknown[] = []; const service = { encryptionKey: key, @@ -191,7 +194,20 @@ describe("credential encryption", () => { }), ).rejects.toThrow("Previous credential is already revoked"); - expect(audited).toEqual([]); + expect(audited).toEqual([ + { + eventType: "credential.rotation_refused", + targetType: "credential", + targetId: "credential-old", + actorUserId: "admin", + payload: { + kind: "model", + provider: "openai", + keyId: "primary", + reason: "Previous credential is already revoked", + }, + }, + ]); }); test("decrypts only an active credential for server-side use", async () => { diff --git a/server/tests/plugin-credential-rotation.integration.test.ts b/server/tests/plugin-credential-rotation.integration.test.ts new file mode 100644 index 00000000..22ebbeb4 --- /dev/null +++ b/server/tests/plugin-credential-rotation.integration.test.ts @@ -0,0 +1,220 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import type { ActionPolicy } from "../src/computer/policy"; +import { createCredentialStore } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { + credentials, + mcpServers, + mcpUserCredentials, + users, +} from "../src/db/schema"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL } from "./support/database"; + +/** + * Registering a client twice, and connecting twice, against a real vault. + * + * Both paths used to insert a second live credential for a key and revoke the first afterwards, on + * a best-effort basis. `credentials_active_key_idx` refuses the second insert outright, so the + * question is no longer whether an orphan is left behind but whether the path still works at all. + * A stubbed vault cannot answer that: the index is a database object, and only a database enforces + * it. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); + +const ENCRYPTION_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] }; +const suite = randomUUID().slice(0, 8); +const serverId = "google-drive"; +const personId = `plugin_rotation_person_${suite}`; + +const store = createPluginStore({ + database, + auditStore: createAuditStore(database), + credentials: createCredentialStore(database), + encryptionKey: ENCRYPTION_KEY, + policy: () => policy, +}); + +let clientBefore: string | null = null; +let serverExisted = false; + +async function rowsFor( + kind: "mcp_oauth_client" | "mcp_user_token", + keyId: string, +) { + return database + .select({ id: credentials.id, revokedAt: credentials.revokedAt }) + .from(credentials) + .where( + and( + eq(credentials.kind, kind), + eq(credentials.provider, serverId), + eq(credentials.keyId, keyId), + ), + ); +} + +function live(rows: T[]) { + return rows.filter((row) => row.revokedAt === null); +} + +beforeAll(async () => { + await database + .insert(users) + .values({ + id: personId, + email: `${personId}@openbot.test`, + name: personId, + emailVerified: false, + }) + .onConflictDoNothing(); + + const [existing] = await database + .select({ id: mcpServers.id, credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + serverExisted = existing !== undefined; + clientBefore = existing?.credentialId ?? null; + + await database + .insert(mcpServers) + .values({ + id: serverId, + title: "Google Drive", + vendor: "Google", + url: "https://www.googleapis.com/drive/v3", + provenance: "first-party", + }) + .onConflictDoNothing(); + + // This run's own client key, so a deployment's real Drive registration is never touched. + await database + .update(credentials) + .set({ revokedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(credentials.kind, "mcp_oauth_client"), + eq(credentials.provider, serverId), + isNull(credentials.revokedAt), + ), + ); +}); + +afterAll(async () => { + await database + .delete(mcpUserCredentials) + .where(eq(mcpUserCredentials.userId, personId)); + /* + * The pointer is dropped before the rows are, and put back only if what it named survives. + * + * `mcp_servers.credential_id` is a real foreign key, so a credential this run registered cannot be + * deleted while the server still names it. Restoring first is not enough either: on a database + * where an earlier run of this file left the pointer on one of its own rows, restoring puts it + * straight back onto a row about to be deleted. + */ + await database + .update(mcpServers) + .set({ credentialId: null }) + .where(eq(mcpServers.id, serverId)); + const mine = [ + ...(await rowsFor("mcp_user_token", personId)), + ...(await rowsFor("mcp_oauth_client", `oauth-client-${serverId}`)), + ].map((row) => row.id); + if (mine.length) { + await database.delete(credentials).where(inArray(credentials.id, mine)); + } + await database.delete(users).where(eq(users.id, personId)); + if (clientBefore) { + const [survivor] = await database + .select({ id: credentials.id }) + .from(credentials) + .where(eq(credentials.id, clientBefore)); + if (survivor) { + await database + .update(mcpServers) + .set({ credentialId: clientBefore }) + .where(eq(mcpServers.id, serverId)); + } + } + if (!serverExisted) { + await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); + } + await database.$client.end(); +}); + +describe("registering an OAuth client twice", () => { + test("replaces the client rather than meeting the index", async () => { + await store.registerOAuthClient({ + serverId, + client: { clientId: `client-one-${suite}`, clientSecret: "one" }, + by: personId, + }); + const first = live( + await rowsFor("mcp_oauth_client", `oauth-client-${serverId}`), + ); + expect(first).toHaveLength(1); + + await store.registerOAuthClient({ + serverId, + client: { clientId: `client-two-${suite}`, clientSecret: "two" }, + by: personId, + }); + + const all = await rowsFor("mcp_oauth_client", `oauth-client-${serverId}`); + expect(live(all)).toHaveLength(1); + expect(live(all)[0]?.id).not.toBe(first[0]?.id); + expect(all.filter((row) => row.revokedAt !== null).length).toBeGreaterThan( + 0, + ); + + const [server] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + expect(server?.credentialId).toBe(live(all)[0]?.id as string); + }); +}); + +describe("reconnecting the same person to the same server", () => { + test("replaces their token rather than meeting the index", async () => { + await store.recordConnection({ + serverId, + userId: personId, + refreshToken: "refresh-one", + scope: "https://www.googleapis.com/auth/drive.readonly", + }); + const first = live(await rowsFor("mcp_user_token", personId)); + expect(first).toHaveLength(1); + + await store.recordConnection({ + serverId, + userId: personId, + refreshToken: "refresh-two", + scope: "https://www.googleapis.com/auth/drive.readonly", + }); + + const all = await rowsFor("mcp_user_token", personId); + expect(live(all)).toHaveLength(1); + expect(live(all)[0]?.id).not.toBe(first[0]?.id); + + const [connection] = await database + .select({ credentialId: mcpUserCredentials.credentialId }) + .from(mcpUserCredentials) + .where( + and( + eq(mcpUserCredentials.serverId, serverId), + eq(mcpUserCredentials.userId, personId), + ), + ); + expect(connection?.credentialId).toBe(live(all)[0]?.id as string); + }); +}); diff --git a/server/tests/plugin-user-credential.integration.test.ts b/server/tests/plugin-user-credential.integration.test.ts index f4adad2a..08ca92b4 100644 --- a/server/tests/plugin-user-credential.integration.test.ts +++ b/server/tests/plugin-user-credential.integration.test.ts @@ -155,8 +155,35 @@ const store = createPluginStore({ }, }); +/** + * Retire whatever this key currently holds, the way the product now does. + * + * These fixtures insert straight into the vault rather than going through the store, and a key holds + * at most one live credential since `credentials_active_key_idx`. Re-registering a client or + * reconnecting a person is a replacement, so the row it replaces is revoked first; without this the + * second test to call either helper meets the index instead of the behaviour it came to check. + */ +async function retireLive( + kind: "mcp_oauth_client" | "mcp_user_token", + keyId: string, +) { + const revokedAt = new Date(); + await database + .update(credentials) + .set({ revokedAt, updatedAt: revokedAt }) + .where( + and( + eq(credentials.kind, kind), + eq(credentials.provider, serverId), + eq(credentials.keyId, keyId), + isNull(credentials.revokedAt), + ), + ); +} + /** Register the deployment's OAuth client, which is what `mcp_servers.credential_id` holds. */ async function registerClient() { + await retireLive("mcp_oauth_client", "oauth-client"); const [credential] = await database .insert(credentials) .values({ @@ -180,6 +207,7 @@ async function registerClient() { } async function connect(userId: string, refreshToken: string) { + await retireLive("mcp_user_token", userId); const [credential] = await database .insert(credentials) .values({