From 89711e4d575eceafe93c6b8a558ee51a8512a268 Mon Sep 17 00:00:00 2001 From: Flo Date: Thu, 16 Oct 2025 13:00:36 +0200 Subject: [PATCH 01/10] fix: deleting identity shouldnt 500 --- .../v2_identities_create_identity/200_test.go | 40 +-- .../v2_identities_delete_identity/200_test.go | 244 +++++++++++------ .../v2_identities_delete_identity/handler.go | 59 +++-- .../v2_identities_get_identity/handler.go | 64 ++--- .../v2_identities_list_identities/200_test.go | 2 +- .../v2_identities_update_identity/handler.go | 246 +++++++++--------- .../api/routes/v2_keys_create_key/200_test.go | 4 +- .../api/routes/v2_keys_create_key/handler.go | 4 +- .../api/routes/v2_keys_update_key/200_test.go | 4 +- .../api/routes/v2_keys_update_key/handler.go | 4 +- .../v2_keys_update_key/three_state_test.go | 8 +- ...delete_old_by_external_id.sql_generated.go | 40 +++ go/pkg/db/identity_find.sql_generated.go | 66 +++-- ...tity_find_with_ratelimits.sql_generated.go | 162 ++++++++++++ go/pkg/db/querier_generated.go | 73 +++++- .../identity_delete_old_by_external_id.sql | 8 + go/pkg/db/queries/identity_find.sql | 19 +- .../queries/identity_find_with_ratelimits.sql | 45 ++++ 18 files changed, 765 insertions(+), 327 deletions(-) create mode 100644 go/pkg/db/identity_delete_old_by_external_id.sql_generated.go create mode 100644 go/pkg/db/identity_find_with_ratelimits.sql_generated.go create mode 100644 go/pkg/db/queries/identity_delete_old_by_external_id.sql create mode 100644 go/pkg/db/queries/identity_find_with_ratelimits.sql diff --git a/go/apps/api/routes/v2_identities_create_identity/200_test.go b/go/apps/api/routes/v2_identities_create_identity/200_test.go index cc79fb3063..17de182e47 100644 --- a/go/apps/api/routes/v2_identities_create_identity/200_test.go +++ b/go/apps/api/routes/v2_identities_create_identity/200_test.go @@ -49,8 +49,8 @@ func TestCreateIdentitySuccessfully(t *testing.T) { }) require.NoError(t, err) - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ - Identity: identityID, + identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + IdentityID: identityID, Deleted: false, WorkspaceID: h.Resources().UserWorkspace.ID, }) @@ -72,8 +72,8 @@ func TestCreateIdentitySuccessfully(t *testing.T) { }) require.NoError(t, err) - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ - Identity: identityID, + identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + IdentityID: identityID, Deleted: false, WorkspaceID: h.Resources().UserWorkspace.ID, }) @@ -108,9 +108,9 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: externalTestID, + ExternalID: externalTestID, Deleted: false, }) require.NoError(t, err) @@ -130,9 +130,9 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: externalTestID, + ExternalID: externalTestID, Deleted: false, }) require.NoError(t, err) @@ -171,9 +171,9 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: externalTestID, + ExternalID: externalTestID, Deleted: false, }) require.NoError(t, err) @@ -230,9 +230,9 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: externalTestID, + ExternalID: externalTestID, Deleted: false, }) require.NoError(t, err) @@ -306,9 +306,9 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: externalTestID, + ExternalID: externalTestID, Deleted: false, }) require.NoError(t, err) @@ -347,9 +347,9 @@ func TestCreateIdentitySuccessfully(t *testing.T) { // Verify each identity was created with the correct externalId for i, externalID := range externalIDs { - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: externalID, + ExternalID: externalID, Deleted: false, }) identityIDs = append(identityIDs, identity.ID) @@ -377,9 +377,9 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.NotNil(t, res.Body) // Verify in database - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: externalTestID, + ExternalID: externalTestID, Deleted: false, }) require.NoError(t, err) @@ -417,9 +417,9 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.NotNil(t, res.Body) // Verify in database - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: externalTestID, + ExternalID: externalTestID, Deleted: false, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_identities_delete_identity/200_test.go b/go/apps/api/routes/v2_identities_delete_identity/200_test.go index 1644d566e3..e70f1be85f 100644 --- a/go/apps/api/routes/v2_identities_delete_identity/200_test.go +++ b/go/apps/api/routes/v2_identities_delete_identity/200_test.go @@ -10,55 +10,13 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_delete_identity" + "github.com/unkeyed/unkey/go/pkg/array" "github.com/unkeyed/unkey/go/pkg/db" "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/testutil/seed" "github.com/unkeyed/unkey/go/pkg/uid" ) -type TestIdentity struct { - ID string - ExternalID string - RatelimitIds []string -} - -// Helper function that creates a new identity with rate-limits and returns it -func createTestIdentity(t *testing.T, h *testutil.Harness, numberOfRatelimits int) TestIdentity { - identityID := uid.New(uid.IdentityPrefix) - externalID := uid.New("test_external_id") - - err := db.Query.InsertIdentity(t.Context(), h.DB.RW(), db.InsertIdentityParams{ - ID: identityID, - ExternalID: externalID, - WorkspaceID: h.Resources().UserWorkspace.ID, - Meta: []byte("{}"), - CreatedAt: time.Now().UnixMilli(), - Environment: "default", - }) - require.NoError(t, err) - - ratelimitIds := make([]string, 0, numberOfRatelimits) - for i := range numberOfRatelimits { - rateLimitID := uid.New(uid.RatelimitPrefix) - err = db.Query.InsertIdentityRatelimit(t.Context(), h.DB.RW(), db.InsertIdentityRatelimitParams{ - ID: rateLimitID, - WorkspaceID: h.Resources().UserWorkspace.ID, - IdentityID: sql.NullString{String: identityID, Valid: true}, - Name: fmt.Sprintf("Rate Limit %d", i+1), - Limit: 10, - Duration: (time.Minute * 10).Milliseconds(), - CreatedAt: time.Now().UnixMilli(), - }) - require.NoError(t, err) - ratelimitIds = append(ratelimitIds, rateLimitID) - } - - return TestIdentity{ - ID: identityID, - ExternalID: externalID, - RatelimitIds: ratelimitIds, - } -} - func TestDeleteIdentitySuccess(t *testing.T) { ctx := context.Background() h := testutil.NewHarness(t) @@ -79,75 +37,104 @@ func TestDeleteIdentitySuccess(t *testing.T) { } t.Run("delete identity by external ID", func(t *testing.T) { - testIdentity := createTestIdentity(t, h, 0) + externalID := "test_user_1" + identityID := h.CreateIdentity(seed.CreateIdentityRequest{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalID, + Meta: []byte("{}"), + }) // Verify identity exists before deletion - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: testIdentity.ExternalID, + ExternalID: externalID, Deleted: false, }) require.NoError(t, err) - require.Equal(t, testIdentity.ExternalID, identity.ExternalID) + require.Equal(t, externalID, identity.ExternalID) // Delete the identity via API - req := handler.Request{Identity: testIdentity.ExternalID} + req := handler.Request{Identity: externalID} res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) // Verify identity is soft deleted - _, err = db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + _, err = db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: testIdentity.ExternalID, + ExternalID: externalID, Deleted: false, }) require.Equal(t, sql.ErrNoRows, err, "identity should not be found with deleted=false") // Verify identity still exists but marked as deleted - deletedIdentity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + deletedIdentity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: testIdentity.ExternalID, + IdentityID: identityID, Deleted: true, }) require.NoError(t, err, "identity should still exist with deleted=true") - require.Equal(t, testIdentity.ExternalID, deletedIdentity.ExternalID) + require.Equal(t, externalID, deletedIdentity.ExternalID) require.True(t, deletedIdentity.Deleted) }) t.Run("delete identity with rate limits", func(t *testing.T) { numberOfRatelimits := 3 - testIdentity := createTestIdentity(t, h, numberOfRatelimits) + externalID := "test_user_with_ratelimits" + + ratelimits := array.Fill( + numberOfRatelimits, + func() seed.CreateRatelimitRequest { + return seed.CreateRatelimitRequest{ + Name: fmt.Sprintf("ratelimit_%s", uid.New("test", 3)), + WorkspaceID: h.Resources().UserWorkspace.ID, + Limit: 100, + Duration: time.Minute.Milliseconds(), + } + }, + ) + + identityID := h.CreateIdentity(seed.CreateIdentityRequest{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalID, + Meta: []byte("{}"), + Ratelimits: ratelimits, + }) // Verify rate limits exist - rateLimits, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: testIdentity.ID, Valid: true}) + rateLimits, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: identityID, Valid: true}) require.NoError(t, err) require.Len(t, rateLimits, numberOfRatelimits) // Delete the identity via API - req := handler.Request{Identity: testIdentity.ExternalID} + req := handler.Request{Identity: externalID} res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) // Verify identity is soft deleted - _, err = db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + _, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: testIdentity.ID, + IdentityID: identityID, Deleted: false, }) require.Equal(t, sql.ErrNoRows, err) // Verify rate limits still exist (they should remain for audit purposes) - rateLimitsAfterDeletion, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: testIdentity.ID, Valid: true}) + rateLimitsAfterDeletion, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: identityID, Valid: true}) require.NoError(t, err) require.Len(t, rateLimitsAfterDeletion, numberOfRatelimits, "rate limits should still exist after soft deletion") }) t.Run("delete identity with wildcard permission", func(t *testing.T) { - testIdentity := createTestIdentity(t, h, 0) + externalID := "test_user_wildcard" + identityID := h.CreateIdentity(seed.CreateIdentityRequest{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalID, + Meta: []byte("{}"), + }) // Create root key with wildcard permission wildcardKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "identity.*.delete_identity") @@ -156,32 +143,51 @@ func TestDeleteIdentitySuccess(t *testing.T) { "Authorization": {fmt.Sprintf("Bearer %s", wildcardKey)}, } - req := handler.Request{Identity: testIdentity.ExternalID} + req := handler.Request{Identity: externalID} res := testutil.CallRoute[handler.Request, handler.Response](h, route, wildcardHeaders, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) require.NotNil(t, res.Body) // Verify identity is soft deleted - _, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + _, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: testIdentity.ID, + IdentityID: identityID, Deleted: false, }) require.Equal(t, sql.ErrNoRows, err) }) t.Run("verify audit logs are created", func(t *testing.T) { - testIdentity := createTestIdentity(t, h, 2) + externalID := "test_user_audit_logs" + identityID := h.CreateIdentity(seed.CreateIdentityRequest{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalID, + Meta: []byte("{}"), + Ratelimits: []seed.CreateRatelimitRequest{ + { + Name: "ratelimit_1", + WorkspaceID: h.Resources().UserWorkspace.ID, + Limit: 100, + Duration: time.Minute.Milliseconds(), + }, + { + Name: "ratelimit_2", + WorkspaceID: h.Resources().UserWorkspace.ID, + Limit: 200, + Duration: time.Hour.Milliseconds(), + }, + }, + }) // Delete the identity - req := handler.Request{Identity: testIdentity.ExternalID} + req := handler.Request{Identity: externalID} res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) // Verify audit logs were created - auditLogs, err := db.Query.FindAuditLogTargetByID(ctx, h.DB.RO(), testIdentity.ID) + auditLogs, err := db.Query.FindAuditLogTargetByID(ctx, h.DB.RO(), identityID) require.NoError(t, err) require.GreaterOrEqual(t, len(auditLogs), 1, "should have audit logs for identity deletion") @@ -198,44 +204,120 @@ func TestDeleteIdentitySuccess(t *testing.T) { }) t.Run("delete identity twice (duplicate key error handling)", func(t *testing.T) { - testIdentity := createTestIdentity(t, h, 0) + externalID := "test_user_duplicate" + + // Create first identity + identityID1 := h.CreateIdentity(seed.CreateIdentityRequest{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalID, + Meta: []byte("{}"), + }) // Delete the identity once - req := handler.Request{Identity: testIdentity.ExternalID} + req := handler.Request{Identity: externalID} res1 := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, 200, res1.Status, "first deletion should succeed") // Create a new identity with the same external ID (this will trigger the duplicate key scenario) - newIdentityID := uid.New(uid.IdentityPrefix) - err := db.Query.InsertIdentity(ctx, h.DB.RW(), db.InsertIdentityParams{ - ID: newIdentityID, - ExternalID: testIdentity.ExternalID, + identityID2 := h.CreateIdentity(seed.CreateIdentityRequest{ WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalID, Meta: []byte("{}"), - CreatedAt: time.Now().UnixMilli(), - Environment: "default", }) - require.NoError(t, err) // Delete the new identity (this should trigger duplicate key error handling) - req2 := handler.Request{Identity: testIdentity.ExternalID} + req2 := handler.Request{Identity: externalID} res2 := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req2) require.Equal(t, 200, res2.Status, "second deletion should succeed despite duplicate key scenario") // Verify the new identity is soft deleted - _, err = db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + _, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: newIdentityID, + IdentityID: identityID2, Deleted: false, }) require.Equal(t, sql.ErrNoRows, err) // Verify the old identity was hard deleted (should not be found even with deleted=true) - _, err = db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + _, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: testIdentity.ID, + IdentityID: identityID1, Deleted: true, }) require.Equal(t, sql.ErrNoRows, err, "old identity should be hard deleted") }) + + t.Run("delete->create->delete with ratelimits", func(t *testing.T) { + // This test simulates a Stripe tier change workflow: + // 1. User starts with "Advanced" tier (300k requests/month) + // 2. User downgrades to "Starter" tier (20k requests/month) + // 3. Implementation: delete old identity, create new identity with new limits + externalID := "stripe_user_12345" + + // Step 1: Create initial identity with "Advanced" tier ratelimit (300k/month) + identityID1 := h.CreateIdentity(seed.CreateIdentityRequest{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalID, + Meta: []byte(`{"tier":"advanced"}`), + Ratelimits: []seed.CreateRatelimitRequest{ + { + Name: "per_month", + WorkspaceID: h.Resources().UserWorkspace.ID, + Limit: 300000, // 300k requests + Duration: (time.Hour * 24 * 30).Milliseconds(), + }, + }, + }) + + // Step 2: Delete old identity (tier downgrade starts) + req1 := handler.Request{Identity: externalID} + res1 := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req1) + require.Equal(t, 200, res1.Status, "first deletion should succeed") + + // Step 3: Create new identity with "Starter" tier ratelimit (20k/month) + identityID2 := h.CreateIdentity(seed.CreateIdentityRequest{ + WorkspaceID: h.Resources().UserWorkspace.ID, + ExternalID: externalID, // Same externalId + Meta: []byte(`{"tier":"starter"}`), + Ratelimits: []seed.CreateRatelimitRequest{ + { + Name: "per_month", + WorkspaceID: h.Resources().UserWorkspace.ID, + Limit: 20000, // 20k requests + Duration: (time.Hour * 24 * 30).Milliseconds(), + }, + }, + }) + + // Step 4: Delete new identity (this is where the bug used to happen) + req2 := handler.Request{Identity: externalID} + res2 := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req2) + require.Equal(t, 200, res2.Status, "second deletion should succeed without 500 error") + + // Verify the new identity is soft deleted (not hard deleted) + deletedIdentity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + IdentityID: identityID2, + Deleted: true, + }) + require.NoError(t, err, "new identity should exist as soft-deleted") + require.Equal(t, identityID2, deletedIdentity.ID) + require.True(t, deletedIdentity.Deleted) + + // Verify the new identity cannot be found as active + _, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + IdentityID: identityID2, + Deleted: false, + }) + require.Equal(t, sql.ErrNoRows, err, "new identity should not be active") + + // Verify the old identity was hard deleted (cleanup) + _, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + IdentityID: identityID1, + Deleted: true, + }) + require.Equal(t, sql.ErrNoRows, err, "old identity should be hard deleted as cleanup") + }) } diff --git a/go/apps/api/routes/v2_identities_delete_identity/handler.go b/go/apps/api/routes/v2_identities_delete_identity/handler.go index 676a832358..404e799cb8 100644 --- a/go/apps/api/routes/v2_identities_delete_identity/handler.go +++ b/go/apps/api/routes/v2_identities_delete_identity/handler.go @@ -2,7 +2,7 @@ package handler import ( "context" - "database/sql" + "encoding/json" "fmt" "net/http" @@ -71,32 +71,33 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + results, err := db.Query.FindIdentityWithRatelimits(ctx, h.DB.RO(), db.FindIdentityWithRatelimitsParams{ WorkspaceID: auth.AuthorizedWorkspaceID, Identity: req.Identity, Deleted: false, }) if err != nil { - if db.IsNotFound(err) { - return fault.New("identity not found", - fault.Code(codes.Data.Identity.NotFound.URN()), - fault.Internal("identity not found"), fault.Public("This identity does not exist."), - ) - } - return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), fault.Internal("database failed to find the identity"), fault.Public("Error finding the identity."), ) } - if identity.WorkspaceID != auth.AuthorizedWorkspaceID { + if len(results) == 0 { return fault.New("identity not found", fault.Code(codes.Data.Identity.NotFound.URN()), - fault.Internal("wrong workspace, masking as 404"), fault.Public("This identity does not exist."), + fault.Internal("identity not found"), fault.Public("This identity does not exist."), ) } + identity := results[0] + + // Parse ratelimits JSON + var ratelimits []db.RatelimitInfo + if ratelimitBytes, ok := identity.Ratelimits.([]byte); ok && ratelimitBytes != nil { + _ = json.Unmarshal(ratelimitBytes, &ratelimits) // Ignore error, default to empty array + } + err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { err = db.Query.SoftDeleteIdentity(ctx, tx, db.SoftDeleteIdentityParams{ WorkspaceID: auth.AuthorizedWorkspaceID, @@ -106,10 +107,23 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { // If we hit a duplicate key error, we know that we have an identity that was already soft deleted // so we can hard delete the "old" deleted version if db.IsDuplicateKeyError(err) { - // Delete the old soft-deleted identity and its ratelimits - err = db.Query.DeleteOldIdentityWithRatelimits(ctx, tx, db.DeleteOldIdentityWithRatelimitsParams{ + // Check if this identity is already soft-deleted (could happen with concurrent requests) + alreadyDeleted, checkErr := db.Query.FindIdentityByID(ctx, tx, db.FindIdentityByIDParams{ WorkspaceID: auth.AuthorizedWorkspaceID, - Identity: req.Identity, + IdentityID: identity.ID, + Deleted: true, + }) + if checkErr == nil && alreadyDeleted.ID == identity.ID { + // Identity is already soft-deleted, this is idempotent, return success + // Skip audit logs - they were already created by the request that deleted it + return nil + } + + // Delete the old soft-deleted identity with the same external_id, excluding the current one + err = db.Query.DeleteOldIdentityByExternalID(ctx, tx, db.DeleteOldIdentityByExternalIDParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + ExternalID: identity.ExternalID, + CurrentIdentityID: identity.ID, }) if err != nil { return fault.Wrap(err, @@ -124,6 +138,15 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { WorkspaceID: auth.AuthorizedWorkspaceID, Identity: identity.ID, }) + if err != nil { + // If we still get a duplicate key error after deleting the old identity, + // it means another concurrent request already soft-deleted this identity. + // This is safe to treat as success (idempotent operation). + // Skip audit logs - they were already created by the concurrent request + if db.IsDuplicateKeyError(err) { + return nil // Skip audit logs + } + } } if err != nil { @@ -156,14 +179,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }, } - ratelimits, listErr := db.Query.ListIdentityRatelimitsByID(ctx, tx, sql.NullString{String: identity.ID, Valid: true}) - if listErr != nil { - return fault.Wrap(listErr, - fault.Code(codes.App.Internal.ServiceUnavailable.URN()), - fault.Internal("database failed to load identity ratelimits"), fault.Public("Failed to load Identity ratelimits."), - ) - } - for _, rl := range ratelimits { auditLogs = append(auditLogs, auditlog.AuditLog{ WorkspaceID: auth.AuthorizedWorkspaceID, diff --git a/go/apps/api/routes/v2_identities_get_identity/handler.go b/go/apps/api/routes/v2_identities_get_identity/handler.go index fe33567c63..4b1b17fe70 100644 --- a/go/apps/api/routes/v2_identities_get_identity/handler.go +++ b/go/apps/api/routes/v2_identities_get_identity/handler.go @@ -2,7 +2,6 @@ package handler import ( "context" - "database/sql" "encoding/json" "net/http" @@ -52,53 +51,34 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // Find the identity based on either IdentityId or ExternalId - type IdentityResult struct { - Identity db.Identity - Ratelimits []db.Ratelimit + results, err := db.Query.FindIdentityWithRatelimits(ctx, h.DB.RO(), db.FindIdentityWithRatelimitsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Identity: req.Identity, + Deleted: false, + }) + if err != nil { + return fault.Wrap(err, + fault.Internal("unable to find identity"), + fault.Public("We're unable to retrieve the identity."), + ) } - result, err := db.TxWithResult(ctx, h.DB.RO(), func(ctx context.Context, tx db.DBTX) (IdentityResult, error) { - var identity db.Identity - - identity, err = db.Query.FindIdentity(ctx, tx, db.FindIdentityParams{ - Identity: req.Identity, - WorkspaceID: auth.AuthorizedWorkspaceID, - Deleted: false, - }) - if err != nil { - if db.IsNotFound(err) { - return IdentityResult{}, fault.New("identity not found", - fault.Code(codes.Data.Identity.NotFound.URN()), - fault.Internal("identity not found"), - fault.Public("This identity does not exist."), - ) - } - - return IdentityResult{}, fault.Wrap(err, - fault.Internal("unable to find identity"), - fault.Public("We're unable to retrieve the identity."), - ) - } + if len(results) == 0 { + return fault.New("identity not found", + fault.Code(codes.Data.Identity.NotFound.URN()), + fault.Internal("identity not found"), + fault.Public("This identity does not exist."), + ) + } - // Get the ratelimits for this identity - ratelimits, listErr := db.Query.ListIdentityRatelimitsByID(ctx, tx, sql.NullString{Valid: true, String: identity.ID}) - if listErr != nil && !db.IsNotFound(listErr) { - return IdentityResult{}, fault.Wrap(listErr, - fault.Internal("unable to fetch ratelimits"), - fault.Public("We're unable to retrieve the identity's ratelimits."), - ) - } + identity := results[0] - return IdentityResult{Identity: identity, Ratelimits: ratelimits}, nil - }) - if err != nil { - return err + // Parse ratelimits JSON + var ratelimits []db.RatelimitInfo + if ratelimitBytes, ok := identity.Ratelimits.([]byte); ok && ratelimitBytes != nil { + _ = json.Unmarshal(ratelimitBytes, &ratelimits) // Ignore error, default to empty array } - identity := result.Identity - ratelimits := result.Ratelimits - // Check permissions using either wildcard or the specific identity ID err = auth.VerifyRootKey(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ diff --git a/go/apps/api/routes/v2_identities_list_identities/200_test.go b/go/apps/api/routes/v2_identities_list_identities/200_test.go index 3eb8415c3a..2f17286c5a 100644 --- a/go/apps/api/routes/v2_identities_list_identities/200_test.go +++ b/go/apps/api/routes/v2_identities_list_identities/200_test.go @@ -355,7 +355,7 @@ func TestSuccess(t *testing.T) { // ID fields should never be empty require.NotEmpty(t, identity.ExternalId, "External ID should not be empty") - dbIdentity, err := db.Query.FindIdentity(ctx, h.DB.RW(), db.FindIdentityParams{WorkspaceID: workspaceID, Identity: identity.ExternalId, Deleted: false}) + dbIdentity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RW(), db.FindIdentityByExternalIDParams{WorkspaceID: workspaceID, ExternalID: identity.ExternalId, Deleted: false}) require.NoError(t, err) require.NotNil(t, dbIdentity, "Identity should be found in the database") diff --git a/go/apps/api/routes/v2_identities_update_identity/handler.go b/go/apps/api/routes/v2_identities_update_identity/handler.go index 32bf965ee8..7628e88971 100644 --- a/go/apps/api/routes/v2_identities_update_identity/handler.go +++ b/go/apps/api/routes/v2_identities_update_identity/handler.go @@ -112,51 +112,47 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { } } - identity, err := db.TxWithResult(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) (db.Identity, error) { - // Find by external ID - identity, err := db.Query.FindIdentity(ctx, tx, db.FindIdentityParams{ - Identity: req.Identity, - WorkspaceID: auth.AuthorizedWorkspaceID, - Deleted: false, - }) - if err != nil { - if db.IsNotFound(err) { - // nolint:exhaustruct - return db.Identity{}, fault.New("identity not found", - fault.Code(codes.Data.Identity.NotFound.URN()), - fault.Internal("identity not found"), fault.Public("Identity not found in this workspace"), - ) - } + // Use UNION query to find identity + ratelimits in one query (fast!) + results, err := db.Query.FindIdentityWithRatelimits(ctx, h.DB.RO(), db.FindIdentityWithRatelimitsParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Identity: req.Identity, + Deleted: false, + }) + if err != nil { + return fault.Wrap(err, + fault.Internal("unable to find identity"), + fault.Public("We're unable to retrieve the identity."), + ) + } - // nolint:exhaustruct - return db.Identity{}, fault.Wrap(err, - fault.Internal("unable to find identity"), fault.Public("We're unable to retrieve the identity."), - ) - } + if len(results) == 0 { + return fault.New("identity not found", + fault.Code(codes.Data.Identity.NotFound.URN()), + fault.Internal("identity not found"), + fault.Public("Identity not found in this workspace"), + ) + } - if identity.WorkspaceID != auth.AuthorizedWorkspaceID { - // nolint:exhaustruct - return db.Identity{}, fault.New("identity not found", - fault.Code(codes.Data.Identity.NotFound.URN()), - fault.Internal("wrong workspace, masking as 404"), - fault.Public("Identity not found in this workspace"), - ) - } + identityRow := results[0] - var existingRatelimits []db.Ratelimit - existingRatelimits, err = db.Query.ListIdentityRatelimitsByID(ctx, tx, sql.NullString{String: identity.ID, Valid: true}) - if err != nil && !db.IsNotFound(err) { - // nolint:exhaustruct - return db.Identity{}, fault.Wrap(err, - fault.Internal("unable to fetch ratelimits"), fault.Public("We're unable to retrieve the identity's ratelimits."), - ) - } + // Parse existing ratelimits from JSON + var existingRatelimits []db.RatelimitInfo + if ratelimitBytes, ok := identityRow.Ratelimits.([]byte); ok && ratelimitBytes != nil { + _ = json.Unmarshal(ratelimitBytes, &existingRatelimits) // Ignore error, default to empty array + } + + type txResult struct { + identity db.FindIdentityWithRatelimitsRow + finalRatelimits []openapi.RatelimitResponse + } + + result, err := db.TxWithResult(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) (txResult, error) { auditLogs := []auditlog.AuditLog{ { WorkspaceID: auth.AuthorizedWorkspaceID, Event: auditlog.IdentityUpdateEvent, - Display: fmt.Sprintf("Updated identity %s", identity.ID), + Display: fmt.Sprintf("Updated identity %s", identityRow.ID), ActorID: auth.Key.ID, ActorName: "root key", ActorType: auditlog.RootKeyActor, @@ -165,10 +161,10 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - ID: identity.ID, + ID: identityRow.ID, Type: auditlog.IdentityResourceType, - Name: identity.ExternalID, - DisplayName: identity.ExternalID, + Name: identityRow.ExternalID, + DisplayName: identityRow.ExternalID, Meta: nil, }, }, @@ -177,17 +173,20 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { if req.Meta != nil { err = db.Query.UpdateIdentity(ctx, tx, db.UpdateIdentityParams{ - ID: identity.ID, + ID: identityRow.ID, Meta: metaBytes, }) if err != nil { // nolint:exhaustruct - return db.Identity{}, fault.Wrap(err, + return txResult{}, fault.Wrap(err, fault.Internal("unable to update metadata"), fault.Public("We're unable to update the identity's metadata."), ) } } + // Build final ratelimits list (what will exist after this transaction) + finalRatelimits := make([]openapi.RatelimitResponse, 0) + if req.Ratelimits != nil { // Process ratelimits changes // 1. Delete ratelimits that no longer exist @@ -195,16 +194,14 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { // 3. Create new ratelimits // Create maps to easily find existing and new ratelimits by name - existingRatelimitMap := make(map[string]db.Ratelimit) + existingRatelimitMap := make(map[string]db.RatelimitInfo) for _, rl := range existingRatelimits { existingRatelimitMap[rl.Name] = rl } newRatelimitMap := make(map[string]openapi.RatelimitRequest) - if req.Ratelimits != nil { - for _, rl := range *req.Ratelimits { - newRatelimitMap[rl.Name] = rl - } + for _, rl := range *req.Ratelimits { + newRatelimitMap[rl.Name] = rl } rateLimitsToDelete := make([]string, 0) @@ -230,10 +227,10 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - ID: identity.ID, + ID: identityRow.ID, Type: auditlog.IdentityResourceType, - DisplayName: identity.ExternalID, - Name: identity.ExternalID, + DisplayName: identityRow.ExternalID, + Name: identityRow.ExternalID, Meta: nil, }, { @@ -251,7 +248,7 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { err = db.Query.DeleteManyRatelimitsByIDs(ctx, tx, rateLimitsToDelete) if err != nil { // nolint:exhaustruct - return db.Identity{}, fault.Wrap(err, + return txResult{}, fault.Wrap(err, fault.Internal("unable to delete ratelimits"), fault.Public("We're unable to delete ratelimits."), ) } @@ -262,11 +259,13 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { for name, newRL := range newRatelimitMap { existingRL, exists := existingRatelimitMap[name] + var ratelimitID string if exists { + ratelimitID = existingRL.ID rateLimitsToInsert = append(rateLimitsToInsert, db.InsertIdentityRatelimitParams{ ID: existingRL.ID, WorkspaceID: auth.AuthorizedWorkspaceID, - IdentityID: sql.NullString{String: identity.ID, Valid: true}, + IdentityID: sql.NullString{String: identityRow.ID, Valid: true}, Name: newRL.Name, Limit: int32(newRL.Limit), // nolint:gosec Duration: newRL.Duration, @@ -286,10 +285,10 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { UserAgent: s.UserAgent(), Resources: []auditlog.AuditLogResource{ { - ID: identity.ID, + ID: identityRow.ID, Type: auditlog.IdentityResourceType, - Name: identity.ExternalID, - DisplayName: identity.ExternalID, + Name: identityRow.ExternalID, + DisplayName: identityRow.ExternalID, Meta: nil, }, { @@ -301,50 +300,57 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { }, }, }) + } else { + // Create new ratelimit + ratelimitID = uid.New(uid.RatelimitPrefix) + rateLimitsToInsert = append(rateLimitsToInsert, db.InsertIdentityRatelimitParams{ + ID: ratelimitID, + WorkspaceID: auth.AuthorizedWorkspaceID, + IdentityID: sql.NullString{String: identityRow.ID, Valid: true}, + Name: newRL.Name, + Limit: int32(newRL.Limit), // nolint:gosec + Duration: newRL.Duration, + CreatedAt: time.Now().UnixMilli(), + AutoApply: newRL.AutoApply, + }) - continue + // Add audit log for creation + auditLogs = append(auditLogs, auditlog.AuditLog{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Event: auditlog.RatelimitCreateEvent, + Display: fmt.Sprintf("Created ratelimit %s", ratelimitID), + ActorID: auth.Key.ID, + ActorName: "root key", + ActorType: auditlog.RootKeyActor, + ActorMeta: map[string]any{}, + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + ID: identityRow.ID, + Type: auditlog.IdentityResourceType, + DisplayName: identityRow.ExternalID, + Name: identityRow.ExternalID, + Meta: nil, + }, + { + ID: ratelimitID, + Type: auditlog.RatelimitResourceType, + DisplayName: newRL.Name, + Name: newRL.Name, + Meta: nil, + }, + }, + }) } - // Create new ratelimit - ratelimitID := uid.New(uid.RatelimitPrefix) - rateLimitsToInsert = append(rateLimitsToInsert, db.InsertIdentityRatelimitParams{ - ID: ratelimitID, - WorkspaceID: auth.AuthorizedWorkspaceID, - IdentityID: sql.NullString{String: identity.ID, Valid: true}, - Name: newRL.Name, - Limit: int32(newRL.Limit), // nolint:gosec - Duration: newRL.Duration, - CreatedAt: time.Now().UnixMilli(), - AutoApply: newRL.AutoApply, - }) - - // Add audit log for creation - auditLogs = append(auditLogs, auditlog.AuditLog{ - WorkspaceID: auth.AuthorizedWorkspaceID, - Event: auditlog.RatelimitCreateEvent, - Display: fmt.Sprintf("Created ratelimit %s", ratelimitID), - ActorID: auth.Key.ID, - ActorName: "root key", - ActorType: auditlog.RootKeyActor, - ActorMeta: map[string]any{}, - RemoteIP: s.Location(), - UserAgent: s.UserAgent(), - Resources: []auditlog.AuditLogResource{ - { - ID: identity.ID, - Type: auditlog.IdentityResourceType, - DisplayName: identity.ExternalID, - Name: identity.ExternalID, - Meta: nil, - }, - { - ID: ratelimitID, - Type: auditlog.RatelimitResourceType, - DisplayName: newRL.Name, - Name: newRL.Name, - Meta: nil, - }, - }, + // Add to final ratelimits list (no DB query needed!) + finalRatelimits = append(finalRatelimits, openapi.RatelimitResponse{ + Id: ratelimitID, + Name: newRL.Name, + Limit: int64(newRL.Limit), + Duration: newRL.Duration, + AutoApply: newRL.AutoApply, }) } @@ -352,55 +358,51 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { err = db.BulkQuery.InsertIdentityRatelimits(ctx, tx, rateLimitsToInsert) if err != nil { // nolint:exhaustruct - return db.Identity{}, fault.Wrap(err, + return txResult{}, fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), fault.Internal("database failed to insert ratelimits"), fault.Public("Failed to insert ratelimits"), ) } } + } else { + // No ratelimit changes - keep existing ones + for _, rl := range existingRatelimits { + finalRatelimits = append(finalRatelimits, openapi.RatelimitResponse{ + Id: rl.ID, + Name: rl.Name, + Limit: int64(rl.Limit), + Duration: rl.Duration, + AutoApply: rl.AutoApply, + }) + } } err = h.Auditlogs.Insert(ctx, tx, auditLogs) if err != nil { // nolint:exhaustruct - return db.Identity{}, err + return txResult{}, err } - return identity, nil + return txResult{ + identity: identityRow, + finalRatelimits: finalRatelimits, + }, nil }) if err != nil { return err } - updatedRatelimits, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: identity.ID, Valid: true}) - if err != nil && !db.IsNotFound(err) { - return fault.Wrap(err, - fault.Internal("unable to fetch updated ratelimits"), - fault.Public("We were able to update the identity but unable to retrieve the updated ratelimits."), - ) - } - - responseRatelimits := make([]openapi.RatelimitResponse, 0, len(updatedRatelimits)) - for _, r := range updatedRatelimits { - responseRatelimits = append(responseRatelimits, openapi.RatelimitResponse{ - Id: r.ID, - Name: r.Name, - Limit: int64(r.Limit), - Duration: r.Duration, - AutoApply: r.AutoApply, - }) - } - + // No extra SELECT query needed - we built the ratelimits list during the transaction! identityData := openapi.Identity{ - Id: identity.ID, - ExternalId: identity.ExternalID, + Id: result.identity.ID, + ExternalId: result.identity.ExternalID, Meta: req.Meta, Ratelimits: nil, } - if len(responseRatelimits) > 0 { - identityData.Ratelimits = ptr.P(responseRatelimits) + if len(result.finalRatelimits) > 0 { + identityData.Ratelimits = ptr.P(result.finalRatelimits) } response := Response{ diff --git a/go/apps/api/routes/v2_keys_create_key/200_test.go b/go/apps/api/routes/v2_keys_create_key/200_test.go index 7a51d49668..f847c359a0 100644 --- a/go/apps/api/routes/v2_keys_create_key/200_test.go +++ b/go/apps/api/routes/v2_keys_create_key/200_test.go @@ -280,9 +280,9 @@ func TestCreateKeyConcurrentWithSameExternalId(t *testing.T) { } // Verify only one identity was created - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ + identity, err := db.Query.FindIdentityByExternalID(ctx, h.DB.RO(), db.FindIdentityByExternalIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - Identity: externalID, + ExternalID: externalID, Deleted: false, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_create_key/handler.go b/go/apps/api/routes/v2_keys_create_key/handler.go index 58c5a038db..47ddc8d3fb 100644 --- a/go/apps/api/routes/v2_keys_create_key/handler.go +++ b/go/apps/api/routes/v2_keys_create_key/handler.go @@ -210,9 +210,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { externalID := *req.ExternalId // Try to find existing identity - identity, err := db.Query.FindIdentity(ctx, tx, db.FindIdentityParams{ + identity, err := db.Query.FindIdentityByExternalID(ctx, tx, db.FindIdentityByExternalIDParams{ WorkspaceID: auth.AuthorizedWorkspaceID, - Identity: externalID, + ExternalID: externalID, Deleted: false, }) diff --git a/go/apps/api/routes/v2_keys_update_key/200_test.go b/go/apps/api/routes/v2_keys_update_key/200_test.go index a6f3fc6de9..9bc3f39aa7 100644 --- a/go/apps/api/routes/v2_keys_update_key/200_test.go +++ b/go/apps/api/routes/v2_keys_update_key/200_test.go @@ -194,8 +194,8 @@ func TestUpdateKeyUpdateAllFields(t *testing.T) { require.Equal(t, int32(50), key.RefillAmount.Int32) // Verify identity was created with correct external ID - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ - Identity: key.IdentityID.String, + identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + IdentityID: key.IdentityID.String, WorkspaceID: h.Resources().UserWorkspace.ID, }) require.NoError(t, err) diff --git a/go/apps/api/routes/v2_keys_update_key/handler.go b/go/apps/api/routes/v2_keys_update_key/handler.go index 0f3c6672dc..549ac86ddc 100644 --- a/go/apps/api/routes/v2_keys_update_key/handler.go +++ b/go/apps/api/routes/v2_keys_update_key/handler.go @@ -151,9 +151,9 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { externalID := req.ExternalId.MustGet() // Try to find existing identity - identity, err := db.Query.FindIdentity(ctx, tx, db.FindIdentityParams{ + identity, err := db.Query.FindIdentityByExternalID(ctx, tx, db.FindIdentityByExternalIDParams{ WorkspaceID: auth.AuthorizedWorkspaceID, - Identity: externalID, + ExternalID: externalID, Deleted: false, }) diff --git a/go/apps/api/routes/v2_keys_update_key/three_state_test.go b/go/apps/api/routes/v2_keys_update_key/three_state_test.go index 46b8312f55..4a30f30cdd 100644 --- a/go/apps/api/routes/v2_keys_update_key/three_state_test.go +++ b/go/apps/api/routes/v2_keys_update_key/three_state_test.go @@ -271,8 +271,8 @@ func TestThreeStateUpdateLogic(t *testing.T) { require.True(t, key.IdentityID.Valid) // Check that the identity exists - identity, err := db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ - Identity: key.IdentityID.String, + identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + IdentityID: key.IdentityID.String, WorkspaceID: h.Resources().UserWorkspace.ID, }) require.NoError(t, err) @@ -318,8 +318,8 @@ func TestThreeStateUpdateLogic(t *testing.T) { require.True(t, key.IdentityID.Valid) // Check that the identity exists with correct external ID - identity, err = db.Query.FindIdentity(ctx, h.DB.RO(), db.FindIdentityParams{ - Identity: key.IdentityID.String, + identity, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + IdentityID: key.IdentityID.String, WorkspaceID: h.Resources().UserWorkspace.ID, }) require.NoError(t, err) diff --git a/go/pkg/db/identity_delete_old_by_external_id.sql_generated.go b/go/pkg/db/identity_delete_old_by_external_id.sql_generated.go new file mode 100644 index 0000000000..cf0535d3c4 --- /dev/null +++ b/go/pkg/db/identity_delete_old_by_external_id.sql_generated.go @@ -0,0 +1,40 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: identity_delete_old_by_external_id.sql + +package db + +import ( + "context" +) + +const deleteOldIdentityByExternalID = `-- name: DeleteOldIdentityByExternalID :exec +DELETE i, rl +FROM identities i +LEFT JOIN ratelimits rl ON rl.identity_id = i.id +WHERE i.workspace_id = ? + AND i.external_id = ? + AND i.id != ? + AND i.deleted = true +` + +type DeleteOldIdentityByExternalIDParams struct { + WorkspaceID string `db:"workspace_id"` + ExternalID string `db:"external_id"` + CurrentIdentityID string `db:"current_identity_id"` +} + +// DeleteOldIdentityByExternalID +// +// DELETE i, rl +// FROM identities i +// LEFT JOIN ratelimits rl ON rl.identity_id = i.id +// WHERE i.workspace_id = ? +// AND i.external_id = ? +// AND i.id != ? +// AND i.deleted = true +func (q *Queries) DeleteOldIdentityByExternalID(ctx context.Context, db DBTX, arg DeleteOldIdentityByExternalIDParams) error { + _, err := db.ExecContext(ctx, deleteOldIdentityByExternalID, arg.WorkspaceID, arg.ExternalID, arg.CurrentIdentityID) + return err +} diff --git a/go/pkg/db/identity_find.sql_generated.go b/go/pkg/db/identity_find.sql_generated.go index 7bb2178827..ab01339676 100644 --- a/go/pkg/db/identity_find.sql_generated.go +++ b/go/pkg/db/identity_find.sql_generated.go @@ -9,34 +9,66 @@ import ( "context" ) -const findIdentity = `-- name: FindIdentity :one -SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at -FROM identities -WHERE workspace_id = ? - AND (external_id = ? OR id = ?) - AND deleted = ? +const findIdentityByExternalID = `-- name: FindIdentityByExternalID :one +SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at +FROM identities +WHERE workspace_id = ? + AND external_id = ? + AND deleted = ? ` -type FindIdentityParams struct { +type FindIdentityByExternalIDParams struct { WorkspaceID string `db:"workspace_id"` - Identity string `db:"identity"` + ExternalID string `db:"external_id"` Deleted bool `db:"deleted"` } -// FindIdentity +// FindIdentityByExternalID // // SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at // FROM identities // WHERE workspace_id = ? -// AND (external_id = ? OR id = ?) -// AND deleted = ? -func (q *Queries) FindIdentity(ctx context.Context, db DBTX, arg FindIdentityParams) (Identity, error) { - row := db.QueryRowContext(ctx, findIdentity, - arg.WorkspaceID, - arg.Identity, - arg.Identity, - arg.Deleted, +// AND external_id = ? +// AND deleted = ? +func (q *Queries) FindIdentityByExternalID(ctx context.Context, db DBTX, arg FindIdentityByExternalIDParams) (Identity, error) { + row := db.QueryRowContext(ctx, findIdentityByExternalID, arg.WorkspaceID, arg.ExternalID, arg.Deleted) + var i Identity + err := row.Scan( + &i.ID, + &i.ExternalID, + &i.WorkspaceID, + &i.Environment, + &i.Meta, + &i.Deleted, + &i.CreatedAt, + &i.UpdatedAt, ) + return i, err +} + +const findIdentityByID = `-- name: FindIdentityByID :one +SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at +FROM identities +WHERE workspace_id = ? + AND id = ? + AND deleted = ? +` + +type FindIdentityByIDParams struct { + WorkspaceID string `db:"workspace_id"` + IdentityID string `db:"identity_id"` + Deleted bool `db:"deleted"` +} + +// FindIdentityByID +// +// SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at +// FROM identities +// WHERE workspace_id = ? +// AND id = ? +// AND deleted = ? +func (q *Queries) FindIdentityByID(ctx context.Context, db DBTX, arg FindIdentityByIDParams) (Identity, error) { + row := db.QueryRowContext(ctx, findIdentityByID, arg.WorkspaceID, arg.IdentityID, arg.Deleted) var i Identity err := row.Scan( &i.ID, diff --git a/go/pkg/db/identity_find_with_ratelimits.sql_generated.go b/go/pkg/db/identity_find_with_ratelimits.sql_generated.go new file mode 100644 index 0000000000..92558675b4 --- /dev/null +++ b/go/pkg/db/identity_find_with_ratelimits.sql_generated.go @@ -0,0 +1,162 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: identity_find_with_ratelimits.sql + +package db + +import ( + "context" + "database/sql" +) + +const findIdentityWithRatelimits = `-- name: FindIdentityWithRatelimits :many +SELECT + i.id, i.external_id, i.workspace_id, i.environment, i.meta, i.deleted, i.created_at, i.updated_at, + COALESCE( + (SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'id', rl.id, + 'name', rl.name, + 'key_id', rl.key_id, + 'identity_id', rl.identity_id, + 'limit', rl.` + "`" + `limit` + "`" + `, + 'duration', rl.duration, + 'auto_apply', rl.auto_apply = 1 + ) + ) + FROM ratelimits rl WHERE rl.identity_id = i.id), + JSON_ARRAY() + ) as ratelimits +FROM identities i +WHERE i.workspace_id = ? + AND i.id = ? + AND i.deleted = ? +UNION ALL +SELECT + i.id, i.external_id, i.workspace_id, i.environment, i.meta, i.deleted, i.created_at, i.updated_at, + COALESCE( + (SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'id', rl.id, + 'name', rl.name, + 'key_id', rl.key_id, + 'identity_id', rl.identity_id, + 'limit', rl.` + "`" + `limit` + "`" + `, + 'duration', rl.duration, + 'auto_apply', rl.auto_apply = 1 + ) + ) + FROM ratelimits rl WHERE rl.identity_id = i.id), + JSON_ARRAY() + ) as ratelimits +FROM identities i +WHERE i.workspace_id = ? + AND i.external_id = ? + AND i.deleted = ? +LIMIT 1 +` + +type FindIdentityWithRatelimitsParams struct { + WorkspaceID string `db:"workspace_id"` + Identity string `db:"identity"` + Deleted bool `db:"deleted"` +} + +type FindIdentityWithRatelimitsRow struct { + ID string `db:"id"` + ExternalID string `db:"external_id"` + WorkspaceID string `db:"workspace_id"` + Environment string `db:"environment"` + Meta []byte `db:"meta"` + Deleted bool `db:"deleted"` + CreatedAt int64 `db:"created_at"` + UpdatedAt sql.NullInt64 `db:"updated_at"` + Ratelimits interface{} `db:"ratelimits"` +} + +// FindIdentityWithRatelimits +// +// SELECT +// i.id, i.external_id, i.workspace_id, i.environment, i.meta, i.deleted, i.created_at, i.updated_at, +// COALESCE( +// (SELECT JSON_ARRAYAGG( +// JSON_OBJECT( +// 'id', rl.id, +// 'name', rl.name, +// 'key_id', rl.key_id, +// 'identity_id', rl.identity_id, +// 'limit', rl.`limit`, +// 'duration', rl.duration, +// 'auto_apply', rl.auto_apply = 1 +// ) +// ) +// FROM ratelimits rl WHERE rl.identity_id = i.id), +// JSON_ARRAY() +// ) as ratelimits +// FROM identities i +// WHERE i.workspace_id = ? +// AND i.id = ? +// AND i.deleted = ? +// UNION ALL +// SELECT +// i.id, i.external_id, i.workspace_id, i.environment, i.meta, i.deleted, i.created_at, i.updated_at, +// COALESCE( +// (SELECT JSON_ARRAYAGG( +// JSON_OBJECT( +// 'id', rl.id, +// 'name', rl.name, +// 'key_id', rl.key_id, +// 'identity_id', rl.identity_id, +// 'limit', rl.`limit`, +// 'duration', rl.duration, +// 'auto_apply', rl.auto_apply = 1 +// ) +// ) +// FROM ratelimits rl WHERE rl.identity_id = i.id), +// JSON_ARRAY() +// ) as ratelimits +// FROM identities i +// WHERE i.workspace_id = ? +// AND i.external_id = ? +// AND i.deleted = ? +// LIMIT 1 +func (q *Queries) FindIdentityWithRatelimits(ctx context.Context, db DBTX, arg FindIdentityWithRatelimitsParams) ([]FindIdentityWithRatelimitsRow, error) { + rows, err := db.QueryContext(ctx, findIdentityWithRatelimits, + arg.WorkspaceID, + arg.Identity, + arg.Deleted, + arg.WorkspaceID, + arg.Identity, + arg.Deleted, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FindIdentityWithRatelimitsRow + for rows.Next() { + var i FindIdentityWithRatelimitsRow + if err := rows.Scan( + &i.ID, + &i.ExternalID, + &i.WorkspaceID, + &i.Environment, + &i.Meta, + &i.Deleted, + &i.CreatedAt, + &i.UpdatedAt, + &i.Ratelimits, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index 1b0f601f7d..c278403521 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -90,6 +90,16 @@ type Querier interface { // DELETE FROM roles_permissions // WHERE role_id = ? DeleteManyRolePermissionsByRoleID(ctx context.Context, db DBTX, roleID string) error + //DeleteOldIdentityByExternalID + // + // DELETE i, rl + // FROM identities i + // LEFT JOIN ratelimits rl ON rl.identity_id = i.id + // WHERE i.workspace_id = ? + // AND i.external_id = ? + // AND i.id != ? + // AND i.deleted = true + DeleteOldIdentityByExternalID(ctx context.Context, db DBTX, arg DeleteOldIdentityByExternalIDParams) error //DeleteOldIdentityWithRatelimits // // DELETE i, rl @@ -255,14 +265,69 @@ type Querier interface { // AND project_id = ? // AND slug = ? FindEnvironmentByProjectIdAndSlug(ctx context.Context, db DBTX, arg FindEnvironmentByProjectIdAndSlugParams) (FindEnvironmentByProjectIdAndSlugRow, error) - //FindIdentity + //FindIdentityByExternalID // // SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at // FROM identities // WHERE workspace_id = ? - // AND (external_id = ? OR id = ?) - // AND deleted = ? - FindIdentity(ctx context.Context, db DBTX, arg FindIdentityParams) (Identity, error) + // AND external_id = ? + // AND deleted = ? + FindIdentityByExternalID(ctx context.Context, db DBTX, arg FindIdentityByExternalIDParams) (Identity, error) + //FindIdentityByID + // + // SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at + // FROM identities + // WHERE workspace_id = ? + // AND id = ? + // AND deleted = ? + FindIdentityByID(ctx context.Context, db DBTX, arg FindIdentityByIDParams) (Identity, error) + //FindIdentityWithRatelimits + // + // SELECT + // i.id, i.external_id, i.workspace_id, i.environment, i.meta, i.deleted, i.created_at, i.updated_at, + // COALESCE( + // (SELECT JSON_ARRAYAGG( + // JSON_OBJECT( + // 'id', rl.id, + // 'name', rl.name, + // 'key_id', rl.key_id, + // 'identity_id', rl.identity_id, + // 'limit', rl.`limit`, + // 'duration', rl.duration, + // 'auto_apply', rl.auto_apply = 1 + // ) + // ) + // FROM ratelimits rl WHERE rl.identity_id = i.id), + // JSON_ARRAY() + // ) as ratelimits + // FROM identities i + // WHERE i.workspace_id = ? + // AND i.id = ? + // AND i.deleted = ? + // UNION ALL + // SELECT + // i.id, i.external_id, i.workspace_id, i.environment, i.meta, i.deleted, i.created_at, i.updated_at, + // COALESCE( + // (SELECT JSON_ARRAYAGG( + // JSON_OBJECT( + // 'id', rl.id, + // 'name', rl.name, + // 'key_id', rl.key_id, + // 'identity_id', rl.identity_id, + // 'limit', rl.`limit`, + // 'duration', rl.duration, + // 'auto_apply', rl.auto_apply = 1 + // ) + // ) + // FROM ratelimits rl WHERE rl.identity_id = i.id), + // JSON_ARRAY() + // ) as ratelimits + // FROM identities i + // WHERE i.workspace_id = ? + // AND i.external_id = ? + // AND i.deleted = ? + // LIMIT 1 + FindIdentityWithRatelimits(ctx context.Context, db DBTX, arg FindIdentityWithRatelimitsParams) ([]FindIdentityWithRatelimitsRow, error) //FindKeyByID // // SELECT id, key_auth_id, hash, start, workspace_id, for_workspace_id, name, owner_id, identity_id, meta, expires, created_at_m, updated_at_m, deleted_at_m, refill_day, refill_amount, last_refill_at, enabled, remaining_requests, ratelimit_async, ratelimit_limit, ratelimit_duration, environment FROM `keys` k diff --git a/go/pkg/db/queries/identity_delete_old_by_external_id.sql b/go/pkg/db/queries/identity_delete_old_by_external_id.sql new file mode 100644 index 0000000000..070ffd2bc1 --- /dev/null +++ b/go/pkg/db/queries/identity_delete_old_by_external_id.sql @@ -0,0 +1,8 @@ +-- name: DeleteOldIdentityByExternalID :exec +DELETE i, rl +FROM identities i +LEFT JOIN ratelimits rl ON rl.identity_id = i.id +WHERE i.workspace_id = sqlc.arg(workspace_id) + AND i.external_id = sqlc.arg(external_id) + AND i.id != sqlc.arg(current_identity_id) + AND i.deleted = true; diff --git a/go/pkg/db/queries/identity_find.sql b/go/pkg/db/queries/identity_find.sql index c9ad98a161..17dcaa98cf 100644 --- a/go/pkg/db/queries/identity_find.sql +++ b/go/pkg/db/queries/identity_find.sql @@ -1,6 +1,13 @@ --- name: FindIdentity :one -SELECT * -FROM identities -WHERE workspace_id = sqlc.arg(workspace_id) - AND (external_id = sqlc.arg(identity) OR id = sqlc.arg(identity)) - AND deleted = sqlc.arg(deleted); +-- name: FindIdentityByID :one +SELECT * +FROM identities +WHERE workspace_id = sqlc.arg(workspace_id) + AND id = sqlc.arg(identity_id) + AND deleted = sqlc.arg(deleted); + +-- name: FindIdentityByExternalID :one +SELECT * +FROM identities +WHERE workspace_id = sqlc.arg(workspace_id) + AND external_id = sqlc.arg(external_id) + AND deleted = sqlc.arg(deleted); diff --git a/go/pkg/db/queries/identity_find_with_ratelimits.sql b/go/pkg/db/queries/identity_find_with_ratelimits.sql new file mode 100644 index 0000000000..9f7f24d457 --- /dev/null +++ b/go/pkg/db/queries/identity_find_with_ratelimits.sql @@ -0,0 +1,45 @@ +-- name: FindIdentityWithRatelimits :many +SELECT + i.*, + COALESCE( + (SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'id', rl.id, + 'name', rl.name, + 'key_id', rl.key_id, + 'identity_id', rl.identity_id, + 'limit', rl.`limit`, + 'duration', rl.duration, + 'auto_apply', rl.auto_apply = 1 + ) + ) + FROM ratelimits rl WHERE rl.identity_id = i.id), + JSON_ARRAY() + ) as ratelimits +FROM identities i +WHERE i.workspace_id = sqlc.arg(workspace_id) + AND i.id = sqlc.arg(identity) + AND i.deleted = sqlc.arg(deleted) +UNION ALL +SELECT + i.*, + COALESCE( + (SELECT JSON_ARRAYAGG( + JSON_OBJECT( + 'id', rl.id, + 'name', rl.name, + 'key_id', rl.key_id, + 'identity_id', rl.identity_id, + 'limit', rl.`limit`, + 'duration', rl.duration, + 'auto_apply', rl.auto_apply = 1 + ) + ) + FROM ratelimits rl WHERE rl.identity_id = i.id), + JSON_ARRAY() + ) as ratelimits +FROM identities i +WHERE i.workspace_id = sqlc.arg(workspace_id) + AND i.external_id = sqlc.arg(identity) + AND i.deleted = sqlc.arg(deleted) +LIMIT 1; From fb3f0420e7eaa0bb947478bc1130b770847a5368 Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 17 Oct 2025 15:46:42 +0200 Subject: [PATCH 02/10] fix: rename file --- ...tity_find_by_external_id.sql_generated.go} | 39 +-------------- .../db/identity_find_by_id.sql_generated.go | 47 +++++++++++++++++++ ...d.sql => identity_find_by_external_id.sql} | 7 --- go/pkg/db/queries/identity_find_by_id.sql | 6 +++ 4 files changed, 54 insertions(+), 45 deletions(-) rename go/pkg/db/{identity_find.sql_generated.go => identity_find_by_external_id.sql_generated.go} (53%) create mode 100644 go/pkg/db/identity_find_by_id.sql_generated.go rename go/pkg/db/queries/{identity_find.sql => identity_find_by_external_id.sql} (52%) create mode 100644 go/pkg/db/queries/identity_find_by_id.sql diff --git a/go/pkg/db/identity_find.sql_generated.go b/go/pkg/db/identity_find_by_external_id.sql_generated.go similarity index 53% rename from go/pkg/db/identity_find.sql_generated.go rename to go/pkg/db/identity_find_by_external_id.sql_generated.go index ab01339676..f2bac1688c 100644 --- a/go/pkg/db/identity_find.sql_generated.go +++ b/go/pkg/db/identity_find_by_external_id.sql_generated.go @@ -1,7 +1,7 @@ // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.29.0 -// source: identity_find.sql +// source: identity_find_by_external_id.sql package db @@ -45,40 +45,3 @@ func (q *Queries) FindIdentityByExternalID(ctx context.Context, db DBTX, arg Fin ) return i, err } - -const findIdentityByID = `-- name: FindIdentityByID :one -SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at -FROM identities -WHERE workspace_id = ? - AND id = ? - AND deleted = ? -` - -type FindIdentityByIDParams struct { - WorkspaceID string `db:"workspace_id"` - IdentityID string `db:"identity_id"` - Deleted bool `db:"deleted"` -} - -// FindIdentityByID -// -// SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at -// FROM identities -// WHERE workspace_id = ? -// AND id = ? -// AND deleted = ? -func (q *Queries) FindIdentityByID(ctx context.Context, db DBTX, arg FindIdentityByIDParams) (Identity, error) { - row := db.QueryRowContext(ctx, findIdentityByID, arg.WorkspaceID, arg.IdentityID, arg.Deleted) - var i Identity - err := row.Scan( - &i.ID, - &i.ExternalID, - &i.WorkspaceID, - &i.Environment, - &i.Meta, - &i.Deleted, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} diff --git a/go/pkg/db/identity_find_by_id.sql_generated.go b/go/pkg/db/identity_find_by_id.sql_generated.go new file mode 100644 index 0000000000..afd21a3d2f --- /dev/null +++ b/go/pkg/db/identity_find_by_id.sql_generated.go @@ -0,0 +1,47 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: identity_find_by_id.sql + +package db + +import ( + "context" +) + +const findIdentityByID = `-- name: FindIdentityByID :one +SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at +FROM identities +WHERE workspace_id = ? + AND id = ? + AND deleted = ? +` + +type FindIdentityByIDParams struct { + WorkspaceID string `db:"workspace_id"` + IdentityID string `db:"identity_id"` + Deleted bool `db:"deleted"` +} + +// FindIdentityByID +// +// SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at +// FROM identities +// WHERE workspace_id = ? +// AND id = ? +// AND deleted = ? +func (q *Queries) FindIdentityByID(ctx context.Context, db DBTX, arg FindIdentityByIDParams) (Identity, error) { + row := db.QueryRowContext(ctx, findIdentityByID, arg.WorkspaceID, arg.IdentityID, arg.Deleted) + var i Identity + err := row.Scan( + &i.ID, + &i.ExternalID, + &i.WorkspaceID, + &i.Environment, + &i.Meta, + &i.Deleted, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/go/pkg/db/queries/identity_find.sql b/go/pkg/db/queries/identity_find_by_external_id.sql similarity index 52% rename from go/pkg/db/queries/identity_find.sql rename to go/pkg/db/queries/identity_find_by_external_id.sql index 17dcaa98cf..33880825c0 100644 --- a/go/pkg/db/queries/identity_find.sql +++ b/go/pkg/db/queries/identity_find_by_external_id.sql @@ -1,10 +1,3 @@ --- name: FindIdentityByID :one -SELECT * -FROM identities -WHERE workspace_id = sqlc.arg(workspace_id) - AND id = sqlc.arg(identity_id) - AND deleted = sqlc.arg(deleted); - -- name: FindIdentityByExternalID :one SELECT * FROM identities diff --git a/go/pkg/db/queries/identity_find_by_id.sql b/go/pkg/db/queries/identity_find_by_id.sql new file mode 100644 index 0000000000..fe2739f565 --- /dev/null +++ b/go/pkg/db/queries/identity_find_by_id.sql @@ -0,0 +1,6 @@ +-- name: FindIdentityByID :one +SELECT * +FROM identities +WHERE workspace_id = sqlc.arg(workspace_id) + AND id = sqlc.arg(identity_id) + AND deleted = sqlc.arg(deleted); From cb34e36971caa9b0ba5149d18c0d905bfedb919e Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 17 Oct 2025 16:46:33 +0200 Subject: [PATCH 03/10] try something --- .github/workflows/job_test_api_local.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/job_test_api_local.yaml b/.github/workflows/job_test_api_local.yaml index 4e019dac03..a3d00a03ae 100644 --- a/.github/workflows/job_test_api_local.yaml +++ b/.github/workflows/job_test_api_local.yaml @@ -17,7 +17,9 @@ jobs: mkdir -p ./apps/dashboard touch ./apps/dashboard/.env - name: Run containers - run: docker compose -f ./deployment/docker-compose.yaml up mysql redis clickhouse planetscale agent s3 apiv2 api -d --wait + run: | + docker buildx prune -af + docker compose -f ./deployment/docker-compose.yaml up mysql redis clickhouse planetscale agent s3 apiv2 api -d --wait --no-cache env: DOCKER_BUILDKIT: 1 COMPOSE_DOCKER_CLI_BUILD: 1 From 3b21dbe94e3765b82776013addbf8942337c083e Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 17 Oct 2025 16:47:51 +0200 Subject: [PATCH 04/10] undo change --- .github/workflows/job_test_api_local.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/job_test_api_local.yaml b/.github/workflows/job_test_api_local.yaml index a3d00a03ae..4e019dac03 100644 --- a/.github/workflows/job_test_api_local.yaml +++ b/.github/workflows/job_test_api_local.yaml @@ -17,9 +17,7 @@ jobs: mkdir -p ./apps/dashboard touch ./apps/dashboard/.env - name: Run containers - run: | - docker buildx prune -af - docker compose -f ./deployment/docker-compose.yaml up mysql redis clickhouse planetscale agent s3 apiv2 api -d --wait --no-cache + run: docker compose -f ./deployment/docker-compose.yaml up mysql redis clickhouse planetscale agent s3 apiv2 api -d --wait env: DOCKER_BUILDKIT: 1 COMPOSE_DOCKER_CLI_BUILD: 1 From 4d821fac025f992a333998061998f4ec0d375e20 Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 17 Oct 2025 17:09:13 +0200 Subject: [PATCH 05/10] force build --- .github/workflows/job_test_api_local.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/job_test_api_local.yaml b/.github/workflows/job_test_api_local.yaml index 4e019dac03..1d0f9b8ae8 100644 --- a/.github/workflows/job_test_api_local.yaml +++ b/.github/workflows/job_test_api_local.yaml @@ -17,7 +17,7 @@ jobs: mkdir -p ./apps/dashboard touch ./apps/dashboard/.env - name: Run containers - run: docker compose -f ./deployment/docker-compose.yaml up mysql redis clickhouse planetscale agent s3 apiv2 api -d --wait + run: docker compose -f ./deployment/docker-compose.yaml up mysql redis clickhouse planetscale agent s3 apiv2 api -d --wait --build env: DOCKER_BUILDKIT: 1 COMPOSE_DOCKER_CLI_BUILD: 1 From 9cb2faba2e59debf43b0d6a356ffaf205203b9b7 Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 17 Oct 2025 17:17:04 +0200 Subject: [PATCH 06/10] try without blacksmith --- .github/workflows/job_test_api_local.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/job_test_api_local.yaml b/.github/workflows/job_test_api_local.yaml index 1d0f9b8ae8..f04f9007dd 100644 --- a/.github/workflows/job_test_api_local.yaml +++ b/.github/workflows/job_test_api_local.yaml @@ -7,7 +7,8 @@ jobs: test: name: API Test Local timeout-minutes: 90 - runs-on: blacksmith-8vcpu-ubuntu-2404 + # runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-latest steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Docker Buildx @@ -17,7 +18,7 @@ jobs: mkdir -p ./apps/dashboard touch ./apps/dashboard/.env - name: Run containers - run: docker compose -f ./deployment/docker-compose.yaml up mysql redis clickhouse planetscale agent s3 apiv2 api -d --wait --build + run: docker compose -f ./deployment/docker-compose.yaml up mysql redis clickhouse planetscale agent s3 apiv2 api -d --wait env: DOCKER_BUILDKIT: 1 COMPOSE_DOCKER_CLI_BUILD: 1 From c39ca8272f3d0ecf9c37d06a4927006e6a0efda4 Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 17 Oct 2025 17:18:30 +0200 Subject: [PATCH 07/10] try without blacksmith --- .github/workflows/job_test_api_local.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/job_test_api_local.yaml b/.github/workflows/job_test_api_local.yaml index f04f9007dd..7ebe03345c 100644 --- a/.github/workflows/job_test_api_local.yaml +++ b/.github/workflows/job_test_api_local.yaml @@ -11,8 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + # - name: Set up Docker Buildx + # uses: useblacksmith/setup-docker-builder@1796035cb0632d35796ffec6b4b3bddafbc85c6e # v1 - name: Set up Docker Buildx - uses: useblacksmith/setup-docker-builder@1796035cb0632d35796ffec6b4b3bddafbc85c6e # v1 + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Create dashboard env file for Docker Compose run: | mkdir -p ./apps/dashboard From 5430164a9994dbfe8ba355659c0b8d84df312313 Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 17 Oct 2025 17:28:06 +0200 Subject: [PATCH 08/10] go back? --- .github/workflows/job_test_api_local.yaml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/job_test_api_local.yaml b/.github/workflows/job_test_api_local.yaml index 7ebe03345c..4e019dac03 100644 --- a/.github/workflows/job_test_api_local.yaml +++ b/.github/workflows/job_test_api_local.yaml @@ -7,14 +7,11 @@ jobs: test: name: API Test Local timeout-minutes: 90 - # runs-on: blacksmith-8vcpu-ubuntu-2404 - runs-on: ubuntu-latest + runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - # - name: Set up Docker Buildx - # uses: useblacksmith/setup-docker-builder@1796035cb0632d35796ffec6b4b3bddafbc85c6e # v1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: useblacksmith/setup-docker-builder@1796035cb0632d35796ffec6b4b3bddafbc85c6e # v1 - name: Create dashboard env file for Docker Compose run: | mkdir -p ./apps/dashboard From 6648d2bd99cea58ffe8d4387165d6021b9111bc7 Mon Sep 17 00:00:00 2001 From: Flo Date: Fri, 17 Oct 2025 17:44:45 +0200 Subject: [PATCH 09/10] go back? --- .github/workflows/job_test_api_local.yaml | 2 +- .github/workflows/job_test_go_api_local.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/job_test_api_local.yaml b/.github/workflows/job_test_api_local.yaml index 4e019dac03..16985eb302 100644 --- a/.github/workflows/job_test_api_local.yaml +++ b/.github/workflows/job_test_api_local.yaml @@ -11,7 +11,7 @@ jobs: steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Docker Buildx - uses: useblacksmith/setup-docker-builder@1796035cb0632d35796ffec6b4b3bddafbc85c6e # v1 + uses: useblacksmith/setup-docker-builder@78f41686563c732ccc097f7baac2f092f67538f0 # v1 - name: Create dashboard env file for Docker Compose run: | mkdir -p ./apps/dashboard diff --git a/.github/workflows/job_test_go_api_local.yaml b/.github/workflows/job_test_go_api_local.yaml index 3312ebb155..c37a0d4a49 100644 --- a/.github/workflows/job_test_go_api_local.yaml +++ b/.github/workflows/job_test_go_api_local.yaml @@ -14,7 +14,7 @@ jobs: mkdir -p ./apps/dashboard touch ./apps/dashboard/.env - name: Set up Docker Buildx - uses: useblacksmith/setup-docker-builder@1796035cb0632d35796ffec6b4b3bddafbc85c6e # v1 + uses: useblacksmith/setup-docker-builder@78f41686563c732ccc097f7baac2f092f67538f0 # v1 - name: Setup Go uses: ./.github/actions/setup-go with: From a0d7e96f2c559dc94ac9648ab687462d306672cd Mon Sep 17 00:00:00 2001 From: Flo Date: Wed, 22 Oct 2025 20:08:55 +0200 Subject: [PATCH 10/10] fix: tests --- .../v2_identities_delete_identity/200_test.go | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/go/apps/api/routes/v2_identities_delete_identity/200_test.go b/go/apps/api/routes/v2_identities_delete_identity/200_test.go index e70f1be85f..b435d9bc14 100644 --- a/go/apps/api/routes/v2_identities_delete_identity/200_test.go +++ b/go/apps/api/routes/v2_identities_delete_identity/200_test.go @@ -38,7 +38,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { t.Run("delete identity by external ID", func(t *testing.T) { externalID := "test_user_1" - identityID := h.CreateIdentity(seed.CreateIdentityRequest{ + identity := h.CreateIdentity(seed.CreateIdentityRequest{ WorkspaceID: h.Resources().UserWorkspace.ID, ExternalID: externalID, Meta: []byte("{}"), @@ -71,7 +71,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { // Verify identity still exists but marked as deleted deletedIdentity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - IdentityID: identityID, + IdentityID: identity.ID, Deleted: true, }) require.NoError(t, err, "identity should still exist with deleted=true") @@ -95,7 +95,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { }, ) - identityID := h.CreateIdentity(seed.CreateIdentityRequest{ + identity := h.CreateIdentity(seed.CreateIdentityRequest{ WorkspaceID: h.Resources().UserWorkspace.ID, ExternalID: externalID, Meta: []byte("{}"), @@ -103,7 +103,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { }) // Verify rate limits exist - rateLimits, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: identityID, Valid: true}) + rateLimits, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: identity.ID, Valid: true}) require.NoError(t, err) require.Len(t, rateLimits, numberOfRatelimits) @@ -117,20 +117,20 @@ func TestDeleteIdentitySuccess(t *testing.T) { // Verify identity is soft deleted _, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - IdentityID: identityID, + IdentityID: identity.ID, Deleted: false, }) require.Equal(t, sql.ErrNoRows, err) // Verify rate limits still exist (they should remain for audit purposes) - rateLimitsAfterDeletion, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: identityID, Valid: true}) + rateLimitsAfterDeletion, err := db.Query.ListIdentityRatelimitsByID(ctx, h.DB.RO(), sql.NullString{String: identity.ID, Valid: true}) require.NoError(t, err) require.Len(t, rateLimitsAfterDeletion, numberOfRatelimits, "rate limits should still exist after soft deletion") }) t.Run("delete identity with wildcard permission", func(t *testing.T) { externalID := "test_user_wildcard" - identityID := h.CreateIdentity(seed.CreateIdentityRequest{ + identity := h.CreateIdentity(seed.CreateIdentityRequest{ WorkspaceID: h.Resources().UserWorkspace.ID, ExternalID: externalID, Meta: []byte("{}"), @@ -152,7 +152,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { // Verify identity is soft deleted _, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - IdentityID: identityID, + IdentityID: identity.ID, Deleted: false, }) require.Equal(t, sql.ErrNoRows, err) @@ -160,7 +160,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { t.Run("verify audit logs are created", func(t *testing.T) { externalID := "test_user_audit_logs" - identityID := h.CreateIdentity(seed.CreateIdentityRequest{ + identity := h.CreateIdentity(seed.CreateIdentityRequest{ WorkspaceID: h.Resources().UserWorkspace.ID, ExternalID: externalID, Meta: []byte("{}"), @@ -187,7 +187,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { require.Equal(t, 200, res.Status, "expected 200, received: %#v", res) // Verify audit logs were created - auditLogs, err := db.Query.FindAuditLogTargetByID(ctx, h.DB.RO(), identityID) + auditLogs, err := db.Query.FindAuditLogTargetByID(ctx, h.DB.RO(), identity.ID) require.NoError(t, err) require.GreaterOrEqual(t, len(auditLogs), 1, "should have audit logs for identity deletion") @@ -207,7 +207,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { externalID := "test_user_duplicate" // Create first identity - identityID1 := h.CreateIdentity(seed.CreateIdentityRequest{ + identity1 := h.CreateIdentity(seed.CreateIdentityRequest{ WorkspaceID: h.Resources().UserWorkspace.ID, ExternalID: externalID, Meta: []byte("{}"), @@ -219,7 +219,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { require.Equal(t, 200, res1.Status, "first deletion should succeed") // Create a new identity with the same external ID (this will trigger the duplicate key scenario) - identityID2 := h.CreateIdentity(seed.CreateIdentityRequest{ + identity2 := h.CreateIdentity(seed.CreateIdentityRequest{ WorkspaceID: h.Resources().UserWorkspace.ID, ExternalID: externalID, Meta: []byte("{}"), @@ -233,7 +233,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { // Verify the new identity is soft deleted _, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - IdentityID: identityID2, + IdentityID: identity2.ID, Deleted: false, }) require.Equal(t, sql.ErrNoRows, err) @@ -241,7 +241,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { // Verify the old identity was hard deleted (should not be found even with deleted=true) _, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - IdentityID: identityID1, + IdentityID: identity1.ID, Deleted: true, }) require.Equal(t, sql.ErrNoRows, err, "old identity should be hard deleted") @@ -255,7 +255,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { externalID := "stripe_user_12345" // Step 1: Create initial identity with "Advanced" tier ratelimit (300k/month) - identityID1 := h.CreateIdentity(seed.CreateIdentityRequest{ + identity1 := h.CreateIdentity(seed.CreateIdentityRequest{ WorkspaceID: h.Resources().UserWorkspace.ID, ExternalID: externalID, Meta: []byte(`{"tier":"advanced"}`), @@ -275,7 +275,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { require.Equal(t, 200, res1.Status, "first deletion should succeed") // Step 3: Create new identity with "Starter" tier ratelimit (20k/month) - identityID2 := h.CreateIdentity(seed.CreateIdentityRequest{ + identity2 := h.CreateIdentity(seed.CreateIdentityRequest{ WorkspaceID: h.Resources().UserWorkspace.ID, ExternalID: externalID, // Same externalId Meta: []byte(`{"tier":"starter"}`), @@ -297,17 +297,17 @@ func TestDeleteIdentitySuccess(t *testing.T) { // Verify the new identity is soft deleted (not hard deleted) deletedIdentity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - IdentityID: identityID2, + IdentityID: identity2.ID, Deleted: true, }) require.NoError(t, err, "new identity should exist as soft-deleted") - require.Equal(t, identityID2, deletedIdentity.ID) + require.Equal(t, identity2.ID, deletedIdentity.ID) require.True(t, deletedIdentity.Deleted) // Verify the new identity cannot be found as active _, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - IdentityID: identityID2, + IdentityID: identity2.ID, Deleted: false, }) require.Equal(t, sql.ErrNoRows, err, "new identity should not be active") @@ -315,7 +315,7 @@ func TestDeleteIdentitySuccess(t *testing.T) { // Verify the old identity was hard deleted (cleanup) _, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ WorkspaceID: h.Resources().UserWorkspace.ID, - IdentityID: identityID1, + IdentityID: identity1.ID, Deleted: true, }) require.Equal(t, sql.ErrNoRows, err, "old identity should be hard deleted as cleanup")