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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions go/api/database/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ package database

import (
"context"
"errors"
"time"

a2a "github.com/a2aproject/a2a-go/v2/a2a"
"github.com/kagent-dev/kagent/go/api/v1alpha2"
"github.com/pgvector/pgvector-go"
)

// ErrSessionIDInUse means the requested session id is already active on a
// different session (possibly owned by another user).
var ErrSessionIDInUse = errors.New("session id already in use")

type QueryOptions struct {
Limit int
After time.Time
Expand Down
27 changes: 25 additions & 2 deletions go/core/internal/database/client_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

a2a "github.com/a2aproject/a2a-go/v2/a2a"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
dbpkg "github.com/kagent-dev/kagent/go/api/database"
Expand Down Expand Up @@ -83,7 +84,7 @@ func (c *postgresClient) DeleteAgent(ctx context.Context, agentID string) error
// ── Sessions ──────────────────────────────────────────────────────────────────

func (c *postgresClient) StoreSession(ctx context.Context, session *dbpkg.Session) error {
return c.withTx(ctx, func(q *dbgen.Queries) error {
err := c.withTx(ctx, func(q *dbgen.Queries) error {
params := dbgen.UpsertSessionParams{
ID: session.ID,
UserID: session.UserID,
Expand All @@ -96,6 +97,11 @@ func (c *postgresClient) StoreSession(ctx context.Context, session *dbpkg.Sessio
}
return q.UpsertSession(ctx, params)
})
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.ConstraintName == "session_id_active_unique" {
return dbpkg.ErrSessionIDInUse
}
return err
}

func (c *postgresClient) GetSession(ctx context.Context, sessionID, userID string) (*dbpkg.Session, error) {
Expand Down Expand Up @@ -146,7 +152,24 @@ func (c *postgresClient) ListSessionsForAgentAllUsers(ctx context.Context, agent
}

func (c *postgresClient) DeleteSession(ctx context.Context, sessionID, userID string) error {
return c.q.SoftDeleteSession(ctx, dbgen.SoftDeleteSessionParams{ID: sessionID, UserID: userID})
return c.withTx(ctx, func(q *dbgen.Queries) error {
if _, err := q.GetSession(ctx, dbgen.GetSessionParams{ID: sessionID, UserID: userID}); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Any reason we're not using CASCADE deletes via SQL for this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Any reason we're not using CASCADE deletes via SQL for this?

no fk exists between session and task/event/session_share right now
even if it did, tasks and events r soft deleted, and ON DELETE CASCADE only fires on a real DELETE and not an UPDATE.
and session itself is soft deleted too, so cascade would never trigger
that's why this is explicit app code instead.

if errors.Is(err, pgx.ErrNoRows) {
return nil
}
return err
}
if err := q.SoftDeleteTasksBySession(ctx, &sessionID); err != nil {
return err
}
if err := q.SoftDeleteEventsBySession(ctx, &sessionID); err != nil {
return err
}
if err := q.DeleteSessionSharesBySession(ctx, sessionID); err != nil {
return err
}
return q.SoftDeleteSession(ctx, dbgen.SoftDeleteSessionParams{ID: sessionID, UserID: userID})
})
Comment thread
mesutoezdil marked this conversation as resolved.
}

// ── Session Shares ─────────────────────────────────────────────────────────────
Expand Down
18 changes: 18 additions & 0 deletions go/core/internal/database/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,24 @@ func TestStoreSessionIdempotence(t *testing.T) {
assert.Equal(t, "Updated", *retrieved.Name, "Session should have updated name")
}

func TestStoreSessionRejectsIDUsedByAnotherUser(t *testing.T) {
db := setupTestDB(t)
client := NewClient(db)
ctx := context.Background()

agentID := "agent-1"
first := &dbpkg.Session{ID: "shared-id", UserID: "user-a", AgentID: &agentID}
require.NoError(t, client.StoreSession(ctx, first), "first user should get the id")

second := &dbpkg.Session{ID: "shared-id", UserID: "user-b", AgentID: &agentID}
err := client.StoreSession(ctx, second)
require.ErrorIs(t, err, dbpkg.ErrSessionIDInUse, "a second user must not claim an id already active for another user")

// Once the first user's session is gone, the id is free again.
require.NoError(t, client.DeleteSession(ctx, "shared-id", "user-a"))
require.NoError(t, client.StoreSession(ctx, second), "id should be reusable after the original session is deleted")
}

func TestListSessionsOrdersByRecentActivity(t *testing.T) {
db := setupTestDB(t)
client := NewClient(db)
Expand Down
9 changes: 9 additions & 0 deletions go/core/internal/database/gen/events.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions go/core/internal/database/gen/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions go/core/internal/database/gen/session_shares.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions go/core/internal/database/gen/tasks.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions go/core/internal/database/queries/events.sql
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ LIMIT $2;
-- name: SoftDeleteEvent :exec
UPDATE event SET deleted_at = NOW()
WHERE id = $1 AND deleted_at IS NULL;

-- name: SoftDeleteEventsBySession :exec
UPDATE event SET deleted_at = NOW() WHERE session_id = $1 AND deleted_at IS NULL;
3 changes: 3 additions & 0 deletions go/core/internal/database/queries/session_shares.sql
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ WHERE token = $1 AND session_id = $2 AND user_id = $3;
INSERT INTO session_share_access (user_id, share_id, accessed_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id, share_id) DO UPDATE SET accessed_at = NOW();

-- name: DeleteSessionSharesBySession :exec
DELETE FROM session_share WHERE session_id = $1;
3 changes: 3 additions & 0 deletions go/core/internal/database/queries/tasks.sql
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ WHERE upserted_task.session_id IS NOT NULL

-- name: SoftDeleteTask :exec
UPDATE task SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL;

-- name: SoftDeleteTasksBySession :exec
UPDATE task SET deleted_at = NOW() WHERE session_id = $1 AND deleted_at IS NULL;
5 changes: 5 additions & 0 deletions go/core/internal/httpserver/handlers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handlers

import (
"context"
stderrors "errors"
"fmt"
"net/http"
"strconv"
Expand Down Expand Up @@ -178,6 +179,10 @@ func (h *SessionsHandler) HandleCreateSession(w ErrorResponseWriter, r *http.Req
"name", sessionRequest.Name)

if err := h.DatabaseService.StoreSession(r.Context(), session); err != nil {
if stderrors.Is(err, database.ErrSessionIDInUse) {
w.RespondWithError(errors.NewConflictError("Session ID is already in use", err))
return
}
w.RespondWithError(errors.NewInternalServerError("Failed to create session", err))
return
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP INDEX IF EXISTS session_id_active_unique;
5 changes: 5 additions & 0 deletions go/core/pkg/migrations/core/000007_session_id_unique.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- A session's client-supplied id was only unique per (id, user_id), so two
-- different users could create sessions with the same id. Deleting one then
-- cascaded to the other user's tasks and events, which are keyed by session_id
-- alone. This index makes id unique among live sessions so that can't happen.
CREATE UNIQUE INDEX IF NOT EXISTS session_id_active_unique ON session (id) WHERE deleted_at IS NULL;
Loading