diff --git a/apps/docs/errors/unkey/data/ratelimit_namespace_gone.mdx b/apps/docs/errors/unkey/data/ratelimit_namespace_gone.mdx new file mode 100644 index 0000000000..628b0b904a --- /dev/null +++ b/apps/docs/errors/unkey/data/ratelimit_namespace_gone.mdx @@ -0,0 +1,36 @@ +--- +title: Ratelimit Namespace Gone +--- + +## Description + +This error occurs when you attempt to use a ratelimit namespace that has been deleted. Once a namespace is deleted, it cannot be restored through the API or dashboard. + +## Error Code + +`unkey/data/ratelimit_namespace_gone` + +## HTTP Status Code + +`410 Gone` + +## Cause + +The ratelimit namespace you're trying to access was previously deleted and is no longer available through the API or dashboard. + +## Resolution + + +Contact [support@unkey.dev](mailto:support@unkey.dev) with your workspace ID and namespace name if you need this namespace restored. + + +## Prevention + +To avoid accidentally deleting namespaces: +- Restrict namespace deletion via workspace permissions +- Carefully review namespace-deletion requests before confirming + +## Related + +- [Ratelimit Namespace Not Found](/errors/unkey/data/ratelimit_namespace_not_found) +- [Rate Limiting Documentation](/ratelimiting/introduction) \ No newline at end of file diff --git a/apps/docs/errors/user/bad_request/request_body_too_large.mdx b/apps/docs/errors/user/bad_request/request_body_too_large.mdx index 10d19a0b4c..decf08c6b2 100644 --- a/apps/docs/errors/user/bad_request/request_body_too_large.mdx +++ b/apps/docs/errors/user/bad_request/request_body_too_large.mdx @@ -77,7 +77,7 @@ Instead of cramming everything into your API request: **Got a special use case?** If you have a legitimate need to send larger requests, we'd love to hear about it! -[Contact our support team](mailto:support@unkey.com) and include: +[Contact our support team](mailto:support@unkey.dev) and include: - What you're building - Why you need to send large requests - An example of the data you're trying to send diff --git a/go/apps/api/openapi/gen.go b/go/apps/api/openapi/gen.go index 554a8352af..2390dff60e 100644 --- a/go/apps/api/openapi/gen.go +++ b/go/apps/api/openapi/gen.go @@ -137,6 +137,20 @@ type ForbiddenErrorResponse struct { Meta Meta `json:"meta"` } +// GoneErrorResponse Error response when the requested resource has been soft-deleted and is no longer available. This occurs when: +// - The resource has been marked as deleted but still exists in the database +// - The resource is intentionally unavailable but could potentially be restored +// - The resource cannot be restored through the API or dashboard +// +// To resolve this error, contact support if you need the resource restored. +type GoneErrorResponse struct { + // Error Base error structure following Problem Details for HTTP APIs (RFC 7807). This provides a standardized way to carry machine-readable details of errors in HTTP response content. + Error BaseError `json:"error"` + + // Meta Metadata object included in every API response. This provides context about the request and is essential for debugging, audit trails, and support inquiries. The `requestId` is particularly important when troubleshooting issues with the Unkey support team. + Meta Meta `json:"meta"` +} + // Identity defines model for Identity. type Identity struct { // ExternalId External identity ID diff --git a/go/apps/api/openapi/openapi-generated.yaml b/go/apps/api/openapi/openapi-generated.yaml index 15744238e5..577f0764a1 100644 --- a/go/apps/api/openapi/openapi-generated.yaml +++ b/go/apps/api/openapi/openapi-generated.yaml @@ -1825,6 +1825,23 @@ components: "$ref": "#/components/schemas/Meta" data: "$ref": "#/components/schemas/V2RatelimitLimitResponseData" + GoneErrorResponse: + type: object + required: + - meta + - error + properties: + meta: + $ref: "#/components/schemas/Meta" + error: + $ref: "#/components/schemas/BaseError" + description: |- + Error response when the requested resource has been soft-deleted and is no longer available. This occurs when: + - The resource has been marked as deleted but still exists in the database + - The resource is intentionally unavailable but could potentially be restored + - The resource cannot be restored through the API or dashboard + + To resolve this error, contact support if you need the resource restored. V2RatelimitListOverridesRequestBody: additionalProperties: false properties: @@ -5670,7 +5687,7 @@ paths: Use this for rate limiting beyond API keys - limit users by ID, IPs by address, or any custom identifier. Supports namespace organization, variable costs, and custom overrides. - **Important**: Always returns HTTP 200. Check the `success` field to determine if the request should proceed. + **Response Codes**: Successful ratelimit checks return HTTP 200. 4xx responses indicate auth, namespace existence/deletion, or validation errors (e.g., 410 Gone for deleted namespaces). 5xx responses indicate server errors. **Required Permissions** @@ -5750,7 +5767,7 @@ paths: schema: $ref: '#/components/schemas/V2RatelimitLimitResponseBody' description: | - Rate limit check completed. Always returns HTTP 200 - check the `success` field to determine if the request is allowed. + Rate limit check completed successfully. Check the `success` field to determine if the request is allowed. "400": content: application/json: @@ -5775,6 +5792,12 @@ paths: schema: $ref: '#/components/schemas/NotFoundErrorResponse' description: Not Found + "410": + content: + application/json: + schema: + $ref: '#/components/schemas/GoneErrorResponse' + description: Gone - Namespace has been deleted "500": content: application/json: diff --git a/go/apps/api/openapi/spec/error/GoneErrorResponse.yaml b/go/apps/api/openapi/spec/error/GoneErrorResponse.yaml new file mode 100644 index 0000000000..a65a80d211 --- /dev/null +++ b/go/apps/api/openapi/spec/error/GoneErrorResponse.yaml @@ -0,0 +1,16 @@ +type: object +required: + - meta + - error +properties: + meta: + $ref: "../common/Meta.yaml" + error: + $ref: "./BaseError.yaml" +description: |- + Error response when the requested resource has been soft-deleted and is no longer available. This occurs when: + - The resource has been marked as deleted but still exists in the database + - The resource is intentionally unavailable but could potentially be restored + - The resource cannot be restored through the API or dashboard + + To resolve this error, contact support if you need the resource restored. diff --git a/go/apps/api/openapi/spec/paths/v2/ratelimit/limit/index.yaml b/go/apps/api/openapi/spec/paths/v2/ratelimit/limit/index.yaml index 504e4a0870..cffa616826 100644 --- a/go/apps/api/openapi/spec/paths/v2/ratelimit/limit/index.yaml +++ b/go/apps/api/openapi/spec/paths/v2/ratelimit/limit/index.yaml @@ -7,7 +7,7 @@ post: Use this for rate limiting beyond API keys - limit users by ID, IPs by address, or any custom identifier. Supports namespace organization, variable costs, and custom overrides. - **Important**: Always returns HTTP 200. Check the `success` field to determine if the request should proceed. + **Response Codes**: Rate limit checks return HTTP 200 regardless of whether the limit is exceeded - check the `success` field in the response to determine if the request should be allowed. 4xx responses indicate auth, namespace existence/deletion, or validation errors (e.g., 410 Gone for deleted namespaces). 5xx responses indicate server errors. **Required Permissions** @@ -90,7 +90,7 @@ post: success: true overrideId: ovr_2cGKbMxRyIzhCxo1Idjz8q description: | - Rate limit check completed. Always returns HTTP 200 - check the `success` field to determine if the request is allowed. + Rate limit check completed successfully. Check the `success` field to determine if the request is allowed. "400": description: Bad request content: @@ -115,6 +115,12 @@ post: application/json: schema: "$ref": "../../../../error/NotFoundErrorResponse.yaml" + "410": + description: Gone - Namespace has been deleted + content: + application/json: + schema: + "$ref": "../../../../error/GoneErrorResponse.yaml" "500": description: Internal server error content: diff --git a/go/apps/api/routes/register.go b/go/apps/api/routes/register.go index 7bd08e116b..5e3ac1a17a 100644 --- a/go/apps/api/routes/register.go +++ b/go/apps/api/routes/register.go @@ -122,6 +122,7 @@ func Register(srv *zen.Server, svc *Services) { Ratelimit: svc.Ratelimit, RatelimitNamespaceCache: svc.Caches.RatelimitNamespace, TestMode: srv.Flags().TestMode, + Auditlogs: svc.Auditlogs, }, ) diff --git a/go/apps/api/routes/v2_ratelimit_delete_override/handler.go b/go/apps/api/routes/v2_ratelimit_delete_override/handler.go index e507543d35..85595e6810 100644 --- a/go/apps/api/routes/v2_ratelimit_delete_override/handler.go +++ b/go/apps/api/routes/v2_ratelimit_delete_override/handler.go @@ -3,12 +3,15 @@ package handler import ( "context" "database/sql" + "encoding/json" "fmt" "net/http" + "strings" "time" "github.com/unkeyed/unkey/go/apps/api/openapi" "github.com/unkeyed/unkey/go/internal/services/auditlogs" + "github.com/unkeyed/unkey/go/internal/services/caches" "github.com/unkeyed/unkey/go/internal/services/keys" "github.com/unkeyed/unkey/go/pkg/auditlog" "github.com/unkeyed/unkey/go/pkg/cache" @@ -23,7 +26,6 @@ import ( type Request = openapi.V2RatelimitDeleteOverrideRequestBody type Response = openapi.V2RatelimitDeleteOverrideResponseBody -// Handler implements zen.Route interface for the v2 ratelimit delete override endpoint type Handler struct { Logger logging.Logger DB db.Database @@ -32,17 +34,14 @@ type Handler struct { RatelimitNamespaceCache cache.Cache[cache.ScopedKey, db.FindRatelimitNamespace] } -// 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/ratelimit.deleteOverride" } -// Handle processes the HTTP request func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { auth, emit, err := h.Keys.GetRootKey(ctx, s) defer emit() @@ -55,68 +54,113 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { - namespace, err := db.Query.FindRatelimitNamespace(ctx, tx, db.FindRatelimitNamespaceParams{ - WorkspaceID: auth.AuthorizedWorkspaceID, - Namespace: req.Namespace, - }) - if err != nil { - if db.IsNotFound(err) { - return fault.New("namespace not found", - fault.Code(codes.Data.RatelimitNamespace.NotFound.URN()), - fault.Internal("namespace not found"), - fault.Public("This namespace does not exist."), - ) + namespace, hit, err := h.RatelimitNamespaceCache.SWR( + ctx, + cache.ScopedKey{WorkspaceID: auth.AuthorizedWorkspaceID, Key: req.Namespace}, + func(ctx context.Context) (db.FindRatelimitNamespace, error) { + result := db.FindRatelimitNamespace{} // nolint:exhaustruct + response, err := db.WithRetry(func() (db.FindRatelimitNamespaceRow, error) { + return db.Query.FindRatelimitNamespace(ctx, h.DB.RO(), db.FindRatelimitNamespaceParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Namespace: req.Namespace, + }) + }) + if err != nil { + return result, err } - return err - } - if namespace.DeletedAtM.Valid { - return fault.New("namespace was deleted", + result = db.FindRatelimitNamespace{ + ID: response.ID, + WorkspaceID: response.WorkspaceID, + Name: response.Name, + CreatedAtM: response.CreatedAtM, + UpdatedAtM: response.UpdatedAtM, + DeletedAtM: response.DeletedAtM, + DirectOverrides: make(map[string]db.FindRatelimitNamespaceLimitOverride), + WildcardOverrides: make([]db.FindRatelimitNamespaceLimitOverride, 0), + } + + overrides := make([]db.FindRatelimitNamespaceLimitOverride, 0) + if overrideBytes, ok := response.Overrides.([]byte); ok && overrideBytes != nil { + err = json.Unmarshal(overrideBytes, &overrides) + if err != nil { + return result, err + } + } + + for _, override := range overrides { + result.DirectOverrides[override.Identifier] = override + if strings.Contains(override.Identifier, "*") { + result.WildcardOverrides = append(result.WildcardOverrides, override) + } + } + + return result, nil + }, + caches.DefaultFindFirstOp, + ) + + if err != nil { + if db.IsNotFound(err) { + return fault.New("namespace not found", fault.Code(codes.Data.RatelimitNamespace.NotFound.URN()), + fault.Internal("namespace not found"), fault.Public("This namespace does not exist."), ) } - err = auth.VerifyRootKey(ctx, keys.WithPermissions(rbac.Or( - rbac.T(rbac.Tuple{ - ResourceType: rbac.Ratelimit, - ResourceID: namespace.ID, - Action: rbac.DeleteOverride, - }), - rbac.T(rbac.Tuple{ - ResourceType: rbac.Ratelimit, - ResourceID: "*", - Action: rbac.DeleteOverride, - }), - ))) - if err != nil { - return err - } - // Check if the override exists before deleting - override, overrideErr := db.Query.FindRatelimitOverrideByIdentifier(ctx, tx, db.FindRatelimitOverrideByIdentifierParams{ - WorkspaceID: auth.AuthorizedWorkspaceID, - NamespaceID: namespace.ID, - Identifier: req.Identifier, - }) + return fault.Wrap(err, + fault.Code(codes.App.Internal.UnexpectedError.URN()), + fault.Public("An unexpected error occurred while fetching the namespace."), + ) + } - if db.IsNotFound(overrideErr) { - return fault.New("override not found", - fault.Code(codes.Data.RatelimitOverride.NotFound.URN()), - fault.Internal("override not found"), - fault.Public("This override does not exist."), - ) - } - if overrideErr != nil { - return overrideErr - } + if namespace.DeletedAtM.Valid { + return fault.New("namespace deleted", + fault.Code(codes.Data.RatelimitNamespace.NotFound.URN()), + fault.Internal("namespace deleted"), + fault.Public("This namespace does not exist."), + ) + } - // Perform soft delete by updating the DeletedAt field + if hit == cache.Null { + return fault.New("namespace not found", + fault.Code(codes.Data.RatelimitNamespace.NotFound.URN()), + fault.Internal("namespace not found"), + fault.Public("This namespace does not exist."), + ) + } + + err = auth.VerifyRootKey(ctx, keys.WithPermissions(rbac.Or( + rbac.T(rbac.Tuple{ + ResourceType: rbac.Ratelimit, + ResourceID: namespace.ID, + Action: rbac.DeleteOverride, + }), + rbac.T(rbac.Tuple{ + ResourceType: rbac.Ratelimit, + ResourceID: "*", + Action: rbac.DeleteOverride, + }), + ))) + if err != nil { + return err + } + + override, ok := namespace.DirectOverrides[req.Identifier] + if !ok { + return fault.New("override not found", + fault.Code(codes.Data.RatelimitOverride.NotFound.URN()), + fault.Internal("override not found"), + fault.Public("This override does not exist."), + ) + } + + err = db.Tx(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) error { err = db.Query.SoftDeleteRatelimitOverride(ctx, tx, db.SoftDeleteRatelimitOverrideParams{ ID: override.ID, Now: sql.NullInt64{Valid: true, Int64: time.Now().UnixMilli()}, }) - if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.ServiceUnavailable.URN()), @@ -158,23 +202,24 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - h.RatelimitNamespaceCache.Remove(ctx, - cache.ScopedKey{ - WorkspaceID: auth.AuthorizedWorkspaceID, - Key: namespace.ID, - }, - cache.ScopedKey{ - WorkspaceID: auth.AuthorizedWorkspaceID, - Key: namespace.Name, - }, - ) - return nil }) if err != nil { return err } + h.RatelimitNamespaceCache.Remove( + ctx, + cache.ScopedKey{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Key: namespace.ID, + }, + cache.ScopedKey{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Key: namespace.Name, + }, + ) + return s.JSON(http.StatusOK, Response{ Meta: openapi.Meta{ RequestId: s.RequestID(), diff --git a/go/apps/api/routes/v2_ratelimit_limit/200_test.go b/go/apps/api/routes/v2_ratelimit_limit/200_test.go index e97ca836fa..0a03784859 100644 --- a/go/apps/api/routes/v2_ratelimit_limit/200_test.go +++ b/go/apps/api/routes/v2_ratelimit_limit/200_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" handler "github.com/unkeyed/unkey/go/apps/api/routes/v2_ratelimit_limit" + "github.com/unkeyed/unkey/go/pkg/auditlog" "github.com/unkeyed/unkey/go/pkg/clickhouse" "github.com/unkeyed/unkey/go/pkg/clickhouse/schema" "github.com/unkeyed/unkey/go/pkg/db" @@ -28,10 +29,58 @@ func TestLimitSuccessfully(t *testing.T) { ClickHouse: h.ClickHouse, Ratelimit: h.Ratelimit, RatelimitNamespaceCache: h.Caches.RatelimitNamespace, + Auditlogs: h.Auditlogs, } h.Register(route) + // Test auto-creation of namespace with audit log + t.Run("auto-create namespace with audit log", func(t *testing.T) { + // Use a namespace that doesn't exist + namespaceName := uid.New("nonexistent") + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "ratelimit.*.create_namespace", "ratelimit.*.limit") + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + req := handler.Request{ + Namespace: namespaceName, + Identifier: "user_123", + Limit: 100, + Duration: 60000, // 1 minute in ms + } + + // First request should succeed and create the namespace + res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req) + require.Equal(t, 200, res.Status, "expected 200, received: %v", res.Body) + require.NotNil(t, res.Body) + require.True(t, res.Body.Data.Success, "Rate limit should not be exceeded on first request") + require.Equal(t, int64(100), res.Body.Data.Limit) + require.Equal(t, int64(99), res.Body.Data.Remaining) + + // Verify namespace was created + namespace, err := db.Query.FindRatelimitNamespaceByName(ctx, h.DB.RO(), db.FindRatelimitNamespaceByNameParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + Name: namespaceName, + }) + require.NoError(t, err) + require.Equal(t, namespaceName, namespace.Name) + require.False(t, namespace.DeletedAtM.Valid, "Namespace should not be deleted") + + // Verify audit log was created for the namespace + auditTargets, err := db.Query.FindAuditLogTargetByID(ctx, h.DB.RO(), namespace.ID) + require.NoError(t, err) + require.Len(t, auditTargets, 1, "Should have exactly one audit log entry for the namespace") + + auditTarget := auditTargets[0] + require.Equal(t, string(auditlog.RatelimitNamespaceResourceType), auditTarget.AuditLogTarget.Type) + require.Equal(t, namespace.ID, auditTarget.AuditLogTarget.ID) + require.Equal(t, namespaceName, auditTarget.AuditLogTarget.Name.String) + require.Equal(t, string(auditlog.RatelimitNamespaceCreateEvent), auditTarget.AuditLog.Event) + require.Contains(t, auditTarget.AuditLog.Display, namespaceName, "Audit log should mention the namespace name") + }) + // Test basic rate limiting t.Run("basic rate limiting", func(t *testing.T) { namespaceID, namespaceName := createNamespace(t, h) @@ -102,7 +151,6 @@ func TestLimitSuccessfully(t *testing.T) { require.Equal(t, req.Identifier, row.Identifier) require.Equal(t, res.Body.Data.Success, row.Passed) require.Equal(t, res.Body.Meta.RequestId, row.RequestID) - }) // Test with custom cost diff --git a/go/apps/api/routes/v2_ratelimit_limit/400_test.go b/go/apps/api/routes/v2_ratelimit_limit/400_test.go index 195961917f..fc786b2b7a 100644 --- a/go/apps/api/routes/v2_ratelimit_limit/400_test.go +++ b/go/apps/api/routes/v2_ratelimit_limit/400_test.go @@ -25,6 +25,7 @@ func TestBadRequests(t *testing.T) { Logger: h.Logger, Ratelimit: h.Ratelimit, RatelimitNamespaceCache: h.Caches.RatelimitNamespace, + Auditlogs: h.Auditlogs, } h.Register(route) @@ -104,10 +105,12 @@ func TestMissingAuthorizationHeader(t *testing.T) { h := testutil.NewHarness(t) route := &handler.Handler{ - DB: h.DB, - Keys: h.Keys, - Logger: h.Logger, - Ratelimit: h.Ratelimit, + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Ratelimit: h.Ratelimit, + RatelimitNamespaceCache: h.Caches.RatelimitNamespace, + Auditlogs: h.Auditlogs, } h.Register(route) diff --git a/go/apps/api/routes/v2_ratelimit_limit/401_test.go b/go/apps/api/routes/v2_ratelimit_limit/401_test.go index e50ea8ac25..24013805d1 100644 --- a/go/apps/api/routes/v2_ratelimit_limit/401_test.go +++ b/go/apps/api/routes/v2_ratelimit_limit/401_test.go @@ -18,6 +18,7 @@ func TestUnauthorizedAccess(t *testing.T) { Logger: h.Logger, Ratelimit: h.Ratelimit, RatelimitNamespaceCache: h.Caches.RatelimitNamespace, + Auditlogs: h.Auditlogs, } h.Register(route) diff --git a/go/apps/api/routes/v2_ratelimit_limit/403_test.go b/go/apps/api/routes/v2_ratelimit_limit/403_test.go index 5386b683ae..d8500d2a5d 100644 --- a/go/apps/api/routes/v2_ratelimit_limit/403_test.go +++ b/go/apps/api/routes/v2_ratelimit_limit/403_test.go @@ -36,6 +36,7 @@ func TestWorkspacePermissions(t *testing.T) { Logger: h.Logger, Ratelimit: h.Ratelimit, RatelimitNamespaceCache: h.Caches.RatelimitNamespace, + Auditlogs: h.Auditlogs, } h.Register(route) @@ -57,9 +58,60 @@ func TestWorkspacePermissions(t *testing.T) { Duration: 60000, } - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) + res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) - // This should return a 404 Not Found (for security reasons we don't reveal if the namespace exists) - require.Equal(t, http.StatusNotFound, res.Status, "expected 404, got: %d, body: %s", res.Status, res.RawBody) + // This should return a 403 Forbidden - user lacks create_namespace permission + require.Equal(t, http.StatusForbidden, res.Status, "expected 403, got: %d, body: %s", res.Status, res.RawBody) require.NotNil(t, res.Body) + require.Contains(t, res.Body.Error.Detail, "create_namespace", "Error should mention missing create_namespace permission") +} + +func TestInsufficientPermissions(t *testing.T) { + h := testutil.NewHarness(t) + + route := &handler.Handler{ + DB: h.DB, + Keys: h.Keys, + Logger: h.Logger, + Ratelimit: h.Ratelimit, + RatelimitNamespaceCache: h.Caches.RatelimitNamespace, + Auditlogs: h.Auditlogs, + } + + h.Register(route) + + t.Run("has limit permission but no create_namespace permission", func(t *testing.T) { + // Use a namespace that doesn't exist + nonExistentNamespace := uid.New("nonexistent") + + // Create a key that can limit any namespace but cannot create namespaces + rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID, "ratelimit.*.limit") + + headers := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, + } + + req := handler.Request{ + Namespace: nonExistentNamespace, + Identifier: "user_123", + Limit: 100, + Duration: 60000, + } + + res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req) + + // Should return 403 because user has some permissions but not create_namespace + require.Equal(t, http.StatusForbidden, res.Status, "expected 403, got: %d, body: %s", res.Status, res.RawBody) + require.NotNil(t, res.Body) + require.Contains(t, res.Body.Error.Detail, "create_namespace", "Error should mention missing create_namespace permission") + + // Verify the namespace was NOT created + ctx := context.Background() + _, err := db.Query.FindRatelimitNamespace(ctx, h.DB.RO(), db.FindRatelimitNamespaceParams{ + WorkspaceID: h.Resources().UserWorkspace.ID, + Namespace: nonExistentNamespace, + }) + require.True(t, db.IsNotFound(err), "Namespace should not have been created when user lacks create_namespace permission") + }) } diff --git a/go/apps/api/routes/v2_ratelimit_limit/404_test.go b/go/apps/api/routes/v2_ratelimit_limit/410_test.go similarity index 57% rename from go/apps/api/routes/v2_ratelimit_limit/404_test.go rename to go/apps/api/routes/v2_ratelimit_limit/410_test.go index c8314eb136..be1545399b 100644 --- a/go/apps/api/routes/v2_ratelimit_limit/404_test.go +++ b/go/apps/api/routes/v2_ratelimit_limit/410_test.go @@ -16,7 +16,7 @@ import ( "github.com/unkeyed/unkey/go/pkg/uid" ) -func TestNamespaceNotFound(t *testing.T) { +func TestSoftDeletedNamespace(t *testing.T) { h := testutil.NewHarness(t) route := &handler.Handler{ @@ -25,38 +25,14 @@ func TestNamespaceNotFound(t *testing.T) { Logger: h.Logger, Ratelimit: h.Ratelimit, RatelimitNamespaceCache: h.Caches.RatelimitNamespace, + Auditlogs: h.Auditlogs, } h.Register(route) - rootKey := h.CreateRootKey(h.Resources().UserWorkspace.ID) - - headers := http.Header{ - "Content-Type": {"application/json"}, - "Authorization": {fmt.Sprintf("Bearer %s", rootKey)}, - } - - // Test with non-existent namespace - t.Run("namespace not found", func(t *testing.T) { - t.Skip() - nonExistentNamespace := "nonexistent_namespace" - req := handler.Request{ - Namespace: nonExistentNamespace, - Identifier: "user_123", - Limit: 100, - Duration: 60000, - } - - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) - require.Equal(t, http.StatusNotFound, res.Status) - require.NotNil(t, res.Body) - require.Equal(t, "https://unkey.com/docs/errors/not_found", res.Body.Error.Type) - require.Equal(t, http.StatusNotFound, res.Body.Error.Status) - }) - - // Test with deleted namespace - t.Run("deleted namespace", func(t *testing.T) { - // Create a namespace and then delete it + // Test with soft-deleted namespace - should return 410 + t.Run("soft-deleted namespace returns 410", func(t *testing.T) { + // Create a namespace and then soft-delete it deletedNamespace := uid.New("test") ctx := context.Background() @@ -77,6 +53,14 @@ func TestNamespaceNotFound(t *testing.T) { }) require.NoError(t, err) + // Create root key with permissions to use the namespace + rootKeyWithPerms := h.CreateRootKey(h.Resources().UserWorkspace.ID, fmt.Sprintf("ratelimit.%s.limit", namespaceID)) + + headersWithPerms := http.Header{ + "Content-Type": {"application/json"}, + "Authorization": {fmt.Sprintf("Bearer %s", rootKeyWithPerms)}, + } + req := handler.Request{ Namespace: deletedNamespace, Identifier: "user_123", @@ -84,9 +68,11 @@ func TestNamespaceNotFound(t *testing.T) { Duration: 60000, } - res := testutil.CallRoute[handler.Request, openapi.NotFoundErrorResponse](h, route, headers, req) - require.Equal(t, http.StatusNotFound, res.Status) + // Should return 410 because namespace is soft-deleted + res := testutil.CallRoute[handler.Request, openapi.GoneErrorResponse](h, route, headersWithPerms, req) + require.Equal(t, http.StatusGone, res.Status, "Should return 410 for soft-deleted namespace") require.NotNil(t, res.Body) - require.Equal(t, "https://unkey.com/docs/errors/unkey/data/ratelimit_namespace_not_found", res.Body.Error.Type) + require.Equal(t, "https://unkey.com/docs/errors/unkey/data/ratelimit_namespace_gone", res.Body.Error.Type) + require.Contains(t, res.Body.Error.Detail, "deleted", "Error message should indicate namespace was deleted") }) } diff --git a/go/apps/api/routes/v2_ratelimit_limit/accuracy_test.go b/go/apps/api/routes/v2_ratelimit_limit/accuracy_test.go index d00c456eb5..e0fd9cadee 100644 --- a/go/apps/api/routes/v2_ratelimit_limit/accuracy_test.go +++ b/go/apps/api/routes/v2_ratelimit_limit/accuracy_test.go @@ -63,6 +63,7 @@ func TestRateLimitAccuracy(t *testing.T) { ClickHouse: h.ClickHouse, Ratelimit: h.Ratelimit, RatelimitNamespaceCache: h.Caches.RatelimitNamespace, + Auditlogs: h.Auditlogs, } h.Register(route) ctx := context.Background() diff --git a/go/apps/api/routes/v2_ratelimit_limit/handler.go b/go/apps/api/routes/v2_ratelimit_limit/handler.go index 4802a1a264..79e77cfa9f 100644 --- a/go/apps/api/routes/v2_ratelimit_limit/handler.go +++ b/go/apps/api/routes/v2_ratelimit_limit/handler.go @@ -2,6 +2,7 @@ package v2RatelimitLimit import ( "context" + "database/sql" "encoding/json" "net/http" "strconv" @@ -9,9 +10,11 @@ import ( "time" "github.com/unkeyed/unkey/go/apps/api/openapi" + "github.com/unkeyed/unkey/go/internal/services/auditlogs" "github.com/unkeyed/unkey/go/internal/services/caches" "github.com/unkeyed/unkey/go/internal/services/keys" "github.com/unkeyed/unkey/go/internal/services/ratelimit" + "github.com/unkeyed/unkey/go/pkg/auditlog" "github.com/unkeyed/unkey/go/pkg/cache" "github.com/unkeyed/unkey/go/pkg/clickhouse" "github.com/unkeyed/unkey/go/pkg/clickhouse/schema" @@ -20,7 +23,9 @@ import ( "github.com/unkeyed/unkey/go/pkg/fault" "github.com/unkeyed/unkey/go/pkg/match" "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/uid" "github.com/unkeyed/unkey/go/pkg/zen" ) @@ -29,13 +34,13 @@ type Response = openapi.V2RatelimitLimitResponseBody // Handler implements zen.Route interface for the v2 ratelimit limit endpoint type Handler struct { - // Services as public fields Logger logging.Logger Keys keys.KeyService DB db.Database ClickHouse clickhouse.Bufferer Ratelimit ratelimit.Service RatelimitNamespaceCache cache.Cache[cache.ScopedKey, db.FindRatelimitNamespace] + Auditlogs auditlogs.AuditLogService TestMode bool } @@ -55,7 +60,6 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { s.DisableClickHouseLogging() } - // Authenticate the request with a root key auth, emit, err := h.Keys.GetRootKey(ctx, s) defer emit() if err != nil { @@ -67,87 +71,160 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - cost := int64(1) - if req.Cost != nil { - cost = *req.Cost - } + cacheKey := cache.ScopedKey{WorkspaceID: auth.AuthorizedWorkspaceID, Key: req.Namespace} - // Use the namespace field directly - it can be either name or ID - namespaceKey := req.Namespace + var loader = func(ctx context.Context) (db.FindRatelimitNamespace, error) { + result := db.FindRatelimitNamespace{} // nolint:exhaustruct + response, err := db.WithRetry(func() (db.FindRatelimitNamespaceRow, error) { + return db.Query.FindRatelimitNamespace(ctx, h.DB.RO(), db.FindRatelimitNamespaceParams{ + WorkspaceID: auth.AuthorizedWorkspaceID, + Namespace: req.Namespace, + }) + }) + if err != nil { + return result, err + } - namespace, hit, err := h.RatelimitNamespaceCache.SWR(ctx, - cache.ScopedKey{WorkspaceID: auth.AuthorizedWorkspaceID, Key: namespaceKey}, - func(ctx context.Context) (db.FindRatelimitNamespace, error) { - result := db.FindRatelimitNamespace{} // nolint:exhaustruct + result = db.FindRatelimitNamespace{ + ID: response.ID, + WorkspaceID: response.WorkspaceID, + Name: response.Name, + CreatedAtM: response.CreatedAtM, + UpdatedAtM: response.UpdatedAtM, + DeletedAtM: response.DeletedAtM, + DirectOverrides: make(map[string]db.FindRatelimitNamespaceLimitOverride), + WildcardOverrides: make([]db.FindRatelimitNamespaceLimitOverride, 0), + } - response, err := db.WithRetry(func() (db.FindRatelimitNamespaceRow, error) { - return db.Query.FindRatelimitNamespace(ctx, h.DB.RO(), db.FindRatelimitNamespaceParams{ - WorkspaceID: auth.AuthorizedWorkspaceID, - Namespace: namespaceKey, - }) - }) + overrides := make([]db.FindRatelimitNamespaceLimitOverride, 0) + if overrideBytes, ok := response.Overrides.([]byte); ok && overrideBytes != nil { + err = json.Unmarshal(overrideBytes, &overrides) if err != nil { return result, err } + } - result = db.FindRatelimitNamespace{ - ID: response.ID, - WorkspaceID: response.WorkspaceID, - Name: response.Name, - CreatedAtM: response.CreatedAtM, - UpdatedAtM: response.UpdatedAtM, - DeletedAtM: response.DeletedAtM, - DirectOverrides: make(map[string]db.FindRatelimitNamespaceLimitOverride), - WildcardOverrides: make([]db.FindRatelimitNamespaceLimitOverride, 0), + for _, override := range overrides { + result.DirectOverrides[override.Identifier] = override + if strings.Contains(override.Identifier, "*") { + result.WildcardOverrides = append(result.WildcardOverrides, override) } + } - overrides := make([]db.FindRatelimitNamespaceLimitOverride, 0) - if overrideBytes, ok := response.Overrides.([]byte); ok && overrideBytes != nil { - err = json.Unmarshal(overrideBytes, &overrides) - if err != nil { - return result, err - } + return result, nil + } + + namespace, hit, err := h.RatelimitNamespaceCache.SWR( + ctx, + cacheKey, + loader, + caches.DefaultFindFirstOp, + ) + if err != nil && !db.IsNotFound(err) { + return fault.Wrap(err, + fault.Code(codes.App.Internal.UnexpectedError.URN()), + fault.Public("An unexpected error occurred while fetching the namespace."), + ) + } + + if hit == cache.Null || db.IsNotFound(err) { + err = auth.VerifyRootKey(ctx, keys.WithPermissions( + rbac.T( + rbac.Tuple{ + ResourceType: rbac.Ratelimit, + ResourceID: "*", + Action: rbac.CreateNamespace, + }, + ), + )) + if err != nil { + return err + } + + namespace, err = db.TxWithResult(ctx, h.DB.RW(), func(ctx context.Context, tx db.DBTX) (db.FindRatelimitNamespace, error) { + result := db.FindRatelimitNamespace{} + now := time.Now().UnixMilli() + id := uid.New(uid.RatelimitNamespacePrefix) + + err := db.Query.InsertRatelimitNamespace(ctx, tx, db.InsertRatelimitNamespaceParams{ + ID: id, + WorkspaceID: auth.AuthorizedWorkspaceID, + Name: req.Namespace, + CreatedAt: now, + }) + if err != nil && !db.IsDuplicateKeyError(err) { + return result, fault.Wrap(err, + fault.Code(codes.App.Internal.UnexpectedError.URN()), + fault.Public("An unexpected error occurred while creating the namespace."), + ) } - for _, override := range overrides { - result.DirectOverrides[override.Identifier] = override - if strings.Contains(override.Identifier, "*") { - result.WildcardOverrides = append(result.WildcardOverrides, override) + if db.IsDuplicateKeyError(err) { + namespace, err := loader(ctx) + if err != nil { + return result, fault.Wrap(err, + fault.Code(codes.App.Internal.UnexpectedError.URN()), + fault.Public("An unexpected error occurred while fetching the namespace."), + ) } + + return namespace, err } - return result, nil - }, caches.DefaultFindFirstOp) + result = db.FindRatelimitNamespace{ + ID: id, + WorkspaceID: auth.AuthorizedWorkspaceID, + Name: req.Namespace, + CreatedAtM: now, + UpdatedAtM: sql.NullInt64{Valid: false, Int64: 0}, + DeletedAtM: sql.NullInt64{Valid: false, Int64: 0}, + DirectOverrides: make(map[string]db.FindRatelimitNamespaceLimitOverride), + WildcardOverrides: make([]db.FindRatelimitNamespaceLimitOverride, 0), + } - if err != nil { - if db.IsNotFound(err) { - return fault.New("namespace was deleted", - fault.Code(codes.Data.RatelimitNamespace.NotFound.URN()), - fault.Public("This namespace does not exist."), - ) - } + // Audit log for namespace creation + err = h.Auditlogs.Insert(ctx, tx, []auditlog.AuditLog{ + { + WorkspaceID: auth.AuthorizedWorkspaceID, + Event: auditlog.RatelimitNamespaceCreateEvent, + Display: "Created ratelimit namespace " + req.Namespace, + ActorID: auth.Key.ID, + ActorName: auth.Key.Name.String, + ActorMeta: map[string]any{}, + ActorType: auditlog.RootKeyActor, + RemoteIP: s.Location(), + UserAgent: s.UserAgent(), + Resources: []auditlog.AuditLogResource{ + { + ID: id, + Type: auditlog.RatelimitNamespaceResourceType, + Meta: nil, + Name: req.Namespace, + DisplayName: req.Namespace, + }, + }, + }, + }) + if err != nil { + return result, err + } - return fault.Wrap(err, - fault.Code(codes.App.Internal.UnexpectedError.URN()), - fault.Public("An unexpected error occurred while fetching the namespace."), - ) - } + h.RatelimitNamespaceCache.Set(ctx, cacheKey, result) - if hit == cache.Null { - return fault.New("namespace cache null", - fault.Code(codes.Data.RatelimitNamespace.NotFound.URN()), - fault.Public("This namespace does not exist."), - ) + return result, nil + }) + if err != nil { + return err + } } if namespace.DeletedAtM.Valid { return fault.New("namespace was deleted", - fault.Code(codes.Data.RatelimitNamespace.NotFound.URN()), - fault.Public("This namespace does not exist."), + fault.Code(codes.Data.RatelimitNamespace.Gone.URN()), + fault.Public("This namespace has been deleted. Contact support to restore."), ) } - // Verify permissions for rate limiting err = auth.VerifyRootKey(ctx, keys.WithPermissions(rbac.Or( rbac.T(rbac.Tuple{ ResourceType: rbac.Ratelimit, @@ -164,33 +241,22 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return err } - // Determine limit and duration from override or request - var ( - limit = req.Limit - duration = req.Duration - overrideId = "" - ) - - override, found, err := matchOverride(req.Identifier, namespace) + // Apply override if found, otherwise use request values + limit, duration, overrideId, err := getLimitAndDuration(req, namespace) if err != nil { return fault.Wrap(err, fault.Code(codes.App.Internal.UnexpectedError.URN()), - fault.Internal("error matching overrides"), fault.Public("Error matching ratelimit override"), + fault.Internal("error matching overrides"), + fault.Public("Error matching ratelimit override"), ) } - if found { - limit = override.Limit - duration = override.Duration - overrideId = override.ID - } - // Apply rate limit limitReq := ratelimit.RatelimitRequest{ Identifier: namespace.ID + ":" + req.Identifier, Duration: time.Duration(duration) * time.Millisecond, Limit: limit, - Cost: cost, + Cost: ptr.SafeDeref(req.Cost, 1), Time: time.Time{}, } @@ -246,6 +312,19 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error { return s.JSON(http.StatusOK, res) } +func getLimitAndDuration(req Request, namespace db.FindRatelimitNamespace) (int64, int64, string, error) { + override, found, err := matchOverride(req.Identifier, namespace) + if err != nil { + return 0, 0, "", err + } + + if found { + return override.Limit, override.Duration, override.ID, nil + } + + return req.Limit, req.Duration, "", nil +} + func matchOverride(identifier string, namespace db.FindRatelimitNamespace) (db.FindRatelimitNamespaceLimitOverride, bool, error) { if override, ok := namespace.DirectOverrides[identifier]; ok { return override, true, nil diff --git a/go/demo_api/main.go b/go/demo_api/main.go index cfa33d8d4b..3e7c3a4a0f 100644 --- a/go/demo_api/main.go +++ b/go/demo_api/main.go @@ -617,7 +617,7 @@ info: version: 2.0.0 contact: name: Unkey Support - email: support@unkey.com + email: support@unkey.dev servers: - url: /v2 description: API v2 (BREAKING CHANGES) diff --git a/go/pkg/codes/constants_gen.go b/go/pkg/codes/constants_gen.go index eedae35e25..86b1fe0c2f 100644 --- a/go/pkg/codes/constants_gen.go +++ b/go/pkg/codes/constants_gen.go @@ -84,6 +84,8 @@ const ( // NotFound indicates the requested rate limit namespace was not found. UnkeyDataErrorsRatelimitNamespaceNotFound URN = "err:unkey:data:ratelimit_namespace_not_found" + // Gone indicates the requested rate limit namespace was deleted and is no longer available. + UnkeyDataErrorsRatelimitNamespaceGone URN = "err:unkey:data:ratelimit_namespace_gone" // RatelimitOverride diff --git a/go/pkg/codes/unkey_data.go b/go/pkg/codes/unkey_data.go index 4988f6a15a..5d21739314 100644 --- a/go/pkg/codes/unkey_data.go +++ b/go/pkg/codes/unkey_data.go @@ -48,6 +48,8 @@ type dataKeyAuth struct { type dataRatelimitNamespace struct { // NotFound indicates the requested rate limit namespace was not found. NotFound Code + // Gone indicates the requested rate limit namespace was deleted and is no longer available. + Gone Code } // dataRatelimitOverride defines errors related to rate limit override operations. @@ -119,6 +121,7 @@ var Data = UnkeyDataErrors{ RatelimitNamespace: dataRatelimitNamespace{ NotFound: Code{SystemUnkey, CategoryUnkeyData, "ratelimit_namespace_not_found"}, + Gone: Code{SystemUnkey, CategoryUnkeyData, "ratelimit_namespace_gone"}, }, RatelimitOverride: dataRatelimitOverride{ diff --git a/go/pkg/db/acme_user_update_registered.sql_generated.go b/go/pkg/db/acme_user_update_registered.sql_generated.go deleted file mode 100644 index bcf036a2e8..0000000000 --- a/go/pkg/db/acme_user_update_registered.sql_generated.go +++ /dev/null @@ -1,27 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.27.0 -// source: acme_user_update_registered.sql - -package db - -import ( - "context" -) - -const updateAcmeUserRegistered = `-- name: UpdateAcmeUserRegistered :exec -UPDATE acme_users SET registered = ? WHERE id = ? -` - -type UpdateAcmeUserRegisteredParams struct { - Registered bool `db:"registered"` - ID uint64 `db:"id"` -} - -// UpdateAcmeUserRegistered -// -// UPDATE acme_users SET registered = ? WHERE id = ? -func (q *Queries) UpdateAcmeUserRegistered(ctx context.Context, db DBTX, arg UpdateAcmeUserRegisteredParams) error { - _, err := db.ExecContext(ctx, updateAcmeUserRegistered, arg.Registered, arg.ID) - return err -} diff --git a/go/pkg/db/queries/ratelimit_namespace_find.sql b/go/pkg/db/queries/ratelimit_namespace_find.sql index 6e9220f749..0e92ce1bc6 100644 --- a/go/pkg/db/queries/ratelimit_namespace_find.sql +++ b/go/pkg/db/queries/ratelimit_namespace_find.sql @@ -13,5 +13,5 @@ SELECT *, json_array() ) as overrides FROM `ratelimit_namespaces` ns -WHERE ns.workspace_id = sqlc.arg(workspace_id) +WHERE ns.workspace_id = sqlc.arg(workspace_id) AND (ns.id = sqlc.arg(namespace) OR ns.name = sqlc.arg(namespace)); diff --git a/go/pkg/db/ratelimit_namespace_find.sql_generated.go b/go/pkg/db/ratelimit_namespace_find.sql_generated.go index cac3a0e56d..2b73610f98 100644 --- a/go/pkg/db/ratelimit_namespace_find.sql_generated.go +++ b/go/pkg/db/ratelimit_namespace_find.sql_generated.go @@ -25,7 +25,7 @@ SELECT id, workspace_id, name, created_at_m, updated_at_m, deleted_at_m, json_array() ) as overrides FROM ` + "`" + `ratelimit_namespaces` + "`" + ` ns -WHERE ns.workspace_id = ? +WHERE ns.workspace_id = ? AND (ns.id = ? OR ns.name = ?) ` diff --git a/go/pkg/zen/middleware_errors.go b/go/pkg/zen/middleware_errors.go index ec619cad2c..13b9ede930 100644 --- a/go/pkg/zen/middleware_errors.go +++ b/go/pkg/zen/middleware_errors.go @@ -89,6 +89,20 @@ func WithErrorHandling(logger logging.Logger) Middleware { }, }) + // Request Entity Too Large errors + case codes.UnkeyDataErrorsRatelimitNamespaceGone: + return s.JSON(http.StatusGone, openapi.GoneErrorResponse{ + Meta: openapi.Meta{ + RequestId: s.RequestID(), + }, + Error: openapi.BaseError{ + Title: "Resource Gone", + Type: code.DocsURL(), + Detail: fault.UserFacingMessage(err), + Status: http.StatusGone, + }, + }) + // Unauthorized errors case codes.UnkeyAuthErrorsAuthenticationKeyNotFound: diff --git a/go/pkg/zen/middleware_metrics.go b/go/pkg/zen/middleware_metrics.go index b8aeb72530..a177fad598 100644 --- a/go/pkg/zen/middleware_metrics.go +++ b/go/pkg/zen/middleware_metrics.go @@ -57,10 +57,8 @@ func redact(in []byte) []byte { // route, // ) func WithMetrics(eventBuffer EventBuffer) Middleware { - return func(next HandleFunc) HandleFunc { return func(ctx context.Context, s *Session) error { - start := time.Now() nextErr := next(ctx, s) serviceLatency := time.Since(start)