Skip to content

Commit 288118a

Browse files
committed
fix(server): make an API key's digest name exactly one namespace
POST /api/namespaces/api-key let the caller supply the key's plaintext. The stored primary key was SHA256(plaintext), the uniqueness constraint was the composite (key_digest, namespace_id), and authentication resolved the digest with an unbounded scope and took whichever row came back. So the same digest could exist in two namespaces, and an administrator or owner of any namespace could register a key whose plaintext matched a key in a namespace they had no relationship with and then authenticate as that namespace's key, inheriting its role rather than their own. The unbounded resolve carried its own justification -- "the digest is itself the capability identifying the namespace" -- and that reasoning holds only if a digest can name one namespace. It could not. Which of the two rows won was not deterministic: the query planned as a sequential scan, so the answer followed physical order, which an attacker influences by choosing when to create the colliding key and which a routine key rotation flips on its own. A 50% success rate on an authentication attempt is not a mitigation; the defect is that the two rows are indistinguishable to the authenticator at all. Reproduced end to end against Postgres before the fix: an attacker-registered plaintext resolved to the victim's owner key, at owner role, in a namespace the attacker was 403 for on every other route. Five changes, so that no single one of them is load-bearing: CreateAPIKey.Key is gone. The plaintext is generated server-side and always was for every real caller -- the console never sent the field, and nothing in this repository or in cloud/ did either. A caller can no longer name a secret another namespace may already hold. The field's stated purpose, idempotent provisioning, is the vulnerability restated. Migration 023 puts a unique index on api_keys.key_digest, so the schema enforces what the authenticator assumes and a colliding insert fails regardless of any application check. Rows in a colliding group are deleted, all of them, none elected the survivor: authentication was already picking between them arbitrarily, so no row can be trusted to belong to whoever holds the plaintext, and one may have been planted precisely to collide. Deleting rather than 007's non-destructive demotion is forced by the constraint being added -- two rows sharing a digest cannot both survive an index that forbids it -- and expiring one in place would leave the choice of which to bless, which is the decision the whole bug is that we cannot make. So it is loud instead of silent: the migration RAISEs a warning naming the digest and both namespaces before deleting, because a revoked credential the operator never hears about is an outage with no cause. APIKeyResolve refuses a digest matching more than one row instead of choosing, and AuthAPIKey logs it. A duplicate is a security event, not a lookup outcome. This guard is what covers the window the index cannot: a deployment between upgrade and migration, where the colliding rows still exist. The cached authentication is keyed by a new prefix. AuthAPIKey consults the cache before the store, so an entry written before the index existed -- one that may have resolved a colliding digest into either namespace -- would authenticate for its remaining TTL without ever reaching the ambiguity guard, including after the migration deleted both rows. Changing the key makes those entries unreadable rather than trusted. install_keys gets the same index and the same guard in InstallKeyResolve. installKeyTenant also resolves a digest unbounded; install key plaintexts are server-generated so no collision is reachable, but the invariant is identical and nothing in the schema was holding it. It gets no DELETE: an install_keys row carries install_key_events by ON DELETE CASCADE and devices by ON DELETE SET NULL, so deleting to satisfy a constraint that has never been violated would destroy enrollment history to fix nothing. If a collision somehow exists the migration aborts on the index and the operator is told. The composite primary key (key_digest, namespace_id) stays. It is now implied by the new index, but it is the target of the devices and install_key_events foreign keys, which reference the pair. resolveUnique in pg/utils.go carries the Limit(2)-and-count for both resolvers rather than each holding a copy, and migrationStatements replaces the third copy of read-file-split-on-bun:split. TestScopeIsolationInstallKeyResolve asserted the old behaviour -- it planted one digest in two namespaces and expected each bounded resolve to answer with its own. That premise is what this commit forbids. It keeps both halves of its subject with a digest per namespace: each resolves its own, and neither reaches the other's. The shared digest case is now TestInstallKeyDigestIsGloballyUnique. Reported-by: Edu0x01
1 parent 33e02a3 commit 288118a

18 files changed

Lines changed: 328 additions & 160 deletions

openapi/spec/components/schemas/apiKeyCreate.yaml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,6 @@ properties:
2626
the key can access. It must be less or equal than the user's role. Leave
2727
it blank to use the user's role.
2828
example: owner
29-
key:
30-
type: string
31-
format: uuidv4
32-
description: |
33-
An optional and unique value to be used as the API key's internal identifier. This value
34-
is the "internal ID" and will NEVER be returned to the client. Leave it
35-
blank for a random one to be generated.
36-
example: c629572a-b643-4301-90fe-4572b00d007e
3729
required:
3830
- name
3931
- expires_at

pkg/api/requests/api-key.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@ import (
77

88
// CreateAPIKey is the request to mint an API key. UserID, TenantID and Role come from the
99
// authenticated caller's headers, not from the body: a caller cannot mint a key more powerful than
10-
// itself. Key is optional and lets the caller supply the key's id, which is what makes creation
11-
// idempotent for provisioning.
10+
// itself. The key's plaintext is not a field: the server generates it, so no caller can name a
11+
// secret that another namespace may already hold.
1212
type CreateAPIKey struct {
1313
UserID string `header:"X-ID"`
1414
TenantID string `header:"X-Tenant-ID"`
1515
Role authorizer.Role `header:"X-Role"`
1616
Name string `json:"name" validate:"required,api-key_name"`
1717
ExpiresAt int `json:"expires_at" validate:"required,api-key_expires-at"`
18-
Key string `json:"key" validate:"omitempty,uuid"`
1918
OptRole authorizer.Role `json:"role" validate:"omitempty,member_role"`
2019
}
2120

server/api/routes/api-key_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ func TestCreateAPIKey(t *testing.T) {
212212
},
213213
},
214214
{
215-
description: "succeeds with optional body",
215+
description: "ignores a caller-supplied key in the body",
216216
headers: map[string]string{
217217
"Content-Type": "application/json",
218218
"X-ID": "000000000000000000000000",
@@ -235,7 +235,6 @@ func TestCreateAPIKey(t *testing.T) {
235235
Name: "dev",
236236
Role: "owner",
237237
ExpiresAt: 30,
238-
Key: "3d7a3ea1-d1eb-4ffc-8c14-f7bfd1b7c550",
239238
OptRole: "administrator",
240239
}).
241240
Return(&responses.CreateAPIKey{}, nil).

server/api/services/api-key.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ var APIKeySortFields = query.NewFieldSet(
2828
// APIKeyService manages the keys that authenticate a namespace rather than a person. A key's
2929
// plaintext is returned once, at creation, and only its hash is kept.
3030
type APIKeyService interface {
31-
// CreateAPIKey creates a new API key for the specified namespace. If req.Key is empty it will generate a
32-
// random UUID, the optional req.OptRole must be less or equal than the user's role when provided. The key
33-
// will be hashed into an SHA256 hash. It returns the inserted UUID and an error, if any.
31+
// CreateAPIKey creates a new API key for the specified namespace. The key's plaintext is a UUID the
32+
// server generates; the optional req.OptRole must be less or equal than the user's role when provided.
33+
// Only the plaintext's SHA256 digest is stored. It returns the generated plaintext, which is the only
34+
// time it is readable, and an error, if any.
3435
CreateAPIKey(ctx context.Context, req *requests.CreateAPIKey) (res *responses.CreateAPIKey, err error)
3536

3637
// ListAPIKeys retrieves a list of API keys within the specified tenant ID. It returns the list of API keys, the
@@ -72,10 +73,6 @@ func (s *service) CreateAPIKey(ctx context.Context, req *requests.CreateAPIKey)
7273
return nil, NewErrBadRequest(errors.New("experid date to APIKey is invalid"))
7374
}
7475

75-
if req.Key == "" {
76-
req.Key = uuid.Generate()
77-
}
78-
7976
if req.OptRole != "" {
8077
if !req.Role.HasAuthority(req.OptRole) {
8178
return nil, NewErrRoleForbidden()
@@ -84,7 +81,8 @@ func (s *service) CreateAPIKey(ctx context.Context, req *requests.CreateAPIKey)
8481
req.Role = req.OptRole
8582
}
8683

87-
keySum := sha256.Sum256([]byte(req.Key))
84+
plaintext := uuid.Generate()
85+
keySum := sha256.Sum256([]byte(plaintext))
8886
hashedKey := hex.EncodeToString(keySum[:])
8987

9088
if conflicts, has, _ := s.store.APIKeyConflicts(ctx, sc, &models.APIKeyConflicts{ID: hashedKey, Name: req.Name}); has {
@@ -104,8 +102,12 @@ func (s *service) CreateAPIKey(ctx context.Context, req *requests.CreateAPIKey)
104102
return nil, err
105103
}
106104

107-
apiKey, _ := s.store.APIKeyResolve(ctx, sc, store.APIKeyIDResolver, hashedKey)
108-
apiKey.ID = req.Key
105+
apiKey, err := s.store.APIKeyResolve(ctx, sc, store.APIKeyIDResolver, hashedKey)
106+
if err != nil {
107+
return nil, err
108+
}
109+
110+
apiKey.ID = plaintext
109111

110112
return responses.CreateAPIKeyFromModel(apiKey), nil
111113
}

server/api/services/api-key_test.go

Lines changed: 20 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ func TestCreateAPIKey(t *testing.T) {
3131

3232
storeMock := storemock.NewMockStore(t)
3333

34+
prevUUID := uuid.DefaultBackend
35+
defer func() { uuid.DefaultBackend = prevUUID }()
36+
3437
cases := []struct {
3538
description string
3639
req *requests.CreateAPIKey
@@ -43,7 +46,6 @@ func TestCreateAPIKey(t *testing.T) {
4346
UserID: "000000000000000000000000",
4447
TenantID: "00000000-0000-4000-0000-000000000000",
4548
Role: "owner",
46-
Key: "cdfd3cb0-c44e-4e54-b931-6d57713ad159",
4749
Name: "dev",
4850
ExpiresAt: -1,
4951
},
@@ -64,7 +66,6 @@ func TestCreateAPIKey(t *testing.T) {
6466
UserID: "000000000000000000000000",
6567
TenantID: "00000000-0000-4000-0000-000000000000",
6668
Role: "owner",
67-
Key: "cdfd3cb0-c44e-4e54-b931-6d57713ad159",
6869
Name: "dev",
6970
ExpiresAt: 2,
7071
},
@@ -98,7 +99,6 @@ func TestCreateAPIKey(t *testing.T) {
9899
UserID: "000000000000000000000000",
99100
TenantID: "00000000-0000-4000-0000-000000000000",
100101
Role: "administrator",
101-
Key: "cdfd3cb0-c44e-4e54-b931-6d57713ad159",
102102
Name: "dev",
103103
ExpiresAt: -1,
104104
OptRole: "owner",
@@ -133,7 +133,6 @@ func TestCreateAPIKey(t *testing.T) {
133133
UserID: "000000000000000000000000",
134134
TenantID: "00000000-0000-4000-0000-000000000000",
135135
Role: "owner",
136-
Key: "cdfd3cb0-c44e-4e54-b931-6d57713ad159",
137136
Name: "dev",
138137
ExpiresAt: -1,
139138
},
@@ -156,6 +155,13 @@ func TestCreateAPIKey(t *testing.T) {
156155
).
157156
Once()
158157

158+
uuidMock := uuidmock.NewMockUUID(t)
159+
uuid.DefaultBackend = uuidMock
160+
uuidMock.
161+
On("Generate").
162+
Return("cdfd3cb0-c44e-4e54-b931-6d57713ad159").
163+
Once()
164+
159165
keySum := sha256.Sum256([]byte("cdfd3cb0-c44e-4e54-b931-6d57713ad159"))
160166
hashedKey := hex.EncodeToString(keySum[:])
161167

@@ -175,7 +181,6 @@ func TestCreateAPIKey(t *testing.T) {
175181
UserID: "000000000000000000000000",
176182
TenantID: "00000000-0000-4000-0000-000000000000",
177183
Role: "owner",
178-
Key: "cdfd3cb0-c44e-4e54-b931-6d57713ad159",
179184
Name: "dev",
180185
ExpiresAt: -1,
181186
},
@@ -198,6 +203,13 @@ func TestCreateAPIKey(t *testing.T) {
198203
).
199204
Once()
200205

206+
uuidMock := uuidmock.NewMockUUID(t)
207+
uuid.DefaultBackend = uuidMock
208+
uuidMock.
209+
On("Generate").
210+
Return("cdfd3cb0-c44e-4e54-b931-6d57713ad159").
211+
Once()
212+
201213
keySum := sha256.Sum256([]byte("cdfd3cb0-c44e-4e54-b931-6d57713ad159"))
202214
hashedKey := hex.EncodeToString(keySum[:])
203215

@@ -228,78 +240,6 @@ func TestCreateAPIKey(t *testing.T) {
228240
UserID: "000000000000000000000000",
229241
TenantID: "00000000-0000-4000-0000-000000000000",
230242
Role: "owner",
231-
Key: "cdfd3cb0-c44e-4e54-b931-6d57713ad159",
232-
Name: "dev",
233-
ExpiresAt: -1,
234-
},
235-
requiredMocks: func(ctx context.Context) {
236-
storeMock.
237-
On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000").
238-
Return(
239-
&models.Namespace{
240-
Name: "namespace",
241-
Owner: "000000000000000000000000",
242-
TenantID: "00000000-0000-4000-0000-000000000000",
243-
Members: []models.Member{
244-
{
245-
ID: "000000000000000000000000",
246-
Role: "owner",
247-
},
248-
},
249-
},
250-
nil,
251-
).
252-
Once()
253-
254-
keySum := sha256.Sum256([]byte("cdfd3cb0-c44e-4e54-b931-6d57713ad159"))
255-
hashedKey := hex.EncodeToString(keySum[:])
256-
257-
storeMock.
258-
On("APIKeyConflicts", ctx, scope.MustBounded("00000000-0000-4000-0000-000000000000"), &models.APIKeyConflicts{ID: hashedKey, Name: "dev"}).
259-
Return([]string{}, false, nil).
260-
Once()
261-
storeMock.
262-
On("APIKeyCreate", ctx, &models.APIKey{
263-
ID: hashedKey,
264-
Name: "dev",
265-
CreatedBy: "000000000000000000000000",
266-
TenantID: "00000000-0000-4000-0000-000000000000",
267-
Role: "owner",
268-
ExpiresIn: -1,
269-
}).
270-
Return(hashedKey, nil).
271-
Once()
272-
storeMock.
273-
On("APIKeyResolve", ctx, mock.Anything, store.APIKeyIDResolver, hashedKey).
274-
Return(&models.APIKey{
275-
ID: hashedKey,
276-
Name: "dev",
277-
CreatedBy: "000000000000000000000000",
278-
TenantID: "00000000-0000-4000-0000-000000000000",
279-
Role: "owner",
280-
ExpiresIn: -1,
281-
}, nil).
282-
Once()
283-
},
284-
expected: Expected{
285-
res: &responses.CreateAPIKey{
286-
ID: "cdfd3cb0-c44e-4e54-b931-6d57713ad159",
287-
Name: "dev",
288-
UserID: "000000000000000000000000",
289-
TenantID: "00000000-0000-4000-0000-000000000000",
290-
Role: "owner",
291-
ExpiresIn: -1,
292-
},
293-
err: nil,
294-
},
295-
},
296-
{
297-
description: "succeeds when request key is empty",
298-
req: &requests.CreateAPIKey{
299-
UserID: "000000000000000000000000",
300-
TenantID: "00000000-0000-4000-0000-000000000000",
301-
Role: "owner",
302-
Key: "",
303243
Name: "dev",
304244
ExpiresAt: -1,
305245
},
@@ -326,10 +266,10 @@ func TestCreateAPIKey(t *testing.T) {
326266
uuid.DefaultBackend = uuidMock
327267
uuidMock.
328268
On("Generate").
329-
Return("1e7b0f4b-aca4-48eb-a353-7469f00665ed").
269+
Return("cdfd3cb0-c44e-4e54-b931-6d57713ad159").
330270
Once()
331271

332-
keySum := sha256.Sum256([]byte("1e7b0f4b-aca4-48eb-a353-7469f00665ed"))
272+
keySum := sha256.Sum256([]byte("cdfd3cb0-c44e-4e54-b931-6d57713ad159"))
333273
hashedKey := hex.EncodeToString(keySum[:])
334274

335275
storeMock.
@@ -361,7 +301,7 @@ func TestCreateAPIKey(t *testing.T) {
361301
},
362302
expected: Expected{
363303
res: &responses.CreateAPIKey{
364-
ID: "1e7b0f4b-aca4-48eb-a353-7469f00665ed",
304+
ID: "cdfd3cb0-c44e-4e54-b931-6d57713ad159",
365305
Name: "dev",
366306
UserID: "000000000000000000000000",
367307
TenantID: "00000000-0000-4000-0000-000000000000",

server/api/services/auth.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ func (s *service) enrollmentInstallKey(ctx context.Context, sc scope.Scope, req
185185
func (s *service) installKeyTenant(ctx context.Context, installKey string) (string, error) {
186186
sk, err := s.store.InstallKeyResolve(ctx, scope.NewUnbounded(reasonInstallKeyTenant), store.InstallKeyIDResolver, hashInstallKey(installKey))
187187
if err != nil || sk.IsSystem() {
188+
if errors.Is(err, store.ErrAmbiguous) {
189+
log.WithError(err).Error("an install key digest resolved to more than one namespace; refusing to enroll with it")
190+
}
191+
188192
return "", NewErrAuthInvalid(map[string]any{"install_key": "invalid"}, err)
189193
}
190194

@@ -664,10 +668,15 @@ func (s *service) CreateUserToken(ctx context.Context, req *requests.CreateUserT
664668
// apiKeyCacheTTL bounds how long AuthAPIKey serves a key from the cache when nothing revokes it first.
665669
const apiKeyCacheTTL = 2 * time.Minute
666670

667-
// apiKeyCacheKey names the cache entry of the API key with the given digest. The digest is what every
668-
// mutation of the key resolves, so an entry can be dropped without holding the plaintext.
671+
// apiKeyCacheKey namespaces a cached API key authentication by its digest and by the invariant it was
672+
// resolved under. The digest is what every mutation of the key resolves, so an entry can be dropped
673+
// without holding the plaintext. The generation prefix makes an entry written before
674+
// api_keys_key_digest_unique existed unreadable rather than trusted: such an entry may have resolved a
675+
// colliding digest into either of two namespaces, and the cache is consulted ahead of the store, so the
676+
// ambiguity guard in APIKeyResolve would never see it. Changing the key is what stops a pre-upgrade
677+
// collision authenticating past the migration that revoked it.
669678
func apiKeyCacheKey(digest string) string {
670-
return "api-key={" + digest + "}"
679+
return "api-key/unique-digest={" + digest + "}"
671680
}
672681

673682
func (s *service) AuthAPIKey(ctx context.Context, key string) (*models.APIKey, error) {
@@ -682,8 +691,12 @@ func (s *service) AuthAPIKey(ctx context.Context, key string) (*models.APIKey, e
682691
fromCache := apiKey.ID != ""
683692
if !fromCache {
684693
var err error
685-
sc := scope.NewUnbounded("authenticating an API key by its digest, which is itself the capability identifying the namespace")
694+
sc := scope.NewUnbounded("authenticating an API key by its digest, which api_keys_key_digest_unique makes name exactly one namespace")
686695
if apiKey, err = s.store.APIKeyResolve(ctx, sc, store.APIKeyIDResolver, digest); err != nil {
696+
if errors.Is(err, store.ErrAmbiguous) {
697+
log.WithError(err).Error("an API key digest resolved to more than one namespace; refusing to authenticate it")
698+
}
699+
687700
return nil, NewErrAPIKeyNotFound("", err)
688701
}
689702
}

server/api/services/auth_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2635,7 +2635,7 @@ func TestCreateUserToken(t *testing.T) {
26352635
const (
26362636
testKeyPlaintext = "00000000-0000-4000-0000-000000000000"
26372637
testKeyDigest = "f23a2e56cd3fcfba002c72675c870e1e7813292adc40bbf14cea479a2e07976a"
2638-
testKeyCacheEntry = "api-key={f23a2e56cd3fcfba002c72675c870e1e7813292adc40bbf14cea479a2e07976a}"
2638+
testKeyCacheEntry = "api-key/unique-digest={f23a2e56cd3fcfba002c72675c870e1e7813292adc40bbf14cea479a2e07976a}"
26392639
)
26402640

26412641
func TestAuthAPIKey(t *testing.T) {

server/api/services/scope.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66

77
const reasonInternalSessionMutation = "internal SSH-driven session mutation: no namespace exists anywhere in the call chain yet; bounding it changes the SSH-facing contract (see #6749)"
88

9-
const reasonInstallKeyTenant = "enrolling with an install key alone, whose digest is itself the capability identifying the namespace"
9+
const reasonInstallKeyTenant = "enrolling with an install key alone, whose digest install_keys_key_digest_unique makes name exactly one namespace"
1010

1111
// BoundTo bounds an operation to the tenant a request carries. An absent tenant refuses the request
1212
// rather than widening it to every namespace, matching the tenant-guard middleware's fail-closed

server/api/store/errors.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ var (
3434
// bound to one (systems.instance_tenant_id set — Community). Enterprise/Cloud never bind, so
3535
// this is Community-specific and distinct from a plain duplicate-name conflict.
3636
ErrNamespaceSingle = errors.New("instance does not support multi-tenancy", ErrLayer, ErrCodeConstraint)
37+
// ErrAmbiguous is returned when a resolver that must identify a single row matched more than
38+
// one. For a credential digest resolved without a namespace this is a security event rather
39+
// than a lookup outcome: the store cannot tell which namespace the presented secret belongs
40+
// to, so it refuses instead of choosing.
41+
ErrAmbiguous = errors.New("resolver matched more than one document", ErrLayer, ErrCodeConstraint)
3742
// ErrInvalidScope is returned when a namespace-bound operation is given a scope that was never
3843
// constructed. It catches a zero-value [scope.Scope] reaching the store, which would otherwise
3944
// read as neither bounded nor deliberately unbounded.

server/api/store/pg/api-key.go

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"github.com/shellhub-io/shellhub/pkg/models"
99
"github.com/shellhub-io/shellhub/server/api/store"
1010
"github.com/shellhub-io/shellhub/server/api/store/pg/entity"
11-
"github.com/uptrace/bun"
1211
)
1312

1413
// APIKeyCreate implements [store.APIKeyStore].
@@ -102,25 +101,17 @@ func (pg *Pg) APIKeyList(ctx context.Context, sc scope.Scope, opts ...store.Quer
102101

103102
// APIKeyResolve implements [store.APIKeyStore].
104103
func (pg *Pg) APIKeyResolve(ctx context.Context, sc scope.Scope, resolver store.APIKeyResolver, val string, opts ...store.QueryOption) (*models.APIKey, error) {
105-
db := pg.GetConnection(ctx)
106-
107104
column, err := APIKeyResolverToString(resolver)
108105
if err != nil {
109106
return nil, err
110107
}
111108

112-
apKey := new(entity.APIKey)
113-
query := db.NewSelect().Model(apKey).Where("? = ?", bun.Ident(column), val)
114-
query, err = applyScopedOptions(ctx, query, sc, opts...)
109+
apiKey, err := resolveUnique[entity.APIKey](ctx, pg.GetConnection(ctx), sc, column, val, opts...)
115110
if err != nil {
116111
return nil, err
117112
}
118113

119-
if err = query.Scan(ctx); err != nil {
120-
return nil, fromSQLError(err)
121-
}
122-
123-
return entity.APIKeyToModel(apKey), nil
114+
return entity.APIKeyToModel(apiKey), nil
124115
}
125116

126117
// APIKeyUpdate implements [store.APIKeyStore].

0 commit comments

Comments
 (0)