diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go index a39fe3bb1b..ad8f403a05 100644 --- a/go/apps/api/openapi/gen.go +++ b/go/apps/api/openapi/gen.go @@ -3,6 +3,13 @@ // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT. package openapi +import ( + "encoding/json" + "fmt" + + "github.com/oapi-codegen/runtime" +) + const ( RootKeyScopes = "rootKey.Scopes" ) @@ -118,6 +125,18 @@ type PreconditionFailedErrorResponse struct { Meta Meta `json:"meta"` } +// Ratelimit defines model for Ratelimit. +type Ratelimit struct { + // Duration The duration for each ratelimit window in milliseconds. + Duration int64 `json:"duration"` + + // Limit How many requests may pass within a given window before requests are rejected. + Limit int64 `json:"limit"` + + // Name The name of this limit. You will need to use this again when verifying a key. + Name string `json:"name"` +} + // RatelimitDeleteOverrideResponseData defines model for RatelimitDeleteOverrideResponseData. type RatelimitDeleteOverrideResponseData = map[string]interface{} @@ -202,7 +221,7 @@ type V2IdentitiesCreateIdentityRequestBody struct { // Ratelimits Attach ratelimits to this identity. // // When verifying keys, you can specify which limits you want to use and all keys attached to this identity, will share the limits. - Ratelimits *[]V2Ratelimit `json:"ratelimits,omitempty"` + Ratelimits *[]Ratelimit `json:"ratelimits,omitempty"` } // V2IdentitiesCreateIdentityResponseBody defines model for V2IdentitiesCreateIdentityResponseBody. @@ -211,22 +230,32 @@ type V2IdentitiesCreateIdentityResponseBody struct { Meta Meta `json:"meta"` } -// V2LivenessResponseBody defines model for V2LivenessResponseBody. -type V2LivenessResponseBody struct { - Data LivenessResponseData `json:"data"` - Meta Meta `json:"meta"` +// V2IdentitiesDeleteIdentityRequestBody defines model for V2IdentitiesDeleteIdentityRequestBody. +type V2IdentitiesDeleteIdentityRequestBody struct { + // ExternalId The id of this identity in your system. + // + // This usually comes from your authentication provider and could be a userId, organisationId or even an email. + // It does not matter what you use, as long as it uniquely identifies something in your application. + ExternalId *string `json:"externalId,omitempty"` + + // IdentityId The Unkey Identity ID. + IdentityId *string `json:"identityId,omitempty"` + union json.RawMessage } -// V2Ratelimit defines model for V2Ratelimit. -type V2Ratelimit struct { - // Duration The duration for each ratelimit window in milliseconds. - Duration int64 `json:"duration"` +// V2IdentitiesDeleteIdentityRequestBody0 defines model for . +type V2IdentitiesDeleteIdentityRequestBody0 = interface{} - // Limit How many requests may pass within a given window before requests are rejected. - Limit int64 `json:"limit"` +// V2IdentitiesDeleteIdentityRequestBody1 defines model for . +type V2IdentitiesDeleteIdentityRequestBody1 = interface{} - // Name The name of this limit. You will need to use this again when verifying a key. - Name string `json:"name"` +// V2IdentitiesDeleteIdentityResponseBody defines model for V2IdentitiesDeleteIdentityResponseBody. +type V2IdentitiesDeleteIdentityResponseBody = map[string]interface{} + +// V2LivenessResponseBody defines model for V2LivenessResponseBody. +type V2LivenessResponseBody struct { + Data LivenessResponseData `json:"data"` + Meta Meta `json:"meta"` } // V2RatelimitDeleteOverrideRequestBody Deletes an existing override. @@ -353,6 +382,9 @@ type CreateApiJSONRequestBody = V2ApisCreateApiRequestBody // IdentitiesCreateIdentityJSONRequestBody defines body for IdentitiesCreateIdentity for application/json ContentType. type IdentitiesCreateIdentityJSONRequestBody = V2IdentitiesCreateIdentityRequestBody +// V2IdentitiesDeleteIdentityJSONRequestBody defines body for V2IdentitiesDeleteIdentity for application/json ContentType. +type V2IdentitiesDeleteIdentityJSONRequestBody = V2IdentitiesDeleteIdentityRequestBody + // RatelimitDeleteOverrideJSONRequestBody defines body for RatelimitDeleteOverride for application/json ContentType. type RatelimitDeleteOverrideJSONRequestBody = V2RatelimitDeleteOverrideRequestBody @@ -367,3 +399,113 @@ type RatelimitListOverridesJSONRequestBody = V2RatelimitListOverridesRequestBody // RatelimitSetOverrideJSONRequestBody defines body for RatelimitSetOverride for application/json ContentType. type RatelimitSetOverrideJSONRequestBody = V2RatelimitSetOverrideRequestBody + +// AsV2IdentitiesDeleteIdentityRequestBody0 returns the union data inside the V2IdentitiesDeleteIdentityRequestBody as a V2IdentitiesDeleteIdentityRequestBody0 +func (t V2IdentitiesDeleteIdentityRequestBody) AsV2IdentitiesDeleteIdentityRequestBody0() (V2IdentitiesDeleteIdentityRequestBody0, error) { + var body V2IdentitiesDeleteIdentityRequestBody0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromV2IdentitiesDeleteIdentityRequestBody0 overwrites any union data inside the V2IdentitiesDeleteIdentityRequestBody as the provided V2IdentitiesDeleteIdentityRequestBody0 +func (t *V2IdentitiesDeleteIdentityRequestBody) FromV2IdentitiesDeleteIdentityRequestBody0(v V2IdentitiesDeleteIdentityRequestBody0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeV2IdentitiesDeleteIdentityRequestBody0 performs a merge with any union data inside the V2IdentitiesDeleteIdentityRequestBody, using the provided V2IdentitiesDeleteIdentityRequestBody0 +func (t *V2IdentitiesDeleteIdentityRequestBody) MergeV2IdentitiesDeleteIdentityRequestBody0(v V2IdentitiesDeleteIdentityRequestBody0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsV2IdentitiesDeleteIdentityRequestBody1 returns the union data inside the V2IdentitiesDeleteIdentityRequestBody as a V2IdentitiesDeleteIdentityRequestBody1 +func (t V2IdentitiesDeleteIdentityRequestBody) AsV2IdentitiesDeleteIdentityRequestBody1() (V2IdentitiesDeleteIdentityRequestBody1, error) { + var body V2IdentitiesDeleteIdentityRequestBody1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromV2IdentitiesDeleteIdentityRequestBody1 overwrites any union data inside the V2IdentitiesDeleteIdentityRequestBody as the provided V2IdentitiesDeleteIdentityRequestBody1 +func (t *V2IdentitiesDeleteIdentityRequestBody) FromV2IdentitiesDeleteIdentityRequestBody1(v V2IdentitiesDeleteIdentityRequestBody1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeV2IdentitiesDeleteIdentityRequestBody1 performs a merge with any union data inside the V2IdentitiesDeleteIdentityRequestBody, using the provided V2IdentitiesDeleteIdentityRequestBody1 +func (t *V2IdentitiesDeleteIdentityRequestBody) MergeV2IdentitiesDeleteIdentityRequestBody1(v V2IdentitiesDeleteIdentityRequestBody1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t V2IdentitiesDeleteIdentityRequestBody) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + if err != nil { + return nil, err + } + object := make(map[string]json.RawMessage) + if t.union != nil { + err = json.Unmarshal(b, &object) + if err != nil { + return nil, err + } + } + + if t.ExternalId != nil { + object["externalId"], err = json.Marshal(t.ExternalId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'externalId': %w", err) + } + } + + if t.IdentityId != nil { + object["identityId"], err = json.Marshal(t.IdentityId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'identityId': %w", err) + } + } + b, err = json.Marshal(object) + return b, err +} + +func (t *V2IdentitiesDeleteIdentityRequestBody) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + if err != nil { + return err + } + object := make(map[string]json.RawMessage) + err = json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["externalId"]; found { + err = json.Unmarshal(raw, &t.ExternalId) + if err != nil { + return fmt.Errorf("error reading 'externalId': %w", err) + } + } + + if raw, found := object["identityId"]; found { + err = json.Unmarshal(raw, &t.IdentityId) + if err != nil { + return fmt.Errorf("error reading 'identityId': %w", err) + } + } + + return err +} diff --git a/go/apps/api/openapi/openapi.json b/go/apps/api/openapi/openapi.json index 2a331a834c..e1e3c815f9 100644 --- a/go/apps/api/openapi/openapi.json +++ b/go/apps/api/openapi/openapi.json @@ -519,19 +519,20 @@ "ratelimits": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Ratelimit" + "$ref": "#/components/schemas/Ratelimit" }, "description": "Attach ratelimits to this identity.\n\nWhen verifying keys, you can specify which limits you want to use and all keys attached to this identity, will share the limits." } } }, - "V2Ratelimit": { + "Ratelimit": { "type": "object", "required": ["name", "limit", "duration"], "properties": { "name": { "description": "The name of this limit. You will need to use this again when verifying a key.", "type": "string", + "example": "api", "minLength": 3, "maxLength": 128 }, @@ -569,6 +570,35 @@ } } }, + "V2IdentitiesDeleteIdentityRequestBody": { + "additionalProperties": false, + "type": "object", + "properties": { + "externalId": { + "type": "string", + "minLength": 3, + "description": "The id of this identity in your system.\n\nThis usually comes from your authentication provider and could be a userId, organisationId or even an email.\nIt does not matter what you use, as long as it uniquely identifies something in your application.\n", + "example": "user_123" + }, + "identityId": { + "type": "string", + "minLength": 3, + "description": "The Unkey Identity ID.", + "example": "id_123" + } + }, + "oneOf": [ + { + "required": ["externalId"] + }, + { + "required": ["identityId"] + } + ] + }, + "V2IdentitiesDeleteIdentityResponseBody": { + "type": "object" + }, "V2ApisCreateApiRequestBody": { "type": "object", "required": ["name"], @@ -1152,6 +1182,90 @@ } } }, + "/v2/identities.deleteIdentity": { + "post": { + "tags": ["identities"], + "operationId": "v2.identities.deleteIdentity", + "x-speakeasy-name-override": "deleteIdentity", + "security": [ + { + "rootKey": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2IdentitiesDeleteIdentityRequestBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2IdentitiesDeleteIdentityResponseBody" + } + } + }, + "description": "OK" + }, + "400": { + "description": "Bad request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/BadRequestErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponse" + } + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponse" + } + } + }, + "description": "Error" + } + } + } + }, "/v2/apis.createApi": { "post": { "tags": ["apis"], diff --git a/go/apps/api/routes/register.go b/go/apps/api/routes/register.go index 79e530d2e9..a0b800b6f8 100644 --- a/go/apps/api/routes/register.go +++ b/go/apps/api/routes/register.go @@ -10,6 +10,7 @@ import ( v2RatelimitSetOverride "github.com/unkeyed/unkey/go/apps/api/routes/v2_ratelimit_set_override" v2IdentitiesCreateIdentity "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_create_identity" + v2IdentitiesDeleteIdentity "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_delete_identity" zen "github.com/unkeyed/unkey/go/pkg/zen" ) @@ -103,6 +104,18 @@ func Register(srv *zen.Server, svc *Services) { }), ) + // v2/identities.deleteIdentity + srv.RegisterRoute( + defaultMiddlewares, + v2IdentitiesDeleteIdentity.New(v2IdentitiesDeleteIdentity.Services{ + Logger: svc.Logger, + DB: svc.Database, + Keys: svc.Keys, + Permissions: svc.Permissions, + Auditlogs: svc.Auditlogs, + }), + ) + // --------------------------------------------------------------------------- // misc diff --git a/go/apps/api/routes/v2_apis_create_api/401_test.go b/go/apps/api/routes/v2_apis_create_api/401_test.go index 9634cfb8a8..7c3c666216 100644 --- a/go/apps/api/routes/v2_apis_create_api/401_test.go +++ b/go/apps/api/routes/v2_apis_create_api/401_test.go @@ -36,5 +36,4 @@ func TestCreateApi_Unauthorized(t *testing.T) { res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) require.Equal(t, http.StatusUnauthorized, res.Status, "expected 401, sent: %+v, received: %s", req, res.RawBody) }) - } 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 c07aa3e26d..0b4439e686 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 @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "net/http" - "slices" "testing" "time" @@ -52,7 +51,10 @@ func TestCreateIdentitySuccessfully(t *testing.T) { }) require.NoError(t, err) - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), identityID) + identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + ID: identityID, + Deleted: false, + }) require.NoError(t, err) require.Equal(t, identity.ExternalID, externalTestID) }) @@ -71,7 +73,10 @@ func TestCreateIdentitySuccessfully(t *testing.T) { }) require.NoError(t, err) - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), identityID) + identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + ID: identityID, + Deleted: false, + }) require.NoError(t, err) require.Equal(t, identity.ExternalID, externalTestID) @@ -104,7 +109,10 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.NotNil(t, res.Body) require.NotEmpty(t, res.Body.Data.IdentityId) - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), res.Body.Data.IdentityId) + identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + ID: res.Body.Data.IdentityId, + Deleted: false, + }) require.NoError(t, err) require.Equal(t, identity.ExternalID, req.ExternalId) }) @@ -113,7 +121,7 @@ func TestCreateIdentitySuccessfully(t *testing.T) { t.Run("create identity with metadata", func(t *testing.T) { externalTestID := uid.New("test_external_id") - meta := &map[string]interface{}{"key": "example"} + meta := &map[string]any{"key": "example"} req := handler.Request{ ExternalId: externalTestID, Meta: meta, @@ -124,11 +132,14 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.NotNil(t, res.Body) require.NotEmpty(t, res.Body.Data.IdentityId) - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), res.Body.Data.IdentityId) + identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + ID: res.Body.Data.IdentityId, + Deleted: false, + }) require.NoError(t, err) require.Equal(t, identity.ExternalID, req.ExternalId) - var dbMeta map[string]interface{} + var dbMeta map[string]any err = json.Unmarshal(identity.Meta, &dbMeta) require.NoError(t, err) require.Equal(t, *meta, dbMeta) @@ -138,7 +149,7 @@ func TestCreateIdentitySuccessfully(t *testing.T) { t.Run("create identity with ratelimits", func(t *testing.T) { externalTestID := uid.New("test_external_id") - identityRateLimits := []openapi.V2Ratelimit{ + identityRateLimits := []openapi.Ratelimit{ { Duration: time.Minute.Milliseconds(), Limit: 100, @@ -161,7 +172,10 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.NotNil(t, res.Body) require.NotEmpty(t, res.Body.Data.IdentityId) - identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), res.Body.Data.IdentityId) + identity, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + ID: res.Body.Data.IdentityId, + Deleted: false, + }) require.NoError(t, err) require.Equal(t, identity.ExternalID, req.ExternalId) @@ -170,7 +184,13 @@ func TestCreateIdentitySuccessfully(t *testing.T) { require.Len(t, rateLimits, len(identityRateLimits)) for _, ratelimit := range identityRateLimits { - idx := slices.IndexFunc(rateLimits, func(c db.FindRatelimitsByIdentityIDRow) bool { return c.Name == ratelimit.Name }) + idx := -1 + for i, limit := range rateLimits { + if limit.Name == ratelimit.Name { + idx = i + break + } + } require.True(t, idx >= 0 && idx < len(rateLimits), "Rate limit with name %s not found in the database", ratelimit.Name) require.Equal(t, rateLimits[idx].Duration, ratelimit.Duration) diff --git a/go/apps/api/routes/v2_identities_create_identity/400_test.go b/go/apps/api/routes/v2_identities_create_identity/400_test.go index 54a2d62fc2..a1bd1ab741 100644 --- a/go/apps/api/routes/v2_identities_create_identity/400_test.go +++ b/go/apps/api/routes/v2_identities_create_identity/400_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/unkeyed/unkey/go/apps/api/openapi" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_create_identity" + "github.com/unkeyed/unkey/go/pkg/ptr" "github.com/unkeyed/unkey/go/pkg/testutil" "github.com/unkeyed/unkey/go/pkg/uid" ) @@ -79,11 +80,10 @@ func TestBadRequests(t *testing.T) { }) t.Run("metadata exceeds maximum size limit", func(t *testing.T) { - metaData := make(map[string]interface{}) + metaData := make(map[string]any) entriesNeeded := (handler.MAX_META_LENGTH_MB * 1024 * 1024) / 15 - for i := 0; i < entriesNeeded+1000; i++ { - var data interface{} = fmt.Sprintf("some_%d", i) - metaData[fmt.Sprintf("key_%d", i)] = &data + for i := range entriesNeeded + 1000 { + metaData[fmt.Sprintf("key_%d", i)] = ptr.P(fmt.Sprintf("some_%d", i)) } rawMeta, _ := json.Marshal(metaData) @@ -102,10 +102,9 @@ func TestBadRequests(t *testing.T) { }) t.Run("invalid ratelimit", func(t *testing.T) { - req := openapi.V2IdentitiesCreateIdentityRequestBody{ ExternalId: uid.New("test"), - Ratelimits: &[]openapi.V2Ratelimit{ + Ratelimits: &[]openapi.Ratelimit{ { Duration: 1, Limit: 1, diff --git a/go/apps/api/routes/v2_identities_create_identity/409_test.go b/go/apps/api/routes/v2_identities_create_identity/409_test.go index 2ad497ce17..9065e21f05 100644 --- a/go/apps/api/routes/v2_identities_create_identity/409_test.go +++ b/go/apps/api/routes/v2_identities_create_identity/409_test.go @@ -40,7 +40,7 @@ func TestCreateIdentityDuplicate(t *testing.T) { require.NotEmpty(t, successRes.Body.Data.IdentityId, successRes.Body) errorRes := testutil.CallRoute[handler.Request, openapi.ConflictErrorResponse](h, route, headers, req) - require.Equal(t, 409, errorRes.Status, "expected 409, received: %#v", errorRes) + require.Equal(t, 409, errorRes.Status, "expected 409, received: %s", errorRes.RawBody) require.NotNil(t, errorRes.Body) require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/data/identity_already_exists", errorRes.Body.Error.Type) }) diff --git a/go/apps/api/routes/v2_identities_create_identity/handler.go b/go/apps/api/routes/v2_identities_create_identity/handler.go index 1a16ecb814..659362d518 100644 --- a/go/apps/api/routes/v2_identities_create_identity/handler.go +++ b/go/apps/api/routes/v2_identities_create_identity/handler.go @@ -9,7 +9,6 @@ import ( "net/http" "time" - "github.com/go-sql-driver/mysql" "github.com/unkeyed/unkey/go/apps/api/openapi" "github.com/unkeyed/unkey/go/internal/services/auditlogs" "github.com/unkeyed/unkey/go/internal/services/keys" @@ -118,16 +117,22 @@ func New(svc Services) zen.Route { }() identityID := uid.New(uid.IdentityPrefix) - err = db.Query.InsertIdentity(ctx, tx, db.InsertIdentityParams{ + args := db.InsertIdentityParams{ ID: identityID, ExternalID: req.ExternalId, WorkspaceID: auth.AuthorizedWorkspaceID, Environment: "default", CreatedAt: time.Now().UnixMilli(), Meta: meta, - }) + } + svc.Logger.Warn("inserting identity", + "args", args, + ) + err = db.Query.InsertIdentity(ctx, tx, args) + + svc.Logger.Error("insert identity failed", "requestId", s.RequestID(), "error", err) if err != nil { - if mysqlErr, ok := err.(*mysql.MySQLError); ok && mysqlErr.Number == 1062 { + if db.IsDuplicateKeyError(err) { return fault.Wrap(err, fault.WithCode(codes.Data.Identity.Duplicate.URN()), fault.WithDesc("identity already exists", fmt.Sprintf("Identity with externalId \"%s\" already exists in this workspace.", req.ExternalId)), 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 new file mode 100644 index 0000000000..3d6e887bcd --- /dev/null +++ b/go/apps/api/routes/v2_identities_delete_identity/200_test.go @@ -0,0 +1,187 @@ +package handler_test + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_delete_identity" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/ptr" + "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +type Identity struct { + ID string + ExternalID string + RatelimitIds []string +} + +// Helper function that creates a new identity with rate-limits and returns it +func newIdentity(t *testing.T, h *testutil.Harness, numberOfRatelimits int) Identity { + 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: nil, + CreatedAt: time.Now().UnixMilli(), + Environment: "default", + }) + require.NoError(t, err) + + ratelimitIds := make([]string, 0) + for 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: "Requests", + Limit: 15, + Duration: (time.Minute * 15).Milliseconds(), + CreatedAt: time.Now().UnixMilli(), + }) + + require.NoError(t, err) + ratelimitIds = append(ratelimitIds, rateLimitID) + } + + return Identity{ + ID: identityID, + ExternalID: externalID, + RatelimitIds: ratelimitIds, + } +} + +func TestDeleteIdentitySuccessfully(t *testing.T) { + ctx := context.Background() + h := testutil.NewHarness(t) + + route := handler.New(handler.Services{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Permissions: h.Permissions, + Auditlogs: h.Auditlogs, + }) + + h.Register(route) + + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "identity.*.delete_identity") + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + // Create a identity via DB and delete it again + t.Run("delete identity via db and identity id", func(t *testing.T) { + newIdentity := newIdentity(t, h, 0) + + _, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + ID: newIdentity.ID, + Deleted: false, + }) + require.NoError(t, err) + + err = db.Query.DeleteIdentity(ctx, h.DB.RW(), newIdentity.ID) + require.NoError(t, err) + + _, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + ID: newIdentity.ID, + Deleted: false, + }) + require.Equal(t, sql.ErrNoRows, err) + }) + + // Create a identity via DB and delete it again + t.Run("delete identity ratelimits via db", func(t *testing.T) { + numberOfRatelimits := 2 + newIdentity := newIdentity(t, h, numberOfRatelimits) + + _, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + ID: newIdentity.ID, + Deleted: false, + }) + require.NoError(t, err) + + rateLimits, err := db.Query.FindRatelimitsByIdentityID(ctx, h.DB.RO(), sql.NullString{String: newIdentity.ID, Valid: true}) + require.NoError(t, err) + require.Len(t, rateLimits, numberOfRatelimits) + + err = db.Query.DeleteIdentity(ctx, h.DB.RW(), newIdentity.ID) + require.NoError(t, err) + + err = db.Query.DeleteManyRatelimitsByIDs(ctx, h.DB.RW(), newIdentity.RatelimitIds) + require.NoError(t, err) + + _, err = db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + ID: newIdentity.ID, + Deleted: false, + }) + require.Equal(t, sql.ErrNoRows, err) + + ratelimits, err := db.Query.FindRatelimitsByIdentityID(ctx, h.DB.RO(), sql.NullString{String: newIdentity.ID, Valid: true}) + require.NoError(t, err) + require.Len(t, ratelimits, 0) + }) + + t.Run("delete identity via identityID", func(t *testing.T) { + newIdentity := newIdentity(t, h, 0) + + req := handler.Request{IdentityId: ptr.P(newIdentity.ID)} + 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) + + _, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + ID: newIdentity.ID, + Deleted: false, + }) + require.Equal(t, sql.ErrNoRows, err) + }) + + t.Run("delete identity via identityID", func(t *testing.T) { + newIdentity := newIdentity(t, h, 0) + + req := handler.Request{ExternalId: ptr.P(newIdentity.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) + + _, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + ID: newIdentity.ID, + Deleted: false, + }) + require.Equal(t, sql.ErrNoRows, err) + }) + + t.Run("delete identity with ratelimits", func(t *testing.T) { + newIdentity := newIdentity(t, h, 2) + + req := handler.Request{IdentityId: ptr.P(newIdentity.ID)} + 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) + + _, err := db.Query.FindIdentityByID(ctx, h.DB.RO(), db.FindIdentityByIDParams{ + ID: newIdentity.ID, + Deleted: false, + }) + require.Equal(t, sql.ErrNoRows, err) + + // The old ratelimits still exist, while the identity is soft deleted + ratelimits, err := db.Query.FindRatelimitsByIdentityID(ctx, h.DB.RO(), sql.NullString{String: newIdentity.ID, Valid: true}) + require.NoError(t, err) + require.Len(t, ratelimits, 2) + }) +} diff --git a/go/apps/api/routes/v2_identities_delete_identity/400_test.go b/go/apps/api/routes/v2_identities_delete_identity/400_test.go new file mode 100644 index 0000000000..25d73722da --- /dev/null +++ b/go/apps/api/routes/v2_identities_delete_identity/400_test.go @@ -0,0 +1,104 @@ +//nolint:exhaustruct +package handler_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/go/apps/api/openapi" + handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_delete_identity" + "github.com/unkeyed/unkey/go/pkg/ptr" + "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +func TestBadRequests(t *testing.T) { + h := testutil.NewHarness(t) + + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "identity.*.delete_identity") + route := handler.New(handler.Services{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Permissions: h.Permissions, + Auditlogs: h.Auditlogs, + }) + + h.Register(route) + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + t.Run("missing external id AND missing identity id", func(t *testing.T) { + req := handler.Request{} + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) + require.NotNil(t, res.Body) + + require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/application/invalid_input", res.Body.Error.Type) + require.Equal(t, "POST request body for '/v2/identities.deleteIdentity' failed to validate schema", res.Body.Error.Detail) + require.Equal(t, http.StatusBadRequest, res.Body.Error.Status) + require.Equal(t, "Bad Request", res.Body.Error.Title) + require.NotEmpty(t, res.Body.Meta.RequestId) + require.Greater(t, len(res.Body.Error.Errors), 0) + require.Nil(t, res.Body.Error.Instance) + }) + + t.Run("empty external id", func(t *testing.T) { + req := handler.Request{ExternalId: ptr.P("")} + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) + require.NotNil(t, res.Body) + + require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/application/invalid_input", res.Body.Error.Type) + require.Equal(t, "POST request body for '/v2/identities.deleteIdentity' failed to validate schema", res.Body.Error.Detail) + require.Equal(t, http.StatusBadRequest, res.Body.Error.Status) + require.Equal(t, "Bad Request", res.Body.Error.Title) + require.NotEmpty(t, res.Body.Meta.RequestId) + require.Greater(t, len(res.Body.Error.Errors), 0) + require.Nil(t, res.Body.Error.Instance) + }) + + t.Run("external id too short", func(t *testing.T) { + req := handler.Request{ExternalId: ptr.P("id")} + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status, "expected 400, sent: %+v, received: %s", req, res.RawBody) + require.NotNil(t, res.Body) + + require.Equal(t, "https://unkey.com/docs/api-reference/errors-v2/unkey/application/invalid_input", res.Body.Error.Type) + require.Equal(t, "POST request body for '/v2/identities.deleteIdentity' failed to validate schema", res.Body.Error.Detail) + require.Equal(t, http.StatusBadRequest, res.Body.Error.Status) + require.Equal(t, "Bad Request", res.Body.Error.Title) + require.NotEmpty(t, res.Body.Meta.RequestId) + require.Greater(t, len(res.Body.Error.Errors), 0) + require.Nil(t, res.Body.Error.Instance) + }) + + t.Run("missing authorization header", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + // No Authorization header + } + + req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + require.Equal(t, http.StatusBadRequest, res.Status) + require.NotNil(t, res.Body) + }) + + t.Run("malformed authorization header", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {"malformed_header"}, + } + + req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + require.Equal(t, http.StatusBadRequest, res.Status) + require.NotNil(t, res.Body) + }) +} diff --git a/go/apps/api/routes/v2_identities_delete_identity/401_test.go b/go/apps/api/routes/v2_identities_delete_identity/401_test.go new file mode 100644 index 0000000000..f1b7611077 --- /dev/null +++ b/go/apps/api/routes/v2_identities_delete_identity/401_test.go @@ -0,0 +1,38 @@ +package handler_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_delete_identity" + "github.com/unkeyed/unkey/go/pkg/ptr" + "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +func TestDeleteIdentityUnauthorized(t *testing.T) { + h := testutil.NewHarness(t) + + route := handler.New(handler.Services{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Permissions: h.Permissions, + Auditlogs: h.Auditlogs, + }) + + h.Register(route) + + // Invalid authorization token + t.Run("invalid auth token", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {"Bearer invalid_token"}, + } + + req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, http.StatusUnauthorized, res.Status, "expected 401, sent: %+v, received: %s", req, res.RawBody) + }) +} diff --git a/go/apps/api/routes/v2_identities_delete_identity/403_test.go b/go/apps/api/routes/v2_identities_delete_identity/403_test.go new file mode 100644 index 0000000000..067e762536 --- /dev/null +++ b/go/apps/api/routes/v2_identities_delete_identity/403_test.go @@ -0,0 +1,68 @@ +package handler_test + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/go/apps/api/openapi" + handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_delete_identity" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/ptr" + "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +func TestWorkspacePermissions(t *testing.T) { + h := testutil.NewHarness(t) + + route := handler.New(handler.Services{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Permissions: h.Permissions, + Auditlogs: h.Auditlogs, + }) + + h.Register(route) + + t.Run("insufficient permissions", func(t *testing.T) { + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID) + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + req := handler.Request{ExternalId: ptr.P(uid.New("test"))} + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + require.Equal(t, http.StatusForbidden, res.Status, "got: %s", res.RawBody) + require.NotNil(t, res.Body) + }) + + t.Run("delete identity from other workspace", func(t *testing.T) { + identityId := uid.New(uid.IdentityPrefix) + + err := db.Query.InsertIdentity(t.Context(), h.DB.RW(), db.InsertIdentityParams{ + ID: identityId, + ExternalID: "ext_" + identityId, + WorkspaceID: h.Resources().UserWorkspace.ID, + Environment: "default", + CreatedAt: time.Now().Unix(), + Meta: nil, + }) + require.NoError(t, err) + + rootKey := h.CreateRootKey(h.Resources().DifferentWorkspace.ID, "identity.*.delete_identity") + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + req := handler.Request{IdentityId: ptr.P(identityId)} + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + require.Equal(t, http.StatusNotFound, res.Status, "got: %s", res.RawBody) + require.NotNil(t, res.Body) + }) +} diff --git a/go/apps/api/routes/v2_identities_delete_identity/404_test.go b/go/apps/api/routes/v2_identities_delete_identity/404_test.go new file mode 100644 index 0000000000..ff9cd21312 --- /dev/null +++ b/go/apps/api/routes/v2_identities_delete_identity/404_test.go @@ -0,0 +1,68 @@ +package handler_test + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/unkeyed/unkey/go/apps/api/openapi" + handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_identities_delete_identity" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/ptr" + "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +func TestNotFound(t *testing.T) { + h := testutil.NewHarness(t) + + route := handler.New(handler.Services{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Permissions: h.Permissions, + Auditlogs: h.Auditlogs, + }) + + h.Register(route) + + t.Run("delete identity from other workspace", func(t *testing.T) { + identityId := uid.New(uid.IdentityPrefix) + + err := db.Query.InsertIdentity(t.Context(), h.DB.RW(), db.InsertIdentityParams{ + ID: identityId, + ExternalID: "ext_" + identityId, + WorkspaceID: h.Resources().UserWorkspace.ID, + Environment: "default", + CreatedAt: time.Now().Unix(), + Meta: nil, + }) + require.Nil(t, err) + + rootKey := h.CreateRootKey(h.Resources().DifferentWorkspace.ID, "identity.*.delete_identity") + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + req := handler.Request{IdentityId: ptr.P(identityId)} + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + require.Equal(t, http.StatusNotFound, res.Status, "got: %s", res.RawBody) + require.NotNil(t, res.Body) + }) + + t.Run("delete identity that doesn't exist", func(t *testing.T) { + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "identity.*.delete_identity") + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + req := handler.Request{IdentityId: ptr.P(uid.New(uid.IdentityPrefix))} + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + require.Equal(t, http.StatusNotFound, res.Status, "got: %s", res.RawBody) + require.NotNil(t, res.Body) + }) +} diff --git a/go/apps/api/routes/v2_identities_delete_identity/handler.go b/go/apps/api/routes/v2_identities_delete_identity/handler.go new file mode 100644 index 0000000000..a754c6b4a2 --- /dev/null +++ b/go/apps/api/routes/v2_identities_delete_identity/handler.go @@ -0,0 +1,341 @@ +// Package handler implements the API endpoint for deleting an identity in the Unkey system. +// +// OVERVIEW: +// This handler implements the POST /v2/identities.deleteIdentity endpoint which allows +// authorized users to delete identities from the system. The deletion is performed as a +// soft delete to maintain data integrity and audit history. +// +// FLOW DIAGRAM: +// +// +----------------+ +-------------+ +----------------+ +--------------+ +// | Verify Key |---->| Check Perms |---->| Get Identity |---->| Begin Tx | +// +----------------+ +-------------+ +----------------+ +--------------+ +// | +// v +// +----------------+ +-------------+ +----------------+ +--------------+ +// | Return 200 |<----| Commit Tx |<----| Create Audits |<----| Soft Delete | +// +----------------+ +-------------+ +----------------+ +--------------+ +// +// DETAILED PROCESS: +// 1. Authentication: Verifies the root key for API access +// 2. Permission Verification: Checks if the key has permission to delete identities +// - Checks for general identity deletion permission (*) +// - Checks for specific identity deletion permission if ID provided +// +// 3. Identity Retrieval: Gets the identity by either: +// +// +---------------+ +-----------------------+ +// | Identity ID |---->| FindIdentityByID | +// +---------------+ +-----------------------+ +// +---------------+ +-----------------------+ +// | External ID |---->| FindIdentityByExtID | +// +---------------+ +-----------------------+ +// +// 4. Soft Deletion Process: +// +// +----------------+ +// | Soft Delete | +// +--------+-------+ +// | +// v +// +---------------------------+ +------------------------+ +// | Duplicate Key Error? |--Yes->| Delete Old Soft- | +// +-----------------+---------+ | Deleted Identity | +// | +----------+-------------+ +// No | +// | | +// v v +// +--------------------------+ +------------------------+ +// | Create Audit Logs |<-----| Retry Soft Delete | +// +---------------------------+ +------------------------+ +// +// 5. Audit Logging: Creates logs for: +// - The deleted identity +// - Any rate limits associated with the identity +// +// 6. Transaction Management: +// - All database operations are wrapped in a transaction +// - Rollback occurs automatically if any operation fails +// - Commit only happens after all operations succeed +// +// ERROR HANDLING: +// - Authentication failures result in auth errors +// - Permission failures result in authorization errors +// - Database errors are wrapped with appropriate error codes and descriptions +// - Not Found errors are returned when identity doesn't exist or belongs to wrong workspace +package handler + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + + "github.com/unkeyed/unkey/go/apps/api/openapi" + "github.com/unkeyed/unkey/go/internal/services/auditlogs" + "github.com/unkeyed/unkey/go/internal/services/keys" + "github.com/unkeyed/unkey/go/internal/services/permissions" + "github.com/unkeyed/unkey/go/pkg/auditlog" + "github.com/unkeyed/unkey/go/pkg/codes" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/fault" + "github.com/unkeyed/unkey/go/pkg/otel/logging" + "github.com/unkeyed/unkey/go/pkg/rbac" + "github.com/unkeyed/unkey/go/pkg/zen" +) + +type Request = openapi.V2IdentitiesDeleteIdentityRequestBody +type Response = openapi.V2IdentitiesDeleteIdentityResponseBody + +type Services struct { + Logger logging.Logger + DB db.Database + Keys keys.KeyService + Permissions permissions.PermissionService + Auditlogs auditlogs.AuditLogService +} + +func New(svc Services) zen.Route { + return zen.NewRoute("POST", "/v2/identities.deleteIdentity", func(ctx context.Context, s *zen.Session) error { + auth, err := svc.Keys.VerifyRootKey(ctx, s) + if err != nil { + return err + } + + // nolint:exhaustruct + req := Request{} + err = s.BindBody(&req) + if err != nil { + return fault.Wrap(err, + fault.WithDesc("invalid request body", "The request body is invalid."), + ) + } + + checks := []rbac.PermissionQuery{ + rbac.T(rbac.Tuple{ + ResourceType: rbac.Identity, + ResourceID: "*", + Action: rbac.DeleteIdentity, + }), + } + + if req.IdentityId != nil { + checks = append(checks, rbac.T(rbac.Tuple{ + ResourceType: rbac.Identity, + ResourceID: *req.IdentityId, + Action: rbac.DeleteIdentity, + })) + } + + permissions, err := svc.Permissions.Check( + ctx, + auth.KeyID, + rbac.Or(checks...), + ) + if err != nil { + return fault.Wrap(err, + fault.WithDesc("unable to check permissions", "We're unable to check the permissions of your key."), + ) + } + if !permissions.Valid { + return fault.New("insufficient permissions", + fault.WithCode(codes.Auth.Authorization.InsufficientPermissions.URN()), + fault.WithDesc(permissions.Message, permissions.Message), + ) + } + + identity, err := getIdentity(ctx, svc, req, auth.AuthorizedWorkspaceID) + if err != nil { + if db.IsNotFound(err) { + return fault.New("identity not found", + fault.WithCode(codes.Data.Identity.NotFound.URN()), + fault.WithDesc("identity not found", "This identity does not exist."), + ) + } + + return fault.Wrap(err, + fault.WithCode(codes.App.Internal.ServiceUnavailable.URN()), + fault.WithDesc("database failed to find the identity", "Error finding the identity."), + ) + } + + if identity.WorkspaceID != auth.AuthorizedWorkspaceID { + return fault.New("identity not found", + fault.WithCode(codes.Data.Identity.NotFound.URN()), + fault.WithDesc("wrong workspace, masking as 404", "This identity does not exist."), + ) + } + + tx, err := svc.DB.RW().Begin(ctx) + if err != nil { + return fault.Wrap(err, + fault.WithCode(codes.App.Internal.ServiceUnavailable.URN()), + fault.WithDesc("database failed to create transaction", "Unable to start database transaction."), + ) + } + + defer func() { + rollbackErr := tx.Rollback() + if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { + svc.Logger.Error("rollback failed", "requestId", s.RequestID(), "error", rollbackErr) + } + }() + + err = db.Query.SoftDeleteIdentity(ctx, tx, identity.ID) + + // 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) { + err = deleteOldIdentity(ctx, tx, auth.AuthorizedWorkspaceID, identity.ExternalID) + if err != nil { + return err + } + + // Re-apply the soft delete operation + err = db.Query.SoftDeleteIdentity(ctx, tx, identity.ID) + + } + if err != nil { + + return fault.Wrap(err, + fault.WithCode(codes.App.Internal.ServiceUnavailable.URN()), + fault.WithDesc("database failed to soft delete identity", "Failed to delete Identity."), + ) + } + + auditLogs := []auditlog.AuditLog{ + { + WorkspaceID: auth.AuthorizedWorkspaceID, + Event: auditlog.IdentityDeleteEvent, + Display: fmt.Sprintf("Deleted identity %s.", identity.ID), + Bucket: auditlogs.DEFAULT_BUCKET, + ActorID: auth.KeyID, + ActorType: auditlog.RootKeyActor, + ActorMeta: nil, + ActorName: "root key", + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + ID: identity.ID, + Meta: nil, + Type: auditlog.IdentityResourceType, + DisplayName: identity.ExternalID, + Name: identity.ExternalID, + }, + }, + }, + } + + ratelimits, err := db.Query.FindRatelimitsByIdentityID(ctx, tx, sql.NullString{String: identity.ID, Valid: true}) + if err != nil { + return fault.Wrap(err, + fault.WithCode(codes.App.Internal.ServiceUnavailable.URN()), + fault.WithDesc("database failed to load identity ratelimits", "Failed to load Identity ratelimits."), + ) + } + + for _, rl := range ratelimits { + auditLogs = append(auditLogs, auditlog.AuditLog{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Event: auditlog.RatelimitDeleteEvent, + Display: fmt.Sprintf("Deleted ratelimit %s.", rl.ID), + Bucket: auditlogs.DEFAULT_BUCKET, + ActorID: auth.KeyID, + ActorType: auditlog.RootKeyActor, + ActorMeta: nil, + ActorName: "root key", + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + Type: auditlog.IdentityResourceType, + Meta: nil, + ID: identity.ID, + DisplayName: identity.ExternalID, + Name: identity.ExternalID, + }, + { + Type: auditlog.RatelimitResourceType, + Meta: nil, + ID: rl.ID, + DisplayName: rl.Name, + Name: rl.Name, + }, + }, + }) + } + + err = svc.Auditlogs.Insert(ctx, tx, auditLogs) + if err != nil { + return fault.Wrap(err, + fault.WithCode(codes.App.Internal.ServiceUnavailable.URN()), + fault.WithDesc("database failed to insert audit logs", "Failed to insert audit logs"), + ) + } + + err = tx.Commit() + if err != nil { + return fault.Wrap(err, + fault.WithCode(codes.App.Internal.ServiceUnavailable.URN()), + fault.WithDesc("database failed to commit transaction", "Failed to commit changes."), + ) + } + + return s.JSON(http.StatusOK, Response{}) + }) +} + +func deleteOldIdentity(ctx context.Context, tx *sql.Tx, workspaceID, externalID string) error { + oldIdentity, err := db.Query.FindIdentityByExternalID(ctx, tx, db.FindIdentityByExternalIDParams{ + WorkspaceID: workspaceID, + ExternalID: externalID, + Deleted: true, + }) + if err != nil { + return fault.Wrap(err, + fault.WithCode(codes.App.Internal.ServiceUnavailable.URN()), + fault.WithDesc("database failed to load old identity", "Failed to load Identity."), + ) + } + + err = db.Query.DeleteRatelimitsByIdentityID(ctx, tx, sql.NullString{String: oldIdentity.ID, Valid: true}) + if err != nil { + return fault.Wrap(err, + fault.WithCode(codes.App.Internal.ServiceUnavailable.URN()), + fault.WithDesc("database failed to delete identity ratelimits", "Failed to delete Identity ratelimits."), + ) + } + + err = db.Query.DeleteIdentity(ctx, tx, oldIdentity.ID) + if err != nil { + return fault.Wrap(err, + fault.WithCode(codes.App.Internal.ServiceUnavailable.URN()), + fault.WithDesc("database failed to delete identity", "Failed to delete Identity."), + ) + } + + return nil +} + +func getIdentity(ctx context.Context, svc Services, req Request, workspaceID string) (db.Identity, error) { + switch { + case req.IdentityId != nil: + return db.Query.FindIdentityByID(ctx, svc.DB.RO(), db.FindIdentityByIDParams{ + ID: *req.IdentityId, + Deleted: false, + }) + case req.ExternalId != nil: + return db.Query.FindIdentityByExternalID(ctx, svc.DB.RO(), db.FindIdentityByExternalIDParams{ + WorkspaceID: workspaceID, + ExternalID: *req.ExternalId, + Deleted: false, + }) + } + + return db.Identity{}, fault.New("missing identity id or external id", + fault.WithCode(codes.App.Validation.InvalidInput.URN()), + fault.WithDesc("missing identity id or external id", "You must provide either an identity ID or external ID."), + ) +} diff --git a/go/apps/api/routes/v2_ratelimit_get_override/handler.go b/go/apps/api/routes/v2_ratelimit_get_override/handler.go index 0a51e80519..43d63eca95 100644 --- a/go/apps/api/routes/v2_ratelimit_get_override/handler.go +++ b/go/apps/api/routes/v2_ratelimit_get_override/handler.go @@ -27,7 +27,6 @@ type Services struct { func New(svc Services) zen.Route { return zen.NewRoute("POST", "/v2/ratelimit.getOverride", func(ctx context.Context, s *zen.Session) error { - auth, err := svc.Keys.VerifyRootKey(ctx, s) if err != nil { return err @@ -44,13 +43,8 @@ func New(svc Services) zen.Route { namespace, err := getNamespace(ctx, svc, auth.AuthorizedWorkspaceID, req) - if db.IsNotFound(err) { - return fault.New("namespace not found", - fault.WithCode(codes.Data.RatelimitNamespace.NotFound.URN()), - fault.WithDesc("namespace not found", "This namespace does not exist."), - ) - } if err != nil { + // already handled correctly in getNamespace return err } @@ -103,8 +97,12 @@ func New(svc Services) zen.Route { ) } if err != nil { - return err + return fault.Wrap(err, + fault.WithCode(codes.App.Internal.ServiceUnavailable.URN()), + fault.WithDesc("database failed to find the override", "Error finding the ratelimit override."), + ) } + return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), @@ -121,26 +119,42 @@ func New(svc Services) zen.Route { }) } -func getNamespace(ctx context.Context, svc Services, workspaceID string, req Request) (db.RatelimitNamespace, error) { +func getNamespace(ctx context.Context, svc Services, workspaceID string, req Request) (namespace db.RatelimitNamespace, err error) { switch { case req.NamespaceId != nil: { - return db.Query.FindRatelimitNamespaceByID(ctx, svc.DB.RO(), *req.NamespaceId) - + namespace, err = db.Query.FindRatelimitNamespaceByID(ctx, svc.DB.RO(), *req.NamespaceId) + break } case req.NamespaceName != nil: { - return db.Query.FindRatelimitNamespaceByName(ctx, svc.DB.RO(), db.FindRatelimitNamespaceByNameParams{ + namespace, err = db.Query.FindRatelimitNamespaceByName(ctx, svc.DB.RO(), db.FindRatelimitNamespaceByNameParams{ WorkspaceID: workspaceID, Name: *req.NamespaceName, }) + break } + default: + return db.RatelimitNamespace{}, fault.New("namespace id or name required", + fault.WithCode(codes.App.Validation.InvalidInput.URN()), + fault.WithDesc("namespace id or name required", "You must provide either a namespace ID or name."), + ) } - return db.RatelimitNamespace{}, fault.New("missing namespace id or name", - fault.WithCode(codes.App.Validation.InvalidInput.URN()), - fault.WithDesc("missing namespace id or name", "You must provide either a namespace ID or name."), - ) + if err != nil { + if db.IsNotFound(err) { + return db.RatelimitNamespace{}, fault.New("namespace not found", + fault.WithCode(codes.Data.RatelimitNamespace.NotFound.URN()), + fault.WithDesc("namespace not found", "The namespace was not found."), + ) + } + + return db.RatelimitNamespace{}, fault.Wrap(err, + fault.WithCode(codes.App.Internal.ServiceUnavailable.URN()), + fault.WithDesc("database failed to find the namespace", "Error finding the ratelimit namespace."), + ) + } + return namespace, nil } diff --git a/go/go.mod b/go/go.mod index 70cd55ae66..f80511b9fa 100644 --- a/go/go.mod +++ b/go/go.mod @@ -10,6 +10,7 @@ require ( github.com/lmittmann/tint v1.0.7 github.com/maypok86/otter v1.2.4 github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 + github.com/oapi-codegen/runtime v1.1.1 github.com/ory/dockertest/v3 v3.11.0 github.com/pb33f/libopenapi v0.21.8 github.com/pb33f/libopenapi-validator v0.4.0 @@ -44,6 +45,7 @@ require ( github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect diff --git a/go/go.sum b/go/go.sum index 4739da2955..a006f5b436 100644 --- a/go/go.sum +++ b/go/go.sum @@ -9,23 +9,26 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg6 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/ClickHouse/ch-go v0.65.1 h1:SLuxmLl5Mjj44/XbINsK2HFvzqup0s6rwKLFH347ZhU= github.com/ClickHouse/ch-go v0.65.1/go.mod h1:bsodgURwmrkvkBe5jw1qnGDgyITsYErfONKAHn05nv4= -github.com/ClickHouse/clickhouse-go v1.5.4 h1:cKjXeYLNWVJIx2J1K6H2CqyRmfwVJVY1OV1coaaFcI0= github.com/ClickHouse/clickhouse-go/v2 v2.34.0 h1:Y4rqkdrRHgExvC4o/NTbLdY5LFQ3LHS77/RNFxFX3Co= github.com/ClickHouse/clickhouse-go/v2 v2.34.0/go.mod h1:yioSINoRLVZkLyDzdMXPLRIqhDvel8iLBlwh6Iefso8= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -101,7 +104,6 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= @@ -139,7 +141,6 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= @@ -171,6 +172,7 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= @@ -190,8 +192,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lmittmann/tint v1.0.7 h1:D/0OqWZ0YOGZ6AyC+5Y2kD8PBEzBk6rFHVSfOqCkF9Y= github.com/lmittmann/tint v1.0.7/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= @@ -216,6 +216,8 @@ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q= github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8= +github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= +github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -268,8 +270,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= @@ -295,7 +295,6 @@ github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE= github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= @@ -311,6 +310,7 @@ github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3k github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/sqlc-dev/sqlc v1.28.0 h1:2QB4X22pKNpKMyb8dRLnqZwMXW6S+ZCyYCpa+3/ICcI= github.com/sqlc-dev/sqlc v1.28.0/go.mod h1:x6wDsOHH60dTX3ES9sUUxRVaROg5aFB3l3nkkjyuK1A= github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= @@ -326,12 +326,8 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4= github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= @@ -465,8 +461,6 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/go/internal/services/auditlogs/insert.go b/go/internal/services/auditlogs/insert.go index cc8c55b7b8..cc49e413c4 100644 --- a/go/internal/services/auditlogs/insert.go +++ b/go/internal/services/auditlogs/insert.go @@ -57,6 +57,7 @@ func (s *service) Insert(ctx context.Context, tx *sql.Tx, logs []auditlog.AuditL if err != nil { return err } + auditLogs = append(auditLogs, db.InsertAuditLogParams{ ID: auditLogID, WorkspaceID: l.WorkspaceID, @@ -75,7 +76,6 @@ func (s *service) Insert(ctx context.Context, tx *sql.Tx, logs []auditlog.AuditL }) for _, resource := range l.Resources { - meta, err := json.Marshal(resource.Meta) if err != nil { return err diff --git a/go/pkg/codes/constants_gen.go b/go/pkg/codes/constants_gen.go index ccd985b6ca..b172147f1f 100644 --- a/go/pkg/codes/constants_gen.go +++ b/go/pkg/codes/constants_gen.go @@ -1,5 +1,5 @@ // Code generated by generate.go; DO NOT EDIT. -// Generated at: 2025-04-17T14:51:39+02:00 +// Generated at: 2025-04-23T11:47:05+02:00 package codes diff --git a/go/pkg/db/audit_log_bucket_id_find_by_workspace_and_name.sql_generated.go b/go/pkg/db/audit_log_bucket_id_find_by_workspace_and_name.sql_generated.go deleted file mode 100644 index 8fafc323d7..0000000000 --- a/go/pkg/db/audit_log_bucket_id_find_by_workspace_and_name.sql_generated.go +++ /dev/null @@ -1,29 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: audit_log_bucket_id_find_by_workspace_and_name.sql - -package db - -import ( - "context" -) - -const findAuditLogBucketIDByWorkspaceIDAndName = `-- name: FindAuditLogBucketIDByWorkspaceIDAndName :one -SELECT id FROM audit_log_bucket WHERE workspace_id = ? AND name = ? -` - -type FindAuditLogBucketIDByWorkspaceIDAndNameParams struct { - WorkspaceID string `db:"workspace_id"` - Name string `db:"name"` -} - -// FindAuditLogBucketIDByWorkspaceIDAndName -// -// SELECT id FROM audit_log_bucket WHERE workspace_id = ? AND name = ? -func (q *Queries) FindAuditLogBucketIDByWorkspaceIDAndName(ctx context.Context, db DBTX, arg FindAuditLogBucketIDByWorkspaceIDAndNameParams) (string, error) { - row := db.QueryRowContext(ctx, findAuditLogBucketIDByWorkspaceIDAndName, arg.WorkspaceID, arg.Name) - var id string - err := row.Scan(&id) - return id, err -} diff --git a/go/pkg/db/handle_err_duplicate_key.go b/go/pkg/db/handle_err_duplicate_key.go new file mode 100644 index 0000000000..c72bbeace5 --- /dev/null +++ b/go/pkg/db/handle_err_duplicate_key.go @@ -0,0 +1,13 @@ +package db + +import ( + "github.com/go-sql-driver/mysql" +) + +func IsDuplicateKeyError(err error) bool { + if mysqlErr, ok := err.(*mysql.MySQLError); ok && mysqlErr.Number == 1062 { + return true + } + + return false +} diff --git a/go/pkg/db/identity_delete.sql_generated.go b/go/pkg/db/identity_delete.sql_generated.go new file mode 100644 index 0000000000..412703effa --- /dev/null +++ b/go/pkg/db/identity_delete.sql_generated.go @@ -0,0 +1,22 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: identity_delete.sql + +package db + +import ( + "context" +) + +const deleteIdentity = `-- name: DeleteIdentity :exec +DELETE FROM identities WHERE id = ? +` + +// DeleteIdentity +// +// DELETE FROM identities WHERE id = ? +func (q *Queries) DeleteIdentity(ctx context.Context, db DBTX, id string) error { + _, err := db.ExecContext(ctx, deleteIdentity, id) + return err +} diff --git a/go/pkg/db/identity_find_by_external_id.sql_generated.go b/go/pkg/db/identity_find_by_external_id.sql_generated.go new file mode 100644 index 0000000000..465f6a6ff3 --- /dev/null +++ b/go/pkg/db/identity_find_by_external_id.sql_generated.go @@ -0,0 +1,39 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: identity_find_by_external_id.sql + +package db + +import ( + "context" +) + +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 FindIdentityByExternalIDParams struct { + WorkspaceID string `db:"workspace_id"` + ExternalID string `db:"external_id"` + Deleted bool `db:"deleted"` +} + +// FindIdentityByExternalID +// +// SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at FROM identities WHERE workspace_id = ? 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 +} diff --git a/go/pkg/db/identity_find_by_id.sql_generated.go b/go/pkg/db/identity_find_by_id.sql_generated.go index caa0ab9ae6..f54ddbb60e 100644 --- a/go/pkg/db/identity_find_by_id.sql_generated.go +++ b/go/pkg/db/identity_find_by_id.sql_generated.go @@ -7,33 +7,30 @@ package db import ( "context" - "database/sql" ) const findIdentityByID = `-- name: FindIdentityByID :one -SELECT external_id, workspace_id, environment, meta, created_at, updated_at FROM identities WHERE id = ? +SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at FROM identities WHERE id = ? AND deleted = ? ` -type FindIdentityByIDRow struct { - ExternalID string `db:"external_id"` - WorkspaceID string `db:"workspace_id"` - Environment string `db:"environment"` - Meta []byte `db:"meta"` - CreatedAt int64 `db:"created_at"` - UpdatedAt sql.NullInt64 `db:"updated_at"` +type FindIdentityByIDParams struct { + ID string `db:"id"` + Deleted bool `db:"deleted"` } // FindIdentityByID // -// SELECT external_id, workspace_id, environment, meta, created_at, updated_at FROM identities WHERE id = ? -func (q *Queries) FindIdentityByID(ctx context.Context, db DBTX, id string) (FindIdentityByIDRow, error) { - row := db.QueryRowContext(ctx, findIdentityByID, id) - var i FindIdentityByIDRow +// SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at FROM identities WHERE id = ? AND deleted = ? +func (q *Queries) FindIdentityByID(ctx context.Context, db DBTX, arg FindIdentityByIDParams) (Identity, error) { + row := db.QueryRowContext(ctx, findIdentityByID, arg.ID, arg.Deleted) + var i Identity err := row.Scan( + &i.ID, &i.ExternalID, &i.WorkspaceID, &i.Environment, &i.Meta, + &i.Deleted, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/go/pkg/db/identity_find_ratelimits_by_id.sql_generated.go b/go/pkg/db/identity_find_ratelimits_by_id.sql_generated.go index 26dfbcf657..09fd0fe78e 100644 --- a/go/pkg/db/identity_find_ratelimits_by_id.sql_generated.go +++ b/go/pkg/db/identity_find_ratelimits_by_id.sql_generated.go @@ -11,37 +11,29 @@ import ( ) const findRatelimitsByIdentityID = `-- name: FindRatelimitsByIdentityID :many -SELECT id, name, workspace_id, created_at, updated_at, ` + "`" + `limit` + "`" + `, duration FROM ratelimits WHERE identity_id = ? +SELECT id, name, workspace_id, created_at, updated_at, key_id, identity_id, ` + "`" + `limit` + "`" + `, duration FROM ratelimits WHERE identity_id = ? ` -type FindRatelimitsByIdentityIDRow struct { - ID string `db:"id"` - Name string `db:"name"` - WorkspaceID string `db:"workspace_id"` - CreatedAt int64 `db:"created_at"` - UpdatedAt sql.NullInt64 `db:"updated_at"` - Limit int32 `db:"limit"` - Duration int64 `db:"duration"` -} - // FindRatelimitsByIdentityID // -// SELECT id, name, workspace_id, created_at, updated_at, `limit`, duration FROM ratelimits WHERE identity_id = ? -func (q *Queries) FindRatelimitsByIdentityID(ctx context.Context, db DBTX, identityID sql.NullString) ([]FindRatelimitsByIdentityIDRow, error) { +// SELECT id, name, workspace_id, created_at, updated_at, key_id, identity_id, `limit`, duration FROM ratelimits WHERE identity_id = ? +func (q *Queries) FindRatelimitsByIdentityID(ctx context.Context, db DBTX, identityID sql.NullString) ([]Ratelimit, error) { rows, err := db.QueryContext(ctx, findRatelimitsByIdentityID, identityID) if err != nil { return nil, err } defer rows.Close() - var items []FindRatelimitsByIdentityIDRow + var items []Ratelimit for rows.Next() { - var i FindRatelimitsByIdentityIDRow + var i Ratelimit if err := rows.Scan( &i.ID, &i.Name, &i.WorkspaceID, &i.CreatedAt, &i.UpdatedAt, + &i.KeyID, + &i.IdentityID, &i.Limit, &i.Duration, ); err != nil { diff --git a/go/pkg/db/identity_soft_delete.sql_generated.go b/go/pkg/db/identity_soft_delete.sql_generated.go new file mode 100644 index 0000000000..2a4013c152 --- /dev/null +++ b/go/pkg/db/identity_soft_delete.sql_generated.go @@ -0,0 +1,22 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: identity_soft_delete.sql + +package db + +import ( + "context" +) + +const softDeleteIdentity = `-- name: SoftDeleteIdentity :exec +UPDATE identities set deleted = 1 WHERE id = ? +` + +// SoftDeleteIdentity +// +// UPDATE identities set deleted = 1 WHERE id = ? +func (q *Queries) SoftDeleteIdentity(ctx context.Context, db DBTX, id string) error { + _, err := db.ExecContext(ctx, softDeleteIdentity, id) + return err +} diff --git a/go/pkg/db/key_find_by_id.sql_generated.go b/go/pkg/db/key_find_by_id.sql_generated.go index c77ca9e74a..7964eca520 100644 --- a/go/pkg/db/key_find_by_id.sql_generated.go +++ b/go/pkg/db/key_find_by_id.sql_generated.go @@ -12,7 +12,7 @@ import ( const findKeyByID = `-- name: FindKeyByID :one SELECT k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, - i.id, i.external_id, i.workspace_id, i.environment, i.created_at, i.updated_at, i.meta + i.id, i.external_id, i.workspace_id, i.environment, i.meta, i.deleted, i.created_at, i.updated_at FROM ` + "`" + `keys` + "`" + ` k LEFT JOIN identities i ON k.identity_id = i.id WHERE k.id = ? @@ -27,7 +27,7 @@ type FindKeyByIDRow struct { // // SELECT // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, -// i.id, i.external_id, i.workspace_id, i.environment, i.created_at, i.updated_at, i.meta +// i.id, i.external_id, i.workspace_id, i.environment, i.meta, i.deleted, i.created_at, i.updated_at // FROM `keys` k // LEFT JOIN identities i ON k.identity_id = i.id // WHERE k.id = ? @@ -62,9 +62,10 @@ func (q *Queries) FindKeyByID(ctx context.Context, db DBTX, id string) (FindKeyB &i.Identity.ExternalID, &i.Identity.WorkspaceID, &i.Identity.Environment, + &i.Identity.Meta, + &i.Identity.Deleted, &i.Identity.CreatedAt, &i.Identity.UpdatedAt, - &i.Identity.Meta, ) return i, err } diff --git a/go/pkg/db/key_find_for_verification.sql_generated.go b/go/pkg/db/key_find_for_verification.sql_generated.go index fda822a1bc..f2a0d90fd1 100644 --- a/go/pkg/db/key_find_for_verification.sql_generated.go +++ b/go/pkg/db/key_find_for_verification.sql_generated.go @@ -49,7 +49,7 @@ all_ratelimits AS ( ) SELECT k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, - i.id, i.external_id, i.workspace_id, i.environment, i.created_at, i.updated_at, i.meta, + i.id, i.external_id, i.workspace_id, i.environment, i.meta, i.deleted, i.created_at, i.updated_at, JSON_ARRAYAGG( JSON_OBJECT( 'target_type', rl.target_type, @@ -116,7 +116,7 @@ type FindKeyForVerificationRow struct { // ) // SELECT // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, -// i.id, i.external_id, i.workspace_id, i.environment, i.created_at, i.updated_at, i.meta, +// i.id, i.external_id, i.workspace_id, i.environment, i.meta, i.deleted, i.created_at, i.updated_at, // JSON_ARRAYAGG( // JSON_OBJECT( // 'target_type', rl.target_type, @@ -166,9 +166,10 @@ func (q *Queries) FindKeyForVerification(ctx context.Context, db DBTX, hash stri &i.Identity.ExternalID, &i.Identity.WorkspaceID, &i.Identity.Environment, + &i.Identity.Meta, + &i.Identity.Deleted, &i.Identity.CreatedAt, &i.Identity.UpdatedAt, - &i.Identity.Meta, &i.Ratelimits, &i.Permissions, ) diff --git a/go/pkg/db/models_generated.go b/go/pkg/db/models_generated.go index 504cd6f355..5d8920ea94 100644 --- a/go/pkg/db/models_generated.go +++ b/go/pkg/db/models_generated.go @@ -291,9 +291,10 @@ type Identity struct { 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"` - Meta []byte `db:"meta"` } type Key struct { diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index e589915ad5..58ee4890f9 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -10,12 +10,24 @@ import ( ) type Querier interface { + //DeleteIdentity + // + // DELETE FROM identities WHERE id = ? + DeleteIdentity(ctx context.Context, db DBTX, id string) error + //DeleteManyRatelimitsByIDs + // + // DELETE FROM ratelimits WHERE id IN (/*SLICE:ids*/?) + DeleteManyRatelimitsByIDs(ctx context.Context, db DBTX, ids []string) error //DeleteRatelimitNamespace // // UPDATE `ratelimit_namespaces` // SET deleted_at_m = ? // WHERE id = ? DeleteRatelimitNamespace(ctx context.Context, db DBTX, arg DeleteRatelimitNamespaceParams) (sql.Result, error) + //DeleteRatelimitsByIdentityID + // + // DELETE FROM ratelimits WHERE identity_id = ? + DeleteRatelimitsByIdentityID(ctx context.Context, db DBTX, identityID sql.NullString) error //FindApiById // // SELECT id, name, workspace_id, ip_whitelist, auth_type, key_auth_id, created_at_m, updated_at_m, deleted_at_m, delete_protection FROM apis WHERE id = ? @@ -27,10 +39,14 @@ type Querier interface { // JOIN audit_log ON audit_log.id = audit_log_target.audit_log_id // WHERE audit_log_target.id = ? FindAuditLogTargetById(ctx context.Context, db DBTX, id string) ([]FindAuditLogTargetByIdRow, error) + //FindIdentityByExternalID + // + // SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at FROM identities WHERE workspace_id = ? AND external_id = ? AND deleted = ? + FindIdentityByExternalID(ctx context.Context, db DBTX, arg FindIdentityByExternalIDParams) (Identity, error) //FindIdentityByID // - // SELECT external_id, workspace_id, environment, meta, created_at, updated_at FROM identities WHERE id = ? - FindIdentityByID(ctx context.Context, db DBTX, id string) (FindIdentityByIDRow, error) + // SELECT id, external_id, workspace_id, environment, meta, deleted, created_at, updated_at FROM identities WHERE id = ? AND deleted = ? + FindIdentityByID(ctx context.Context, db DBTX, arg FindIdentityByIDParams) (Identity, error) //FindKeyByHash // // 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` WHERE hash = ? @@ -39,7 +55,7 @@ type Querier interface { // // SELECT // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, - // i.id, i.external_id, i.workspace_id, i.environment, i.created_at, i.updated_at, i.meta + // i.id, i.external_id, i.workspace_id, i.environment, i.meta, i.deleted, i.created_at, i.updated_at // FROM `keys` k // LEFT JOIN identities i ON k.identity_id = i.id // WHERE k.id = ? @@ -83,7 +99,7 @@ type Querier interface { // ) // SELECT // k.id, k.key_auth_id, k.hash, k.start, k.workspace_id, k.for_workspace_id, k.name, k.owner_id, k.identity_id, k.meta, k.expires, k.created_at_m, k.updated_at_m, k.deleted_at_m, k.refill_day, k.refill_amount, k.last_refill_at, k.enabled, k.remaining_requests, k.ratelimit_async, k.ratelimit_limit, k.ratelimit_duration, k.environment, - // i.id, i.external_id, i.workspace_id, i.environment, i.created_at, i.updated_at, i.meta, + // i.id, i.external_id, i.workspace_id, i.environment, i.meta, i.deleted, i.created_at, i.updated_at, // JSON_ARRAYAGG( // JSON_OBJECT( // 'target_type', rl.target_type, @@ -175,8 +191,8 @@ type Querier interface { FindRatelimitOverridesByIdentifier(ctx context.Context, db DBTX, arg FindRatelimitOverridesByIdentifierParams) (RatelimitOverride, error) //FindRatelimitsByIdentityID // - // SELECT id, name, workspace_id, created_at, updated_at, `limit`, duration FROM ratelimits WHERE identity_id = ? - FindRatelimitsByIdentityID(ctx context.Context, db DBTX, identityID sql.NullString) ([]FindRatelimitsByIdentityIDRow, error) + // SELECT id, name, workspace_id, created_at, updated_at, key_id, identity_id, `limit`, duration FROM ratelimits WHERE identity_id = ? + FindRatelimitsByIdentityID(ctx context.Context, db DBTX, identityID sql.NullString) ([]Ratelimit, error) //FindWorkspaceByID // // SELECT id, org_id, name, plan, tier, stripe_customer_id, stripe_subscription_id, beta_features, features, subscriptions, enabled, delete_protection, created_at_m, updated_at_m, deleted_at_m FROM `workspaces` @@ -489,6 +505,10 @@ type Querier interface { // ORDER BY w.id ASC // LIMIT 100 ListWorkspaces(ctx context.Context, db DBTX, cursor string) ([]ListWorkspacesRow, error) + //SoftDeleteIdentity + // + // UPDATE identities set deleted = 1 WHERE id = ? + SoftDeleteIdentity(ctx context.Context, db DBTX, id string) error //SoftDeleteRatelimitNamespace // // UPDATE `ratelimit_namespaces` diff --git a/go/pkg/db/queries/identity_delete.sql b/go/pkg/db/queries/identity_delete.sql new file mode 100644 index 0000000000..9d758f12cf --- /dev/null +++ b/go/pkg/db/queries/identity_delete.sql @@ -0,0 +1,2 @@ +-- name: DeleteIdentity :exec +DELETE FROM identities WHERE id = sqlc.arg('id') diff --git a/go/pkg/db/queries/identity_find_by_external_id.sql b/go/pkg/db/queries/identity_find_by_external_id.sql new file mode 100644 index 0000000000..61c02db6be --- /dev/null +++ b/go/pkg/db/queries/identity_find_by_external_id.sql @@ -0,0 +1,2 @@ +-- 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_by_id.sql b/go/pkg/db/queries/identity_find_by_id.sql index 622afe8ccf..af5810122b 100644 --- a/go/pkg/db/queries/identity_find_by_id.sql +++ b/go/pkg/db/queries/identity_find_by_id.sql @@ -1,2 +1,2 @@ -- name: FindIdentityByID :one -SELECT external_id, workspace_id, environment, meta, created_at, updated_at FROM identities WHERE id = sqlc.arg(id) +SELECT * FROM identities WHERE id = sqlc.arg(id) AND deleted = sqlc.arg(deleted); diff --git a/go/pkg/db/queries/identity_find_ratelimits_by_id.sql b/go/pkg/db/queries/identity_find_ratelimits_by_id.sql index 61a417dde7..deaacededf 100644 --- a/go/pkg/db/queries/identity_find_ratelimits_by_id.sql +++ b/go/pkg/db/queries/identity_find_ratelimits_by_id.sql @@ -1,2 +1,2 @@ -- name: FindRatelimitsByIdentityID :many -SELECT id, name, workspace_id, created_at, updated_at, `limit`, duration FROM ratelimits WHERE identity_id = sqlc.arg(identity_id) +SELECT * FROM ratelimits WHERE identity_id = sqlc.arg(identity_id) diff --git a/go/pkg/db/queries/identity_soft_delete.sql b/go/pkg/db/queries/identity_soft_delete.sql new file mode 100644 index 0000000000..4ef0e838cb --- /dev/null +++ b/go/pkg/db/queries/identity_soft_delete.sql @@ -0,0 +1,2 @@ +-- name: SoftDeleteIdentity :exec +UPDATE identities set deleted = 1 WHERE id = sqlc.arg('id') diff --git a/go/pkg/db/queries/ratelimit_delete_by_identity_id.sql b/go/pkg/db/queries/ratelimit_delete_by_identity_id.sql new file mode 100644 index 0000000000..e2d8ca4426 --- /dev/null +++ b/go/pkg/db/queries/ratelimit_delete_by_identity_id.sql @@ -0,0 +1,2 @@ +-- name: DeleteRatelimitsByIdentityID :exec +DELETE FROM ratelimits WHERE identity_id = ?; diff --git a/go/pkg/db/queries/ratelimit_delete_many.sql b/go/pkg/db/queries/ratelimit_delete_many.sql new file mode 100644 index 0000000000..77d721e89b --- /dev/null +++ b/go/pkg/db/queries/ratelimit_delete_many.sql @@ -0,0 +1,2 @@ +-- name: DeleteManyRatelimitsByIDs :exec +DELETE FROM ratelimits WHERE id IN (sqlc.slice(ids)); diff --git a/go/pkg/db/ratelimit_delete_by_identity_id.sql_generated.go b/go/pkg/db/ratelimit_delete_by_identity_id.sql_generated.go new file mode 100644 index 0000000000..90e2d019c4 --- /dev/null +++ b/go/pkg/db/ratelimit_delete_by_identity_id.sql_generated.go @@ -0,0 +1,23 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: ratelimit_delete_by_identity_id.sql + +package db + +import ( + "context" + "database/sql" +) + +const deleteRatelimitsByIdentityID = `-- name: DeleteRatelimitsByIdentityID :exec +DELETE FROM ratelimits WHERE identity_id = ? +` + +// DeleteRatelimitsByIdentityID +// +// DELETE FROM ratelimits WHERE identity_id = ? +func (q *Queries) DeleteRatelimitsByIdentityID(ctx context.Context, db DBTX, identityID sql.NullString) error { + _, err := db.ExecContext(ctx, deleteRatelimitsByIdentityID, identityID) + return err +} diff --git a/go/pkg/db/ratelimit_delete_many.sql_generated.go b/go/pkg/db/ratelimit_delete_many.sql_generated.go new file mode 100644 index 0000000000..42380a1d27 --- /dev/null +++ b/go/pkg/db/ratelimit_delete_many.sql_generated.go @@ -0,0 +1,33 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: ratelimit_delete_many.sql + +package db + +import ( + "context" + "strings" +) + +const deleteManyRatelimitsByIDs = `-- name: DeleteManyRatelimitsByIDs :exec +DELETE FROM ratelimits WHERE id IN (/*SLICE:ids*/?) +` + +// DeleteManyRatelimitsByIDs +// +// DELETE FROM ratelimits WHERE id IN (/*SLICE:ids*/?) +func (q *Queries) DeleteManyRatelimitsByIDs(ctx context.Context, db DBTX, ids []string) error { + query := deleteManyRatelimitsByIDs + var queryParams []interface{} + if len(ids) > 0 { + for _, v := range ids { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:ids*/?", strings.Repeat(",?", len(ids))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:ids*/?", "NULL", 1) + } + _, err := db.ExecContext(ctx, query, queryParams...) + return err +} diff --git a/go/pkg/db/schema.sql b/go/pkg/db/schema.sql index 1b053d1453..4fb6ea5ccd 100644 --- a/go/pkg/db/schema.sql +++ b/go/pkg/db/schema.sql @@ -208,11 +208,12 @@ CREATE TABLE `identities` ( `external_id` varchar(256) NOT NULL, `workspace_id` varchar(256) NOT NULL, `environment` varchar(256) NOT NULL DEFAULT 'default', + `meta` json, + `deleted` boolean NOT NULL DEFAULT false, `created_at` bigint NOT NULL, `updated_at` bigint, - `meta` json, CONSTRAINT `identities_id` PRIMARY KEY(`id`), - CONSTRAINT `external_id_workspace_id_idx` UNIQUE(`external_id`,`workspace_id`) + CONSTRAINT `workspace_id_external_id_deleted_idx` UNIQUE(`workspace_id`,`external_id`,`deleted`) ); CREATE TABLE `ratelimits` ( @@ -292,7 +293,6 @@ CREATE INDEX `idx_keys_on_for_workspace_id` ON `keys` (`for_workspace_id`); CREATE INDEX `owner_id_idx` ON `keys` (`owner_id`); CREATE INDEX `identity_id_idx` ON `keys` (`identity_id`); CREATE INDEX `deleted_at_idx` ON `keys` (`deleted_at_m`); -CREATE INDEX `workspace_id_idx` ON `identities` (`workspace_id`); CREATE INDEX `name_idx` ON `ratelimits` (`name`); CREATE INDEX `identity_id_idx` ON `ratelimits` (`identity_id`); CREATE INDEX `key_id_idx` ON `ratelimits` (`key_id`); diff --git a/go/pkg/fault/dst_test.go b/go/pkg/fault/dst_test.go index eab7ee5f3a..ad3329359c 100644 --- a/go/pkg/fault/dst_test.go +++ b/go/pkg/fault/dst_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/unkeyed/unkey/go/pkg/codes" "github.com/unkeyed/unkey/go/pkg/fault" + "github.com/unkeyed/unkey/go/pkg/testutil" ) var ( @@ -177,6 +178,7 @@ func (g *ErrorChainGenerator) generateErrorChain() ([]codes.URN, []string, error } func TestDST(t *testing.T) { + testutil.SkipUnlessSimulation(t) seed := time.Now().UnixNano() t.Logf("Using seed: %d", seed) diff --git a/go/pkg/testutil/seed/seed.go b/go/pkg/testutil/seed/seed.go index 0722c0c44b..a011035159 100644 --- a/go/pkg/testutil/seed/seed.go +++ b/go/pkg/testutil/seed/seed.go @@ -127,7 +127,6 @@ func (s *Seeder) CreateRootKey(ctx context.Context, workspaceID string, permissi if len(permissions) > 0 { for _, permission := range permissions { - s.t.Logf("creating permission %s for key %s", permission, insertKeyParams.ID) permissionID := uid.New(uid.TestPrefix) err := db.Query.InsertPermission(ctx, s.DB.RW(), db.InsertPermissionParams{ ID: permissionID, @@ -162,7 +161,5 @@ func (s *Seeder) CreateRootKey(ctx context.Context, workspaceID string, permissi } } - s.t.Logf("created root key: %s", insertKeyParams.ID) - return key } diff --git a/internal/db/drizzle/0000_motionless_vargas.sql b/internal/db/drizzle/0000_fat_the_hand.sql similarity index 98% rename from internal/db/drizzle/0000_motionless_vargas.sql rename to internal/db/drizzle/0000_fat_the_hand.sql index 56f4de0fa5..0ab66d12b9 100644 --- a/internal/db/drizzle/0000_motionless_vargas.sql +++ b/internal/db/drizzle/0000_fat_the_hand.sql @@ -208,11 +208,12 @@ CREATE TABLE `identities` ( `external_id` varchar(256) NOT NULL, `workspace_id` varchar(256) NOT NULL, `environment` varchar(256) NOT NULL DEFAULT 'default', + `meta` json, + `deleted` boolean NOT NULL DEFAULT false, `created_at` bigint NOT NULL, `updated_at` bigint, - `meta` json, CONSTRAINT `identities_id` PRIMARY KEY(`id`), - CONSTRAINT `external_id_workspace_id_idx` UNIQUE(`external_id`,`workspace_id`) + CONSTRAINT `workspace_id_external_id_deleted_idx` UNIQUE(`workspace_id`,`external_id`,`deleted`) ); --> statement-breakpoint CREATE TABLE `ratelimits` ( @@ -292,7 +293,6 @@ CREATE INDEX `idx_keys_on_for_workspace_id` ON `keys` (`for_workspace_id`);--> s CREATE INDEX `owner_id_idx` ON `keys` (`owner_id`);--> statement-breakpoint CREATE INDEX `identity_id_idx` ON `keys` (`identity_id`);--> statement-breakpoint CREATE INDEX `deleted_at_idx` ON `keys` (`deleted_at_m`);--> statement-breakpoint -CREATE INDEX `workspace_id_idx` ON `identities` (`workspace_id`);--> statement-breakpoint CREATE INDEX `name_idx` ON `ratelimits` (`name`);--> statement-breakpoint CREATE INDEX `identity_id_idx` ON `ratelimits` (`identity_id`);--> statement-breakpoint CREATE INDEX `key_id_idx` ON `ratelimits` (`key_id`);--> statement-breakpoint diff --git a/internal/db/drizzle/meta/0000_snapshot.json b/internal/db/drizzle/meta/0000_snapshot.json index 59d8049634..1d53e59538 100644 --- a/internal/db/drizzle/meta/0000_snapshot.json +++ b/internal/db/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "mysql", - "id": "b652299c-35af-4024-84c7-13f686c5195d", + "id": "c0a0a8fb-53ef-426f-9cbc-50d8e7f72b86", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "apis": { @@ -1307,6 +1307,21 @@ "autoincrement": false, "default": "'default'" }, + "meta": { + "name": "meta", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, "created_at": { "name": "created_at", "type": "bigint", @@ -1320,24 +1335,12 @@ "primaryKey": false, "notNull": false, "autoincrement": false - }, - "meta": { - "name": "meta", - "type": "json", - "primaryKey": false, - "notNull": false, - "autoincrement": false } }, "indexes": { - "workspace_id_idx": { - "name": "workspace_id_idx", - "columns": ["workspace_id"], - "isUnique": false - }, - "external_id_workspace_id_idx": { - "name": "external_id_workspace_id_idx", - "columns": ["external_id", "workspace_id"], + "workspace_id_external_id_deleted_idx": { + "name": "workspace_id_external_id_deleted_idx", + "columns": ["workspace_id", "external_id", "deleted"], "isUnique": true } }, diff --git a/internal/db/drizzle/meta/_journal.json b/internal/db/drizzle/meta/_journal.json index 633273e231..d602259be6 100644 --- a/internal/db/drizzle/meta/_journal.json +++ b/internal/db/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "5", - "when": 1744529464325, - "tag": "0000_motionless_vargas", + "when": 1745396754471, + "tag": "0000_fat_the_hand", "breakpoints": true } ] diff --git a/internal/db/src/schema/identity.ts b/internal/db/src/schema/identity.ts index 12718fd56d..4f5f64c069 100644 --- a/internal/db/src/schema/identity.ts +++ b/internal/db/src/schema/identity.ts @@ -1,5 +1,14 @@ import { relations } from "drizzle-orm"; -import { bigint, index, int, json, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"; +import { + bigint, + boolean, + index, + int, + json, + mysqlTable, + uniqueIndex, + varchar, +} from "drizzle-orm/mysql-core"; import { keys } from "./keys"; import { lifecycleDates } from "./util/lifecycle_dates"; import { workspaces } from "./workspaces"; @@ -9,20 +18,21 @@ export const identities = mysqlTable( { id: varchar("id", { length: 256 }).primaryKey(), /** - * The extenral id is used to create a reference to the user's existing data. + * The external id is used to create a reference to the user's existing data. * They likely have an organization or user id at hand */ externalId: varchar("external_id", { length: 256 }).notNull(), workspaceId: varchar("workspace_id", { length: 256 }).notNull(), environment: varchar("environment", { length: 256 }).notNull().default("default"), - ...lifecycleDates, meta: json("meta").$type>(), + deleted: boolean("deleted").notNull().default(false), + ...lifecycleDates, }, (table) => ({ - workspaceId: index("workspace_id_idx").on(table.workspaceId), - uniqueExternalIdPerWorkspace: uniqueIndex("external_id_workspace_id_idx").on( - table.externalId, + uniqueDeletedExternalIdPerWorkspace: uniqueIndex("workspace_id_external_id_deleted_idx").on( table.workspaceId, + table.externalId, + table.deleted, ), }), );