feat: add AI Gateway coderd key CRUD endpoints (#25565)

Adds create, list and delete endpoints for AI Gateway keys.
Those keys are used to authenticate into Coderd.
All endpoints require Owner permission.
This commit is contained in:
Paweł Banaszewski
2026-06-03 13:50:33 +02:00
committed by GitHub
parent 6ef687cdfb
commit 96e3a64b12
12 changed files with 1281 additions and 19 deletions
+43
View File
@@ -0,0 +1,43 @@
package keys
import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/database"
)
const (
privateSuffixLength = 32
// KeyPrefixLength is the total length of the visible key prefix.
KeyPrefixLength = 11
// KeyLength is the total length of the plaintext key returned to
// the user on Create.
KeyLength = KeyPrefixLength + privateSuffixLength
)
// New generates an AI Gateway key used for authenticating standalone replicas.
// Returns InsertParams ready for the database query.
func New(name string) (database.InsertAIGatewayKeyParams, string, error) {
secret, hashed, err := apikey.GenerateSecret(KeyLength)
if err != nil {
return database.InsertAIGatewayKeyParams{}, "", xerrors.Errorf("generate secret: %w", err)
}
if len(secret) != KeyLength {
return database.InsertAIGatewayKeyParams{}, "", xerrors.Errorf("generated secret has unexpected length: got %d, want %d", len(secret), KeyLength)
}
if KeyLength < KeyPrefixLength {
return database.InsertAIGatewayKeyParams{}, "", xerrors.Errorf("KeyLength (%d) must be >= KeyPrefixLength (%d)", KeyLength, KeyPrefixLength)
}
visiblePrefix := secret[:KeyPrefixLength]
return database.InsertAIGatewayKeyParams{
ID: uuid.New(),
Name: name,
SecretPrefix: visiblePrefix,
HashedSecret: hashed,
}, secret, nil
}
+22
View File
@@ -0,0 +1,22 @@
package keys_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/aibridge/keys"
"github.com/coder/coder/v2/coderd/apikey"
)
func TestNew(t *testing.T) {
t.Parallel()
params, key, err := keys.New("test-key")
require.NoError(t, err)
require.Len(t, key, keys.KeyLength)
require.Len(t, params.SecretPrefix, keys.KeyPrefixLength)
require.Equal(t, key[:keys.KeyPrefixLength], params.SecretPrefix)
require.True(t, apikey.ValidateHash(params.HashedSecret, key))
require.False(t, apikey.ValidateHash(params.HashedSecret, key[keys.KeyPrefixLength:]))
}
+150
View File
@@ -1474,6 +1474,100 @@ const docTemplate = `{
]
}
},
"/api/v2/aibridge/keys": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "List AI Gateway keys",
"operationId": "list-ai-gateway-keys",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIGatewayKey"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Create AI Gateway key",
"operationId": "create-ai-gateway-key",
"parameters": [
{
"description": "Create AI Gateway key request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/aibridge/keys/{key}": {
"delete": {
"tags": [
"Enterprise"
],
"summary": "Delete AI Gateway key",
"operationId": "delete-ai-gateway-key",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Key ID",
"name": "key",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/aibridge/models": {
"get": {
"produces": [
@@ -15048,6 +15142,29 @@ const docTemplate = `{
}
}
},
"codersdk.AIGatewayKey": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"key_prefix": {
"type": "string"
},
"last_used_at": {
"type": "string",
"format": "date-time"
},
"name": {
"type": "string"
}
}
},
"codersdk.AIProvider": {
"type": "object",
"properties": {
@@ -17582,6 +17699,39 @@ const docTemplate = `{
}
}
},
"codersdk.CreateAIGatewayKeyRequest": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
}
}
},
"codersdk.CreateAIGatewayKeyResponse": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"key": {
"type": "string"
},
"key_prefix": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"codersdk.CreateAIProviderRequest": {
"type": "object",
"properties": {
+136
View File
@@ -1303,6 +1303,88 @@
]
}
},
"/api/v2/aibridge/keys": {
"get": {
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "List AI Gateway keys",
"operationId": "list-ai-gateway-keys",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.AIGatewayKey"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
},
"post": {
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Create AI Gateway key",
"operationId": "create-ai-gateway-key",
"parameters": [
{
"description": "Create AI Gateway key request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.CreateAIGatewayKeyRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/codersdk.CreateAIGatewayKeyResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/aibridge/keys/{key}": {
"delete": {
"tags": ["Enterprise"],
"summary": "Delete AI Gateway key",
"operationId": "delete-ai-gateway-key",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Key ID",
"name": "key",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/api/v2/aibridge/models": {
"get": {
"produces": ["application/json"],
@@ -13440,6 +13522,29 @@
}
}
},
"codersdk.AIGatewayKey": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"key_prefix": {
"type": "string"
},
"last_used_at": {
"type": "string",
"format": "date-time"
},
"name": {
"type": "string"
}
}
},
"codersdk.AIProvider": {
"type": "object",
"properties": {
@@ -15887,6 +15992,37 @@
}
}
},
"codersdk.CreateAIGatewayKeyRequest": {
"type": "object",
"required": ["name"],
"properties": {
"name": {
"type": "string"
}
}
},
"codersdk.CreateAIGatewayKeyResponse": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"id": {
"type": "string",
"format": "uuid"
},
"key": {
"type": "string"
},
"key_prefix": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"codersdk.CreateAIProviderRequest": {
"type": "object",
"properties": {
+19 -19
View File
@@ -14740,13 +14740,13 @@ func TestAIGatewayKeysTableConstraints(t *testing.T) {
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)
preExsiting := database.InsertAIGatewayKeyParams{
preExisting := database.InsertAIGatewayKeyParams{
ID: uuid.New(),
Name: "name",
SecretPrefix: "cgw_test__1",
SecretPrefix: "key_test__1",
HashedSecret: []byte("first-secret"),
}
_, err := db.InsertAIGatewayKey(ctx, preExsiting)
_, err := db.InsertAIGatewayKey(ctx, preExisting)
require.NoError(t, err)
tests := []struct {
@@ -14757,67 +14757,67 @@ func TestAIGatewayKeysTableConstraints(t *testing.T) {
}{
{
name: "duplicate name",
params: aiGatewayKeyParams(preExsiting.Name, "cgw_test002"),
params: aiGatewayKeyParams(preExisting.Name, "key_test002"),
expectUniqueErr: database.UniqueAiGatewayKeysNameIndex,
},
{
name: "duplicate secret prefix",
params: aiGatewayKeyParams("different-key", preExsiting.SecretPrefix),
params: aiGatewayKeyParams("different-key", preExisting.SecretPrefix),
expectUniqueErr: database.UniqueAiGatewayKeysSecretPrefixIndex,
},
{
name: "duplicate hashed secret",
params: database.InsertAIGatewayKeyParams{ID: uuid.New(), Name: "other-name", SecretPrefix: "cgw_1234567", HashedSecret: preExsiting.HashedSecret},
params: database.InsertAIGatewayKeyParams{ID: uuid.New(), Name: "other-name", SecretPrefix: "key_1234567", HashedSecret: preExisting.HashedSecret},
expectUniqueErr: database.UniqueAiGatewayKeysHashedSecretIndex,
},
{
name: "empty name",
params: aiGatewayKeyParams("", "cgw_1234567"),
params: aiGatewayKeyParams("", "key_empty__"),
expectCheckErr: database.CheckAiGatewayKeysNameCheck,
},
{
name: "name with trailing dash",
params: aiGatewayKeyParams("other-name-", "cgw_1234567"),
params: aiGatewayKeyParams("other-name-", "key_trail__"),
expectCheckErr: database.CheckAiGatewayKeysNameCheck,
},
{
name: "name with consecutive dashes",
params: aiGatewayKeyParams("other--name", "cgw_1234567"),
params: aiGatewayKeyParams("other--name", "key_consec_"),
expectCheckErr: database.CheckAiGatewayKeysNameCheck,
},
{
name: "name with underscore",
params: aiGatewayKeyParams("other_name", "cgw_1234567"),
params: aiGatewayKeyParams("other_name", "key_undersc"),
expectCheckErr: database.CheckAiGatewayKeysNameCheck,
},
{
name: "name with space",
params: aiGatewayKeyParams("other name", "cgw_1234567"),
params: aiGatewayKeyParams("other name", "key_spacen_"),
expectCheckErr: database.CheckAiGatewayKeysNameCheck,
},
{
name: "name with leading dash",
params: aiGatewayKeyParams("-other-name", "cgw_1234567"),
params: aiGatewayKeyParams("-other-name", "key_leadng_"),
expectCheckErr: database.CheckAiGatewayKeysNameCheck,
},
{
name: "name longer than 64 characters",
params: aiGatewayKeyParams(strings.Repeat("a", 65), "cgw_1234567"),
params: aiGatewayKeyParams(strings.Repeat("a", 65), "key_longna_"),
expectCheckErr: database.CheckAiGatewayKeysNameCheck,
},
{
name: "empty secret prefix",
params: aiGatewayKeyParams("other-name", ""),
params: aiGatewayKeyParams("check-empty-pfx", ""),
expectCheckErr: database.CheckAiGatewayKeysSecretPrefixCheck,
},
{
name: "invalid secret prefix length",
params: aiGatewayKeyParams("other-name", "cgw_short"),
params: aiGatewayKeyParams("check-short-pfx", "key_short"),
expectCheckErr: database.CheckAiGatewayKeysSecretPrefixCheck,
},
{
name: "empty hashed secret",
params: database.InsertAIGatewayKeyParams{ID: uuid.New(), Name: "other-name", SecretPrefix: "cgw_1234567"},
params: database.InsertAIGatewayKeyParams{ID: uuid.New(), Name: "check-empty-hash", SecretPrefix: "key_ehash__", HashedSecret: []byte{}},
expectCheckErr: database.CheckAiGatewayKeysHashedSecretCheck,
},
}
@@ -14841,8 +14841,8 @@ func TestAIGatewayKeysQueries(t *testing.T) {
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitLong)
first := aiGatewayKeyParams("first-key", "cgw_first__")
second := aiGatewayKeyParams("second-key", "cgw_second_")
first := aiGatewayKeyParams("first-key", "key_first__")
second := aiGatewayKeyParams("second-key", "key_second_")
second.HashedSecret = []byte("second-secret")
firstRow, err := db.InsertAIGatewayKey(ctx, first)
@@ -14889,7 +14889,7 @@ func aiGatewayKeyParams(name string, secretPrefix string) database.InsertAIGatew
ID: uuid.New(),
Name: name,
SecretPrefix: secretPrefix,
HashedSecret: []byte("secret"),
HashedSecret: []byte("secret-" + name + "-" + secretPrefix),
}
}