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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silly-plants-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@langchain/langgraph-api": patch
---

fix(api): call threads:create auth handler when copying a thread
17 changes: 13 additions & 4 deletions libs/langgraph-api/src/storage/ops.mts
Original file line number Diff line number Diff line change
Expand Up @@ -908,22 +908,31 @@ export class FileSystemThreads implements ThreadsRepo {
thread_id,
});

return this.conn.with((STORE) => {
const fromThread = await this.conn.with((STORE) => {
const thread = STORE.threads[thread_id];
if (!thread)
throw new HTTPException(409, { message: "Thread not found" });

if (!isAuthMatching(thread["metadata"], filters)) {
throw new HTTPException(409, { message: "Thread not found" });
}
return thread;
});

const newThreadId = uuid4();
const now = new Date();
const newThreadId = uuid4();
const now = new Date();
const newMetadata = { ...fromThread.metadata, thread_id: newThreadId };
await handleAuthEvent(auth, "threads:create", {
thread_id: newThreadId,
metadata: newMetadata,
});

return this.conn.with((STORE) => {
STORE.threads[newThreadId] = {
thread_id: newThreadId,
created_at: now,
updated_at: now,
metadata: { ...thread.metadata, thread_id: newThreadId },
metadata: newMetadata,
config: {},
status: "idle",
};
Expand Down
27 changes: 26 additions & 1 deletion libs/langgraph-api/tests/auth.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ it.skipIf(process.version.startsWith("v18."))(
);

it.skipIf(process.version.startsWith("v18."))(
"thread copy authorization",
"thread copy authorization (by owner)",
async () => {
const owner = await createJwtClient("johndoe", ["me"]);
const otherUser = await createJwtClient("alice", ["me"]);
Expand All @@ -702,6 +702,31 @@ it.skipIf(process.version.startsWith("v18."))(
}
);

it.skipIf(process.version.startsWith("v18."))(
"thread copy authorization (requires read and write)",
async () => {
const writer = await createJwtClient("johndoe", ["me"]);
const reader = await createJwtClient("johndoe", ["threads:read"]);

const thread = await writer.threads.create();

// Can read the thread from both...
const readerThread = await reader.threads.get(thread.thread_id);
const writerThread = await writer.threads.get(thread.thread_id);
expect(thread).toEqual(readerThread);
expect(thread).toEqual(writerThread);

// But can only copy the thread from the writer
const copiedThread = await writer.threads.copy(thread.thread_id);
expect(copiedThread).not.toBeNull();
expect(copiedThread.thread_id).not.toBe(thread.thread_id);

await expect(reader.threads.copy(thread.thread_id)).rejects.toThrow(
"HTTP 403: Not authorized"
);
}
);

it.skipIf(process.version.startsWith("v18."))(
"thread history authorization",
async () => {
Expand Down
14 changes: 14 additions & 0 deletions libs/langgraph-api/tests/graphs/auth.mts
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,18 @@ export const auth = new Auth()
if (!identity || !value.namespace?.includes(identity)) {
throw new HTTPException(403, { message: "Not authorized" });
}
})
.on("threads:create", ({ value, user, permissions }) => {
if (
!["me", "threads:write", "threads:*"].some((p) =>
permissions?.includes(p)
)
) {
throw new HTTPException(403, { message: "Not authorized" });
}

const filters = { owner: user.identity };
value.metadata ??= {};
value.metadata["owner"] = user.identity;
return filters;
});
Loading