WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions go/apps/api/openapi/openapi-generated.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5446,7 +5446,7 @@ paths:
- `api.*.verify_key` (verify keys in any API)
- `api.<api_id>.verify_key` (verify keys in specific API)

If you are getting a NOT_FOUND error, ensure your root key has the required verify key permissions.
**Note**: If your root key has no verify permissions at all, you will receive a `403 Forbidden` error. If your root key has verify permissions for a different API than the key you're verifying, you will receive a `200` response with `code: NOT_FOUND` to avoid leaking key existence.
operationId: verifyKey
requestBody:
content:
Expand Down Expand Up @@ -5500,7 +5500,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ForbiddenErrorResponse'
description: Forbidden
description: |
Forbidden. Returned when the root key has no verify permissions at all (neither `api.*.verify_key` nor `api.<api_id>.verify_key` for any API).
"404":
content:
application/json:
Expand Down
5 changes: 3 additions & 2 deletions go/apps/api/openapi/spec/paths/v2/keys/verifyKey/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ post:
- `api.*.verify_key` (verify keys in any API)
- `api.<api_id>.verify_key` (verify keys in specific API)

If you are getting a NOT_FOUND error, ensure your root key has the required verify key permissions.
**Note**: If your root key has no verify permissions at all, you will receive a `403 Forbidden` error. If your root key has verify permissions for a different API than the key you're verifying, you will receive a `200` response with `code: NOT_FOUND` to avoid leaking key existence.
operationId: verifyKey
x-speakeasy-name-override: verifyKey
security:
Expand Down Expand Up @@ -73,7 +73,8 @@ post:
schema:
"$ref": "../../../../error/UnauthorizedErrorResponse.yaml"
"403":
description: Forbidden
description: |
Forbidden. Returned when the root key has no verify permissions at all (neither `api.*.verify_key` nor `api.<api_id>.verify_key` for any API).
content:
application/json:
schema:
Expand Down
24 changes: 0 additions & 24 deletions go/apps/api/routes/v2_keys_verify_key/200_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -707,28 +707,4 @@ func TestSuccess(t *testing.T) {
require.Equal(t, openapi.NOTFOUND, res.Body.Data.Code, "Key should be not found but got %s", res.Body.Data.Code)
require.False(t, res.Body.Data.Valid, "Key should be invalid but got %t", res.Body.Data.Valid)
})

key := h.CreateKey(seed.CreateKeyRequest{
WorkspaceID: workspace.ID,
KeySpaceID: api.KeyAuthID.String,
})

t.Run("root key without sufficient permissions", func(t *testing.T) {
// Create root key with insufficient permissions
limitedRootKey := h.CreateRootKey(workspace.ID, "api.*.read") // Wrong permission

req := handler.Request{
Key: key.Key,
}

headers := http.Header{
"Content-Type": {"application/json"},
"Authorization": {fmt.Sprintf("Bearer %s", limitedRootKey)},
}

res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req)
require.Equal(t, 200, res.Status)
require.NotNil(t, res.Body)
require.Equal(t, openapi.NOTFOUND, res.Body.Data.Code, "Key should be not found but got %s", res.Body.Data.Code)
})
}
123 changes: 123 additions & 0 deletions go/apps/api/routes/v2_keys_verify_key/403_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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_verify_key"
"github.com/unkeyed/unkey/go/pkg/testutil"
"github.com/unkeyed/unkey/go/pkg/testutil/seed"
)

func TestForbidden_NoVerifyPermissions(t *testing.T) {
h := testutil.NewHarness(t)

route := &handler.Handler{
DB: h.DB,
Keys: h.Keys,
Logger: h.Logger,
Auditlogs: h.Auditlogs,
ClickHouse: h.ClickHouse,
}

h.Register(route)

workspace := h.Resources().UserWorkspace
api := h.CreateApi(seed.CreateApiRequest{WorkspaceID: workspace.ID})
key := h.CreateKey(seed.CreateKeyRequest{
WorkspaceID: workspace.ID,
KeySpaceID: api.KeyAuthID.String,
})

t.Run("root key with no verify permissions returns 403", func(t *testing.T) {
// Create root key with a permission that is NOT verify_key
rootKeyWithoutVerify := h.CreateRootKey(workspace.ID, "api.*.read_key")

req := handler.Request{
Key: key.Key,
}

headers := http.Header{
"Content-Type": {"application/json"},
"Authorization": {fmt.Sprintf("Bearer %s", rootKeyWithoutVerify)},
}

res := testutil.CallRoute[handler.Request, openapi.ForbiddenErrorResponse](h, route, headers, req)
require.Equal(t, 403, res.Status, "expected 403, received: %d", res.Status)
require.NotNil(t, res.Body)
require.NotNil(t, res.Body.Error)
require.NotEmpty(t, res.Body.Meta.RequestId, "RequestId should be returned in error response")

// Verify the error message mentions both permission options
require.Contains(t, res.Body.Error.Detail, "api.*.verify_key", "error should mention wildcard permission option")
require.Contains(t, res.Body.Error.Detail, "api.<API_ID>.verify_key", "error should mention specific API permission option")
})

t.Run("root key with verify permission for different api returns 200 NOT_FOUND (not 403)", func(t *testing.T) {
// Create a second API
api2 := h.CreateApi(seed.CreateApiRequest{WorkspaceID: workspace.ID})

// Create root key with verify permission for api2 only
rootKeyForApi2 := h.CreateRootKey(workspace.ID, fmt.Sprintf("api.%s.verify_key", api2.ID))

// Try to verify a key from api1
req := handler.Request{
Key: key.Key,
}

headers := http.Header{
"Content-Type": {"application/json"},
"Authorization": {fmt.Sprintf("Bearer %s", rootKeyForApi2)},
}

// Should return 200 with NOT_FOUND (not 403) to avoid leaking key existence
res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req)
require.Equal(t, 200, res.Status, "expected 200, received: %d", res.Status)
require.NotNil(t, res.Body)
require.Equal(t, openapi.NOTFOUND, res.Body.Data.Code, "should return NOT_FOUND to avoid leaking key existence")
require.False(t, res.Body.Data.Valid)
})

t.Run("root key with wildcard verify permission returns 200 VALID", func(t *testing.T) {
// Create root key with wildcard verify permission
rootKeyWithVerify := h.CreateRootKey(workspace.ID, "api.*.verify_key")

req := handler.Request{
Key: key.Key,
}

headers := http.Header{
"Content-Type": {"application/json"},
"Authorization": {fmt.Sprintf("Bearer %s", rootKeyWithVerify)},
}

res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req)
require.Equal(t, 200, res.Status, "expected 200, received: %d", res.Status)
require.NotNil(t, res.Body)
require.Equal(t, openapi.VALID, res.Body.Data.Code)
require.True(t, res.Body.Data.Valid)
})

t.Run("root key with specific api verify permission returns 200 VALID", func(t *testing.T) {
// Create root key with specific API verify permission
rootKeyWithSpecificVerify := h.CreateRootKey(workspace.ID, fmt.Sprintf("api.%s.verify_key", api.ID))

req := handler.Request{
Key: key.Key,
}

headers := http.Header{
"Content-Type": {"application/json"},
"Authorization": {fmt.Sprintf("Bearer %s", rootKeyWithSpecificVerify)},
}

res := testutil.CallRoute[handler.Request, handler.Response](h, route, headers, req)
require.Equal(t, 200, res.Status, "expected 200, received: %d", res.Status)
require.NotNil(t, res.Body)
require.Equal(t, openapi.VALID, res.Body.Data.Code)
require.True(t, res.Body.Data.Valid)
})
}
18 changes: 18 additions & 0 deletions go/apps/api/routes/v2_keys_verify_key/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ func (h *Handler) Handle(ctx context.Context, s *zen.Session) error {
return err
}

// Check if the root key has ANY verify permissions at all.
// If not, return a proper permissions error immediately without looking up the key.
// This prevents returning NOT_FOUND for every request when the root key simply lacks verify permissions entirely.
if !auth.HasAnyPermission(rbac.Api, rbac.VerifyKey) {
return auth.VerifyRootKey(ctx, keys.WithPermissions(rbac.Or(
rbac.T(rbac.Tuple{
ResourceType: rbac.Api,
ResourceID: "*",
Action: rbac.VerifyKey,
}),
rbac.T(rbac.Tuple{
ResourceType: rbac.Api,
ResourceID: "<API_ID>",
Action: rbac.VerifyKey,
}),
)))
}

// Request validation
req, err := zen.BindBody[Request](s)
if err != nil {
Expand Down
17 changes: 17 additions & 0 deletions go/internal/services/keys/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"

"github.com/unkeyed/unkey/go/apps/api/openapi"
Expand Down Expand Up @@ -76,6 +77,22 @@ func (k *KeyVerifier) withIPWhitelist() error {
return nil
}

// HasAnyPermission checks if the key has any permission matching the given action.
// It returns true if the key has at least one permission that ends with the specified action
// for the given resource type (e.g., checking for any "api.*.verify_key" or "api.{apiId}.verify_key").
func (k *KeyVerifier) HasAnyPermission(resourceType rbac.ResourceType, action rbac.ActionType) bool {
prefix := string(resourceType) + "."
suffix := "." + string(action)

for _, perm := range k.Permissions {
if strings.HasPrefix(perm, prefix) && strings.HasSuffix(perm, suffix) {
return true
}
}

return false
}

// withPermissions validates that the key has the required RBAC permissions.
// It uses the configured RBAC system to evaluate the permission query against the key's permissions.
func (k *KeyVerifier) withPermissions(ctx context.Context, query rbac.PermissionQuery) error {
Expand Down