diff --git a/go/apps/api/routes/v2_keys_delete_key/200_test.go b/go/apps/api/routes/v2_keys_delete_key/200_test.go new file mode 100644 index 0000000000..fdf61e2c97 --- /dev/null +++ b/go/apps/api/routes/v2_keys_delete_key/200_test.go @@ -0,0 +1,225 @@ +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_keys_delete_key" + vaultv1 "github.com/unkeyed/unkey/go/gen/proto/vault/v1" + "github.com/unkeyed/unkey/go/internal/services/keys" + "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 TestKeyDeleteSuccess(t *testing.T) { + h := testutil.NewHarness(t) + ctx := context.Background() + + route := &handler.Handler{ + Logger: h.Logger, + DB: h.DB, + Keys: h.Keys, + Permissions: h.Permissions, + Auditlogs: h.Auditlogs, + } + + h.Register(route) + + // Create a workspace and user + workspace := h.Resources().UserWorkspace + + // Create a keyAuth (keyring) for the API + keyAuthID := uid.New(uid.KeyAuthPrefix) + err := db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ + ID: keyAuthID, + WorkspaceID: workspace.ID, + CreatedAtM: time.Now().UnixMilli(), + DefaultPrefix: sql.NullString{Valid: false}, + DefaultBytes: sql.NullInt32{Valid: false}, + }) + require.NoError(t, err) + + // Create a test API + apiID := uid.New("api") + err = db.Query.InsertApi(ctx, h.DB.RW(), db.InsertApiParams{ + ID: apiID, + Name: "Test API", + WorkspaceID: workspace.ID, + AuthType: db.NullApisAuthType{Valid: true, ApisAuthType: db.ApisAuthTypeKey}, + KeyAuthID: sql.NullString{Valid: true, String: keyAuthID}, + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + softDeleteKeyID := uid.New(uid.KeyPrefix) + softDeleteKey, err := h.Keys.CreateKey(ctx, keys.CreateKeyRequest{ + Prefix: "test", + ByteLength: 16, + }) + require.NoError(t, err) + + err = db.Query.InsertKey(ctx, h.DB.RW(), db.InsertKeyParams{ + ID: softDeleteKeyID, + KeyringID: keyAuthID, + Hash: softDeleteKey.Hash, + Start: softDeleteKey.Start, + WorkspaceID: workspace.ID, + ForWorkspaceID: sql.NullString{Valid: false}, + Name: sql.NullString{Valid: true, String: "test-key"}, + Expires: sql.NullTime{Valid: false}, + CreatedAtM: time.Now().UnixMilli(), + Enabled: true, + IdentityID: sql.NullString{Valid: false, String: ""}, + RemainingRequests: sql.NullInt32{Int32: 0, Valid: false}, + }) + require.NoError(t, err) + + hardDeleteKeyID := uid.New(uid.KeyPrefix) + hardDeleteKey, err := h.Keys.CreateKey(ctx, keys.CreateKeyRequest{ + Prefix: "test", + ByteLength: 16, + }) + require.NoError(t, err) + + err = db.Query.InsertKey(ctx, h.DB.RW(), db.InsertKeyParams{ + ID: hardDeleteKeyID, + KeyringID: keyAuthID, + Hash: hardDeleteKey.Hash, + Start: hardDeleteKey.Start, + WorkspaceID: workspace.ID, + ForWorkspaceID: sql.NullString{Valid: false}, + Name: sql.NullString{Valid: true, String: "test-key"}, + Expires: sql.NullTime{Valid: false}, + CreatedAtM: time.Now().UnixMilli(), + Enabled: true, + IdentityID: sql.NullString{Valid: false, String: ""}, + RemainingRequests: sql.NullInt32{Int32: 0, Valid: false}, + }) + require.NoError(t, err) + + encryption, err := h.Vault.Encrypt(ctx, &vaultv1.EncryptRequest{ + Keyring: workspace.ID, + Data: hardDeleteKey.Key, + }) + require.NoError(t, err) + + err = db.Query.InsertKeyEncryption(ctx, h.DB.RW(), db.InsertKeyEncryptionParams{ + WorkspaceID: workspace.ID, + KeyID: hardDeleteKeyID, + CreatedAt: time.Now().UnixMilli(), + Encrypted: encryption.GetEncrypted(), + EncryptionKeyID: encryption.GetKeyId(), + }) + require.NoError(t, err) + + // Create permissions + perm1ID := uid.New(uid.PermissionPrefix) + err = db.Query.InsertPermission(ctx, h.DB.RW(), db.InsertPermissionParams{ + PermissionID: perm1ID, + WorkspaceID: workspace.ID, + Name: "read_data", + Slug: "read_data", + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + // Assign permissions to key + err = db.Query.InsertKeyPermission(ctx, h.DB.RW(), db.InsertKeyPermissionParams{ + KeyID: hardDeleteKeyID, + PermissionID: perm1ID, + WorkspaceID: workspace.ID, + }) + require.NoError(t, err) + + roleID := uid.New(uid.RolePrefix) + err = db.Query.InsertRole(ctx, h.DB.RW(), db.InsertRoleParams{ + RoleID: roleID, + WorkspaceID: workspace.ID, + Name: "data_admin", + }) + require.NoError(t, err) + + // Assign role to key + err = db.Query.InsertKeyRole(ctx, h.DB.RW(), db.InsertKeyRoleParams{ + KeyID: hardDeleteKeyID, + RoleID: roleID, + WorkspaceID: workspace.ID, + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + // Create ratelimits for the key + rl1ID := uid.New(uid.RatelimitPrefix) + err = db.Query.InsertKeyRatelimit(ctx, h.DB.RW(), db.InsertKeyRatelimitParams{ + ID: rl1ID, + WorkspaceID: workspace.ID, + KeyID: sql.NullString{Valid: true, String: hardDeleteKeyID}, + Name: "api_calls", + Limit: 100, + Duration: 60000, // 1 minute + CreatedAt: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + // Create a root key with appropriate permissions + rootKey := h.CreateRootKey(workspace.ID, "api.*.delete_key") + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + t.Run("soft delete key", func(t *testing.T) { + now := time.Now().UnixMilli() + req := handler.Request{ + KeyId: softDeleteKeyID, + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, 200, res.Status) + require.NotNil(t, res.Body) + + key, err := db.Query.FindKeyByID(ctx, h.DB.RO(), softDeleteKeyID) + require.NoError(t, err) + require.NotNil(t, key) + require.Equal(t, key.DeletedAtM.Valid, true) + require.Greater(t, key.DeletedAtM.Int64, now) + }) + + t.Run("permanently delete key", func(t *testing.T) { + req := handler.Request{ + KeyId: hardDeleteKeyID, + Permanent: ptr.P(true), + } + + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, 200, res.Status) + require.NotNil(t, res.Body) + + _, err := db.Query.FindKeyByID(ctx, h.DB.RO(), hardDeleteKeyID) + require.Equal(t, sql.ErrNoRows, err) + + ratelimits, err := db.Query.ListRatelimitsByKeyID(ctx, h.DB.RO(), sql.NullString{String: hardDeleteKeyID, Valid: true}) + require.NoError(t, err) + require.Len(t, ratelimits, 0) + + roles, err := db.Query.ListRolesByKeyID(ctx, h.DB.RO(), hardDeleteKeyID) + require.NoError(t, err) + require.Len(t, roles, 0) + + permissions, err := db.Query.ListPermissionsByKeyID(ctx, h.DB.RO(), db.ListPermissionsByKeyIDParams{ + KeyID: hardDeleteKeyID, + }) + require.NoError(t, err) + require.Len(t, permissions, 0) + + _, err = db.Query.FindKeyEncryptionByKeyID(ctx, h.DB.RO(), hardDeleteKeyID) + require.Equal(t, sql.ErrNoRows, err) + }) +} diff --git a/go/apps/api/routes/v2_keys_delete_key/400_test.go b/go/apps/api/routes/v2_keys_delete_key/400_test.go new file mode 100644 index 0000000000..298a8863a5 --- /dev/null +++ b/go/apps/api/routes/v2_keys_delete_key/400_test.go @@ -0,0 +1,56 @@ +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_keys_delete_key" + "github.com/unkeyed/unkey/go/pkg/testutil" +) + +func TestKeyDeleteBadRequest(t *testing.T) { + h := testutil.NewHarness(t) + + route := &handler.Handler{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Permissions: h.Permissions, + Auditlogs: h.Auditlogs, + } + + h.Register(route) + + // Create root key with read permissions + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "api.*.delete_key") + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + t.Run("missing keyId", func(t *testing.T) { + req := handler.Request{} + + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + require.Contains(t, res.Body.Error.Detail, "POST request body for '/v2/keys.deleteKey' failed to validate schema") + }) + + t.Run("empty keyId string", func(t *testing.T) { + req := handler.Request{ + KeyId: "", + } + + res := testutil.CallRoute[handler.Request, openapi.BadRequestErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + }) + +} diff --git a/go/apps/api/routes/v2_keys_delete_key/401_test.go b/go/apps/api/routes/v2_keys_delete_key/401_test.go new file mode 100644 index 0000000000..3ec9df0530 --- /dev/null +++ b/go/apps/api/routes/v2_keys_delete_key/401_test.go @@ -0,0 +1,144 @@ +package handler_test + +import ( + "database/sql" + "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_keys_delete_key" + "github.com/unkeyed/unkey/go/internal/services/keys" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +func TestKeyDeleteUnauthorized(t *testing.T) { + h := testutil.NewHarness(t) + ctx := t.Context() + + route := &handler.Handler{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Permissions: h.Permissions, + Auditlogs: h.Auditlogs, + } + + h.Register(route) + + // Create a workspace and user + workspace := h.Resources().UserWorkspace + + // Create a keyAuth (keyring) for the API + keyAuthID := uid.New(uid.KeyAuthPrefix) + err := db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ + ID: keyAuthID, + WorkspaceID: workspace.ID, + CreatedAtM: time.Now().UnixMilli(), + DefaultPrefix: sql.NullString{Valid: false}, + DefaultBytes: sql.NullInt32{Valid: false}, + }) + require.NoError(t, err) + + // Create a test API + apiID := uid.New("api") + err = db.Query.InsertApi(ctx, h.DB.RW(), db.InsertApiParams{ + ID: apiID, + Name: "Test API", + WorkspaceID: workspace.ID, + AuthType: db.NullApisAuthType{Valid: true, ApisAuthType: db.ApisAuthTypeKey}, + KeyAuthID: sql.NullString{Valid: true, String: keyAuthID}, + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + keyID := uid.New(uid.KeyPrefix) + key, _ := h.Keys.CreateKey(ctx, keys.CreateKeyRequest{ + Prefix: "test", + ByteLength: 16, + }) + + insertParams := db.InsertKeyParams{ + ID: keyID, + KeyringID: keyAuthID, + Hash: key.Hash, + Start: key.Start, + WorkspaceID: workspace.ID, + ForWorkspaceID: sql.NullString{Valid: false}, + Name: sql.NullString{Valid: true, String: "test-key"}, + Expires: sql.NullTime{Valid: false}, + CreatedAtM: time.Now().UnixMilli(), + Enabled: true, + IdentityID: sql.NullString{Valid: false, String: ""}, + RemainingRequests: sql.NullInt32{Int32: 100, Valid: true}, + } + + err = db.Query.InsertKey(ctx, h.DB.RW(), insertParams) + require.NoError(t, err) + + req := handler.Request{ + KeyId: keyID, + } + + t.Run("missing authorization header", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + } + + res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + }) + + t.Run("empty authorization header", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {""}, + } + + res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + }) + + t.Run("malformed authorization header - no Bearer prefix", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {"invalid_token_without_bearer"}, + } + + res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + }) + + t.Run("malformed authorization header - Bearer only", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {"Bearer"}, + } + + res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) + require.Equal(t, 400, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + }) + + t.Run("nonexistent root key", func(t *testing.T) { + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {"Bearer " + uid.New(uid.KeyPrefix)}, + } + + res := testutil.CallRoute[handler.Request, openapi.UnauthorizedErrorResponse](h, route, headers, req) + require.Equal(t, 401, res.Status) + require.NotNil(t, res.Body) + require.NotNil(t, res.Body.Error) + }) +} diff --git a/go/apps/api/routes/v2_keys_delete_key/403_test.go b/go/apps/api/routes/v2_keys_delete_key/403_test.go new file mode 100644 index 0000000000..286fa22ff3 --- /dev/null +++ b/go/apps/api/routes/v2_keys_delete_key/403_test.go @@ -0,0 +1,190 @@ +package handler_test + +import ( + "context" + "database/sql" + "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_keys_delete_key" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/hash" + "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +func TestKeyDeleteForbidden(t *testing.T) { + + h := testutil.NewHarness(t) + ctx := context.Background() + + route := &handler.Handler{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Permissions: h.Permissions, + Auditlogs: h.Auditlogs, + } + + h.Register(route) + + // Create API for testing + keyAuthID := uid.New(uid.KeyAuthPrefix) + err := db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ + ID: keyAuthID, + WorkspaceID: h.Resources().UserWorkspace.ID, + CreatedAtM: time.Now().UnixMilli(), + DefaultPrefix: sql.NullString{Valid: false, String: ""}, + DefaultBytes: sql.NullInt32{Valid: false, Int32: 0}, + }) + require.NoError(t, err) + + apiID := uid.New(uid.APIPrefix) + err = db.Query.InsertApi(ctx, h.DB.RW(), db.InsertApiParams{ + ID: apiID, + Name: "test-api", + WorkspaceID: h.Resources().UserWorkspace.ID, + AuthType: db.NullApisAuthType{Valid: true, ApisAuthType: db.ApisAuthTypeKey}, + KeyAuthID: sql.NullString{Valid: true, String: keyAuthID}, + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + // Create another API for cross-API testing + otherKeyAuthID := uid.New(uid.KeyAuthPrefix) + err = db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ + ID: otherKeyAuthID, + WorkspaceID: h.Resources().UserWorkspace.ID, + CreatedAtM: time.Now().UnixMilli(), + DefaultPrefix: sql.NullString{Valid: false, String: ""}, + DefaultBytes: sql.NullInt32{Valid: false, Int32: 0}, + }) + require.NoError(t, err) + + otherApiID := uid.New(uid.APIPrefix) + err = db.Query.InsertApi(ctx, h.DB.RW(), db.InsertApiParams{ + ID: otherApiID, + Name: "other-api", + WorkspaceID: h.Resources().UserWorkspace.ID, + AuthType: db.NullApisAuthType{Valid: true, ApisAuthType: db.ApisAuthTypeKey}, + KeyAuthID: sql.NullString{Valid: true, String: otherKeyAuthID}, + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + // Create another Workspace for cross-API testing + otherWorkspace := h.CreateWorkspace() + + otherWsKeyAuthID := uid.New(uid.KeyAuthPrefix) + err = db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ + ID: otherWsKeyAuthID, + WorkspaceID: otherWorkspace.ID, + CreatedAtM: time.Now().UnixMilli(), + DefaultPrefix: sql.NullString{Valid: false, String: ""}, + DefaultBytes: sql.NullInt32{Valid: false, Int32: 0}, + }) + require.NoError(t, err) + + otherWsApiID := uid.New(uid.APIPrefix) + err = db.Query.InsertApi(ctx, h.DB.RW(), db.InsertApiParams{ + ID: otherWsApiID, + Name: "test-api", + WorkspaceID: otherWorkspace.ID, + AuthType: db.NullApisAuthType{Valid: true, ApisAuthType: db.ApisAuthTypeKey}, + KeyAuthID: sql.NullString{Valid: true, String: otherWsKeyAuthID}, + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + // Create a test key + keyID := uid.New(uid.KeyPrefix) + keyString := "test_" + uid.New("") + err = db.Query.InsertKey(ctx, h.DB.RW(), db.InsertKeyParams{ + ID: keyID, + KeyringID: keyAuthID, + Hash: hash.Sha256(keyString), + Start: keyString[:4], + WorkspaceID: h.Resources().UserWorkspace.ID, + ForWorkspaceID: sql.NullString{Valid: false}, + Name: sql.NullString{Valid: true, String: "Test Key"}, + CreatedAtM: time.Now().UnixMilli(), + Enabled: true, + IdentityID: sql.NullString{Valid: false}, + Meta: sql.NullString{Valid: false}, + Expires: sql.NullTime{Valid: false}, + RemainingRequests: sql.NullInt32{Valid: true, Int32: 100}, + RatelimitAsync: sql.NullBool{Valid: false}, + RatelimitLimit: sql.NullInt32{Valid: false}, + RatelimitDuration: sql.NullInt64{Valid: false}, + Environment: sql.NullString{Valid: false}, + }) + require.NoError(t, err) + + req := handler.Request{ + KeyId: keyID, + } + + t.Run("no permissions", func(t *testing.T) { + // Create root key with no permissions + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID) + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) + require.Equal(t, 403, res.Status) + require.NotNil(t, res.Body) + }) + + t.Run("wrong permission - has create but not delete", func(t *testing.T) { + // Create root key with read permission instead of create + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "api.*.create_key") + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) + require.Equal(t, 403, res.Status) + require.NotNil(t, res.Body) + }) + + t.Run("cross workspace access", func(t *testing.T) { + // Create a different workspace + differentWorkspace := h.CreateWorkspace() + + // Create a root key for the different workspace with full permissions + rootKey := h.CreateRootKey(differentWorkspace.ID, "api.*.delete_key") + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) + require.Equal(t, 404, res.Status) + require.NotNil(t, res.Body) + }) + + t.Run("cross api access", func(t *testing.T) { + // Create root key with read permission for a single api + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, fmt.Sprintf("api.%s.delete_key", otherApiID)) + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) + + require.Equal(t, 403, res.Status) + require.NotNil(t, res.Body) + }) +} diff --git a/go/apps/api/routes/v2_keys_delete_key/404_test.go b/go/apps/api/routes/v2_keys_delete_key/404_test.go new file mode 100644 index 0000000000..e12584764b --- /dev/null +++ b/go/apps/api/routes/v2_keys_delete_key/404_test.go @@ -0,0 +1,117 @@ +package handler_test + +import ( + "database/sql" + "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_keys_delete_key" + "github.com/unkeyed/unkey/go/internal/services/keys" + "github.com/unkeyed/unkey/go/pkg/db" + "github.com/unkeyed/unkey/go/pkg/testutil" + "github.com/unkeyed/unkey/go/pkg/uid" +) + +func TestKeyDeleteNotFound(t *testing.T) { + h := testutil.NewHarness(t) + ctx := t.Context() + + route := &handler.Handler{ + 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, "api.*.delete_key") + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + // Create a workspace and user + workspace := h.Resources().UserWorkspace + + // Create a keyAuth (keyring) for the API + keyAuthID := uid.New(uid.KeyAuthPrefix) + err := db.Query.InsertKeyring(ctx, h.DB.RW(), db.InsertKeyringParams{ + ID: keyAuthID, + WorkspaceID: workspace.ID, + CreatedAtM: time.Now().UnixMilli(), + DefaultPrefix: sql.NullString{Valid: false}, + DefaultBytes: sql.NullInt32{Valid: false}, + }) + require.NoError(t, err) + + // Create a test API + apiID := uid.New("api") + err = db.Query.InsertApi(ctx, h.DB.RW(), db.InsertApiParams{ + ID: apiID, + Name: "Test API", + WorkspaceID: workspace.ID, + AuthType: db.NullApisAuthType{Valid: true, ApisAuthType: db.ApisAuthTypeKey}, + KeyAuthID: sql.NullString{Valid: true, String: keyAuthID}, + CreatedAtM: time.Now().UnixMilli(), + }) + require.NoError(t, err) + + keyID := uid.New(uid.KeyPrefix) + key, _ := h.Keys.CreateKey(ctx, keys.CreateKeyRequest{ + Prefix: "test", + ByteLength: 16, + }) + + insertParams := db.InsertKeyParams{ + ID: keyID, + KeyringID: keyAuthID, + Hash: key.Hash, + Start: key.Start, + WorkspaceID: workspace.ID, + ForWorkspaceID: sql.NullString{Valid: false}, + Name: sql.NullString{Valid: true, String: "test-key"}, + Expires: sql.NullTime{Valid: false}, + CreatedAtM: time.Now().UnixMilli(), + Enabled: true, + IdentityID: sql.NullString{Valid: false, String: ""}, + RemainingRequests: sql.NullInt32{Int32: 100, Valid: true}, + } + + err = db.Query.InsertKey(ctx, h.DB.RW(), insertParams) + require.NoError(t, err) + + err = db.Query.SoftDeleteKeyByID(ctx, h.DB.RW(), db.SoftDeleteKeyByIDParams{ + Now: sql.NullInt64{Int64: time.Now().UnixMilli(), Valid: true}, + ID: keyID, + }) + require.NoError(t, err) + + t.Run("nonexistent keyId", func(t *testing.T) { + req := handler.Request{ + KeyId: uid.New(uid.KeyPrefix), + } + + res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) + require.Equal(t, 404, res.Status) + require.NotNil(t, res.Body) + require.Contains(t, res.Body.Error.Detail, "We could not find the requested key") + }) + + t.Run("can't delete soft deleted key", func(t *testing.T) { + req := handler.Request{ + KeyId: keyID, + } + + res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) + require.Equal(t, 404, res.Status) + require.NotNil(t, res.Body) + require.Contains(t, res.Body.Error.Detail, "We could not find the requested key") + }) +} diff --git a/go/apps/api/routes/v2_keys_delete_key/handler.go b/go/apps/api/routes/v2_keys_delete_key/handler.go new file mode 100644 index 0000000000..d9e83804aa --- /dev/null +++ b/go/apps/api/routes/v2_keys_delete_key/handler.go @@ -0,0 +1,171 @@ +package handler + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "time" + + "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/ptr" + "github.com/unkeyed/unkey/go/pkg/rbac" + "github.com/unkeyed/unkey/go/pkg/zen" +) + +type Request = openapi.V2KeysDeleteKeyRequestBody +type Response = openapi.V2KeysDeleteKeyResponseBody + +// Handler implements zen.Route interface for the v2 keys.deleteKey endpoint +type Handler struct { + Logger logging.Logger + DB db.Database + Keys keys.KeyService + Permissions permissions.PermissionService + Auditlogs auditlogs.AuditLogService +} + +// Method returns the HTTP method this route responds to +func (h *Handler) Method() string { + return "POST" +} + +// Path returns the URL path pattern this route matches +func (h *Handler) Path() string { + return "/v2/keys.deleteKey" +} + +func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { + h.Logger.Debug("handling request", "requestId", s.RequestID(), "path", "/v2/keys.deleteKey") + + // Authentication + auth, err := h.Keys.VerifyRootKey(ctx, s) + if err != nil { + return err + } + + // Request validation + req, err := zen.BindBody[Request](s) + if err != nil { + return err + } + + key, err := db.Query.FindKeyByIdOrHash(ctx, + h.DB.RO(), + db.FindKeyByIdOrHashParams{ + ID: sql.NullString{String: req.KeyId, Valid: true}, + Hash: sql.NullString{String: "", Valid: false}, + }, + ) + if err != nil { + if db.IsNotFound(err) { + return fault.Wrap( + err, + fault.Code(codes.Data.Key.NotFound.URN()), + fault.Internal("key does not exist"), + fault.Public("We could not find the requested key."), + ) + } + + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to retrieve Key information."), + ) + } + + // Validate key belongs to authorized workspace + if key.WorkspaceID != auth.AuthorizedWorkspaceID { + return fault.New("key not found", + fault.Code(codes.Data.Key.NotFound.URN()), + fault.Internal("key belongs to different workspace"), + fault.Public("The specified key was not found."), + ) + } + + // Permission check + err = h.Permissions.Check( + ctx, + auth.KeyID, + rbac.Or( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: "*", + Action: rbac.DeleteKey, + }), + rbac.T(rbac.Tuple{ + ResourceType: rbac.Api, + ResourceID: key.Api.ID, + Action: rbac.DeleteKey, + }), + ), + ) + if err != nil { + return err + } + + err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) (err error) { + description := "Deleted" + if ptr.SafeDeref(req.Permanent) { + err = db.Query.DeleteKeyByID(ctx, tx, req.KeyId) + description = "Permanently deleted" + } else { + err = db.Query.SoftDeleteKeyByID(ctx, tx, db.SoftDeleteKeyByIDParams{ + Now: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()}, + ID: req.KeyId, + }) + } + + if err != nil { + return fault.Wrap(err, + fault.Code(codes.App.Internal.ServiceUnavailable.URN()), + fault.Internal("database error"), + fault.Public("Failed to delete key."), + ) + } + + err = h.Auditlogs.Insert(ctx, tx, []auditlog.AuditLog{ + { + Event: auditlog.KeyDeleteEvent, + WorkspaceID: auth.AuthorizedWorkspaceID, + ActorType: auditlog.RootKeyActor, + ActorID: auth.KeyID, + ActorName: "root key", + ActorMeta: map[string]any{}, + Display: fmt.Sprintf("%s %s", description, key.ID), + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + ID: key.ID, + DisplayName: key.Name.String, + Name: key.Name.String, + Meta: map[string]any{}, + Type: auditlog.KeyResourceType, + }, + }, + }, + }) + + return err + }) + + if err != nil { + return err + } + + return s.JSON(http.StatusOK, Response{ + Meta: openapi.Meta{ + RequestId: s.RequestID(), + }, + Data: &openapi.KeysDeleteKeyResponseData{}, + }) +} diff --git a/go/pkg/db/key_delete_by_id.sql_generated.go b/go/pkg/db/key_delete_by_id.sql_generated.go new file mode 100644 index 0000000000..26dfd41166 --- /dev/null +++ b/go/pkg/db/key_delete_by_id.sql_generated.go @@ -0,0 +1,34 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: key_delete_by_id.sql + +package db + +import ( + "context" +) + +const deleteKeyByID = `-- name: DeleteKeyByID :exec +DELETE k, kp, kr, rl, ek +FROM ` + "`" + `keys` + "`" + ` k +LEFT JOIN keys_permissions kp ON k.id = kp.key_id +LEFT JOIN keys_roles kr ON k.id = kr.key_id +LEFT JOIN ratelimits rl ON k.id = rl.key_id +LEFT JOIN encrypted_keys ek ON k.id = ek.key_id +WHERE k.id = ? +` + +// DeleteKeyByID +// +// DELETE k, kp, kr, rl, ek +// FROM `keys` k +// LEFT JOIN keys_permissions kp ON k.id = kp.key_id +// LEFT JOIN keys_roles kr ON k.id = kr.key_id +// LEFT JOIN ratelimits rl ON k.id = rl.key_id +// LEFT JOIN encrypted_keys ek ON k.id = ek.key_id +// WHERE k.id = ? +func (q *Queries) DeleteKeyByID(ctx context.Context, db DBTX, id string) error { + _, err := db.ExecContext(ctx, deleteKeyByID, id) + return err +} diff --git a/go/pkg/db/key_soft_delete_by_id.sql_generated.go b/go/pkg/db/key_soft_delete_by_id.sql_generated.go new file mode 100644 index 0000000000..8b0347b728 --- /dev/null +++ b/go/pkg/db/key_soft_delete_by_id.sql_generated.go @@ -0,0 +1,28 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: key_soft_delete_by_id.sql + +package db + +import ( + "context" + "database/sql" +) + +const softDeleteKeyByID = `-- name: SoftDeleteKeyByID :exec +UPDATE ` + "`" + `keys` + "`" + ` SET deleted_at_m = ? WHERE id = ? +` + +type SoftDeleteKeyByIDParams struct { + Now sql.NullInt64 `db:"now"` + ID string `db:"id"` +} + +// SoftDeleteKeyByID +// +// UPDATE `keys` SET deleted_at_m = ? WHERE id = ? +func (q *Queries) SoftDeleteKeyByID(ctx context.Context, db DBTX, arg SoftDeleteKeyByIDParams) error { + _, err := db.ExecContext(ctx, softDeleteKeyByID, arg.Now, arg.ID) + return err +} diff --git a/go/pkg/db/querier_generated.go b/go/pkg/db/querier_generated.go index a20904263a..7954f600b9 100644 --- a/go/pkg/db/querier_generated.go +++ b/go/pkg/db/querier_generated.go @@ -14,6 +14,16 @@ type Querier interface { // // DELETE FROM identities WHERE id = ? DeleteIdentity(ctx context.Context, db DBTX, id string) error + //DeleteKeyByID + // + // DELETE k, kp, kr, rl, ek + // FROM `keys` k + // LEFT JOIN keys_permissions kp ON k.id = kp.key_id + // LEFT JOIN keys_roles kr ON k.id = kr.key_id + // LEFT JOIN ratelimits rl ON k.id = rl.key_id + // LEFT JOIN encrypted_keys ek ON k.id = ek.key_id + // WHERE k.id = ? + DeleteKeyByID(ctx context.Context, db DBTX, id string) error //DeleteKeyPermissionByKeyAndPermissionID // // DELETE FROM keys_permissions @@ -816,6 +826,10 @@ type Querier interface { // // UPDATE identities set deleted = 1 WHERE id = ? SoftDeleteIdentity(ctx context.Context, db DBTX, id string) error + //SoftDeleteKeyByID + // + // UPDATE `keys` SET deleted_at_m = ? WHERE id = ? + SoftDeleteKeyByID(ctx context.Context, db DBTX, arg SoftDeleteKeyByIDParams) error //SoftDeleteManyKeysByKeyAuthID // // UPDATE `keys` diff --git a/go/pkg/db/queries/key_delete_by_id.sql b/go/pkg/db/queries/key_delete_by_id.sql new file mode 100644 index 0000000000..d39ac2f616 --- /dev/null +++ b/go/pkg/db/queries/key_delete_by_id.sql @@ -0,0 +1,8 @@ +-- name: DeleteKeyByID :exec +DELETE k, kp, kr, rl, ek +FROM `keys` k +LEFT JOIN keys_permissions kp ON k.id = kp.key_id +LEFT JOIN keys_roles kr ON k.id = kr.key_id +LEFT JOIN ratelimits rl ON k.id = rl.key_id +LEFT JOIN encrypted_keys ek ON k.id = ek.key_id +WHERE k.id = ?; diff --git a/go/pkg/db/queries/key_soft_delete_by_id.sql b/go/pkg/db/queries/key_soft_delete_by_id.sql new file mode 100644 index 0000000000..31aa00432a --- /dev/null +++ b/go/pkg/db/queries/key_soft_delete_by_id.sql @@ -0,0 +1,2 @@ +-- name: SoftDeleteKeyByID :exec +UPDATE `keys` SET deleted_at_m = sqlc.arg(now) WHERE id = ?;