mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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
|
||||
}
|
||||
@@ -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:]))
|
||||
}
|
||||
Generated
+150
@@ -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": {
|
||||
|
||||
Generated
+136
@@ -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": {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package codersdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// AIGatewayKey is a shared secret used by a standalone AI Gateway
|
||||
// to authenticate into coderd.
|
||||
type AIGatewayKey struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Name string `json:"name"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty" format:"date-time"`
|
||||
}
|
||||
|
||||
// CreateAIGatewayKeyRequest requests a new AI Gateway key.
|
||||
type CreateAIGatewayKeyRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
|
||||
// CreateAIGatewayKeyResponse returns all key information.
|
||||
// Key value is only returned here and cannot be recovered afterwards.
|
||||
type CreateAIGatewayKeyResponse struct {
|
||||
ID uuid.UUID `json:"id" format:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
CreatedAt time.Time `json:"created_at" format:"date-time"`
|
||||
}
|
||||
|
||||
// CreateAIGatewayKey creates a new AI Gateway key.
|
||||
func (c *Client) CreateAIGatewayKey(ctx context.Context, req CreateAIGatewayKeyRequest) (CreateAIGatewayKeyResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodPost, "/api/v2/aibridge/keys", req)
|
||||
if err != nil {
|
||||
return CreateAIGatewayKeyResponse{}, xerrors.Errorf("make request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusCreated {
|
||||
return CreateAIGatewayKeyResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp CreateAIGatewayKeyResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// ListAIGatewayKeys lists all AI Gateway keys.
|
||||
func (c *Client) ListAIGatewayKeys(ctx context.Context) ([]AIGatewayKey, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, "/api/v2/aibridge/keys", nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("make request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, ReadBodyAsError(res)
|
||||
}
|
||||
var resp []AIGatewayKey
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// DeleteAIGatewayKey deletes an AI Gateway key by ID.
|
||||
func (c *Client) DeleteAIGatewayKey(ctx context.Context, id uuid.UUID) error {
|
||||
res, err := c.Request(ctx, http.MethodDelete,
|
||||
fmt.Sprintf("/api/v2/aibridge/keys/%s", id.String()), nil)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("make request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return ReadBodyAsError(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Generated
+126
@@ -84,6 +84,132 @@ curl -X GET http://coder-server:8080/.well-known/oauth-protected-resource \
|
||||
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OAuth2ProtectedResourceMetadata](schemas.md#codersdkoauth2protectedresourcemetadata) |
|
||||
|
||||
## List AI Gateway keys
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/aibridge/keys \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /api/v2/aibridge/keys`
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"key_prefix": "string",
|
||||
"last_used_at": "2019-08-24T14:15:22Z",
|
||||
"name": "string"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|---------------------------------------------------------|-------------|-------------------------------------------------------------------|
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.AIGatewayKey](schemas.md#codersdkaigatewaykey) |
|
||||
|
||||
<h3 id="list-ai-gateway-keys-responseschema">Response Schema</h3>
|
||||
|
||||
Status Code **200**
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|------------------|-------------------|----------|--------------|-------------|
|
||||
| `[array item]` | array | false | | |
|
||||
| `» created_at` | string(date-time) | false | | |
|
||||
| `» id` | string(uuid) | false | | |
|
||||
| `» key_prefix` | string | false | | |
|
||||
| `» last_used_at` | string(date-time) | false | | |
|
||||
| `» name` | string | false | | |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Create AI Gateway key
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/aibridge/keys \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /api/v2/aibridge/keys`
|
||||
|
||||
> Body parameter
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|--------|------|------------------------------------------------------------------------------------|----------|-------------------------------|
|
||||
| `body` | body | [codersdk.CreateAIGatewayKeyRequest](schemas.md#codersdkcreateaigatewaykeyrequest) | true | Create AI Gateway key request |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 201 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"key": "string",
|
||||
"key_prefix": "string",
|
||||
"name": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|--------------------------------------------------------------|-------------|--------------------------------------------------------------------------------------|
|
||||
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.CreateAIGatewayKeyResponse](schemas.md#codersdkcreateaigatewaykeyresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Delete AI Gateway key
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X DELETE http://coder-server:8080/api/v2/aibridge/keys/{key} \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`DELETE /api/v2/aibridge/keys/{key}`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
|-------|------|--------------|----------|-------------|
|
||||
| `key` | path | string(uuid) | true | Key ID |
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
|--------|-----------------------------------------------------------------|-------------|--------|
|
||||
| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get appearance
|
||||
|
||||
### Code samples
|
||||
|
||||
Generated
+58
@@ -1248,6 +1248,28 @@
|
||||
| `bridge` | [codersdk.AIBridgeConfig](#codersdkaibridgeconfig) | false | | |
|
||||
| `chat` | [codersdk.ChatConfig](#codersdkchatconfig) | false | | |
|
||||
|
||||
## codersdk.AIGatewayKey
|
||||
|
||||
```json
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"key_prefix": "string",
|
||||
"last_used_at": "2019-08-24T14:15:22Z",
|
||||
"name": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|----------------|--------|----------|--------------|-------------|
|
||||
| `created_at` | string | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `key_prefix` | string | false | | |
|
||||
| `last_used_at` | string | false | | |
|
||||
| `name` | string | false | | |
|
||||
|
||||
## codersdk.AIProvider
|
||||
|
||||
```json
|
||||
@@ -4406,6 +4428,42 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
| `password` | string | true | | |
|
||||
| `to_type` | [codersdk.LoginType](#codersdklogintype) | true | | To type is the login type to convert to. |
|
||||
|
||||
## codersdk.CreateAIGatewayKeyRequest
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|--------|--------|----------|--------------|-------------|
|
||||
| `name` | string | true | | |
|
||||
|
||||
## codersdk.CreateAIGatewayKeyResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"key": "string",
|
||||
"key_prefix": "string",
|
||||
"name": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|--------------|--------|----------|--------------|-------------|
|
||||
| `created_at` | string | false | | |
|
||||
| `id` | string | false | | |
|
||||
| `key` | string | false | | |
|
||||
| `key_prefix` | string | false | | |
|
||||
| `name` | string | false | | |
|
||||
|
||||
## codersdk.CreateAIProviderRequest
|
||||
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/aibridge/keys"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// nameFormatDetail is the human-readable description of valid key names.
|
||||
const nameFormatDetail = "Must be 64 characters or fewer, lowercase letters, numbers, and non-consecutive hyphens, cannot start or end with a hyphen."
|
||||
|
||||
// @Summary Create AI Gateway key
|
||||
// @ID create-ai-gateway-key
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param request body codersdk.CreateAIGatewayKeyRequest true "Create AI Gateway key request"
|
||||
// @Success 201 {object} codersdk.CreateAIGatewayKeyResponse
|
||||
// @Router /api/v2/aibridge/keys [post]
|
||||
func (api *API) postAIGatewayKey(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
auditor = api.AGPL.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.AIGatewayKey](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionCreate,
|
||||
})
|
||||
)
|
||||
defer commitAudit()
|
||||
|
||||
var req codersdk.CreateAIGatewayKeyRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
row, secret, err := api.generateAndInsertKey(ctx, req.Name)
|
||||
if err != nil {
|
||||
writeKeyInsertError(ctx, rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
aReq.New = database.AIGatewayKey{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
SecretPrefix: row.SecretPrefix,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateAIGatewayKeyResponse{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
KeyPrefix: row.SecretPrefix,
|
||||
CreatedAt: row.CreatedAt,
|
||||
Key: secret,
|
||||
})
|
||||
}
|
||||
|
||||
// generateAndInsertKey creates fresh key material and attempts an insert.
|
||||
func (api *API) generateAndInsertKey(ctx context.Context, name string) (database.InsertAIGatewayKeyRow, string, error) {
|
||||
params, key, err := keys.New(name)
|
||||
if err != nil {
|
||||
return database.InsertAIGatewayKeyRow{}, "", err
|
||||
}
|
||||
row, err := api.Database.InsertAIGatewayKey(ctx, params)
|
||||
if err != nil {
|
||||
return database.InsertAIGatewayKeyRow{}, "", err
|
||||
}
|
||||
return row, key, nil
|
||||
}
|
||||
|
||||
// writeKeyInsertError maps insert errors to HTTP responses.
|
||||
func writeKeyInsertError(ctx context.Context, rw http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case httpapi.IsUnauthorizedError(err):
|
||||
httpapi.Forbidden(rw)
|
||||
case database.IsCheckViolation(err, database.CheckAiGatewayKeysNameCheck):
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid key name.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{Field: "name", Detail: nameFormatDetail},
|
||||
},
|
||||
})
|
||||
case database.IsUniqueViolation(err, database.UniqueAiGatewayKeysNameIndex):
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Key name must be unique.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{Field: "name", Detail: "A key with this name already exists."},
|
||||
},
|
||||
})
|
||||
default:
|
||||
// Secret collisions (hashed_secret or secret_prefix unique
|
||||
// violations, should not happen in practice) and other unexpected errors
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to create key. Please retry.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary List AI Gateway keys
|
||||
// @ID list-ai-gateway-keys
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Success 200 {array} codersdk.AIGatewayKey
|
||||
// @Router /api/v2/aibridge/keys [get]
|
||||
func (api *API) aiGatewayKeys(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
rows, err := api.Database.ListAIGatewayKeys(ctx)
|
||||
if httpapi.IsUnauthorizedError(err) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to list keys.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]codersdk.AIGatewayKey, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, convertAIGatewayKey(row))
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// @Summary Delete AI Gateway key
|
||||
// @ID delete-ai-gateway-key
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Enterprise
|
||||
// @Param key path string true "Key ID" format(uuid)
|
||||
// @Success 204
|
||||
// @Router /api/v2/aibridge/keys/{key} [delete]
|
||||
func (api *API) deleteAIGatewayKey(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
auditor = api.AGPL.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.AIGatewayKey](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionDelete,
|
||||
})
|
||||
)
|
||||
defer commitAudit()
|
||||
|
||||
id, err := uuid.Parse(chi.URLParam(r, "key"))
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid key ID.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := api.Database.DeleteAIGatewayKey(ctx, id)
|
||||
if err != nil {
|
||||
if httpapi.IsUnauthorizedError(err) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to delete key.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
aReq.Old = database.AIGatewayKey{
|
||||
ID: deleted.ID,
|
||||
Name: deleted.Name,
|
||||
SecretPrefix: deleted.SecretPrefix,
|
||||
CreatedAt: deleted.CreatedAt,
|
||||
LastUsedAt: deleted.LastUsedAt,
|
||||
}
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func convertAIGatewayKey(row database.ListAIGatewayKeysRow) codersdk.AIGatewayKey {
|
||||
var lastUsed *time.Time
|
||||
if row.LastUsedAt.Valid {
|
||||
t := row.LastUsedAt.Time
|
||||
lastUsed = &t
|
||||
}
|
||||
return codersdk.AIGatewayKey{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
KeyPrefix: row.SecretPrefix,
|
||||
CreatedAt: row.CreatedAt,
|
||||
LastUsedAt: lastUsed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
aibridgekeys "github.com/coder/coder/v2/coderd/aibridge/keys"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
entaudit "github.com/coder/coder/v2/enterprise/audit"
|
||||
"github.com/coder/coder/v2/enterprise/audit/backends"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/license"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestAIGatewayKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("CRUD", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerClient, _ := coderdenttest.New(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
//nolint:gocritic // Managing AI Gateway keys is owner-only.
|
||||
keys, err := ownerClient.ListAIGatewayKeys(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, keys)
|
||||
|
||||
name := uniqueName(t, "happy")
|
||||
|
||||
created, err := ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: name})
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, uuid.Nil, created.ID)
|
||||
require.Equal(t, name, created.Name)
|
||||
require.Len(t, created.KeyPrefix, aibridgekeys.KeyPrefixLength)
|
||||
require.Len(t, created.Key, aibridgekeys.KeyLength)
|
||||
require.True(t, strings.HasPrefix(created.Key, created.KeyPrefix), "key must begin with key_prefix")
|
||||
require.WithinDuration(t, time.Now(), created.CreatedAt, time.Minute)
|
||||
|
||||
keys, err = ownerClient.ListAIGatewayKeys(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys, 1)
|
||||
require.Equal(t, created.ID, keys[0].ID)
|
||||
require.Equal(t, created.Name, keys[0].Name)
|
||||
require.Equal(t, created.KeyPrefix, keys[0].KeyPrefix)
|
||||
require.Nil(t, keys[0].LastUsedAt)
|
||||
|
||||
require.NoError(t, ownerClient.DeleteAIGatewayKey(ctx, created.ID))
|
||||
|
||||
keys, err = ownerClient.ListAIGatewayKeys(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, keys)
|
||||
})
|
||||
|
||||
t.Run("ListResponseDoesNotLeakSecrets", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerClient, _ := coderdenttest.New(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
//nolint:gocritic // Managing AI Gateway keys is owner-only.
|
||||
created, err := ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{
|
||||
Name: uniqueName(t, "leak"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
fullKey := created.Key
|
||||
|
||||
resp, err := ownerClient.Request(ctx, http.MethodGet, "/api/v2/aibridge/keys", nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = resp.Body.Close() })
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotContains(t, string(body), fullKey, "LIST response leaked full key")
|
||||
})
|
||||
|
||||
t.Run("CreateValidation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerClient, _ := coderdenttest.New(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Empty name -> 400 (validate:"required" on request struct).
|
||||
//nolint:gocritic // Managing AI Gateway keys is owner-only.
|
||||
_, err := ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: ""})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.ErrorContains(t, err, "Validation failed")
|
||||
|
||||
// >64 char name -> 400 (DB check constraint).
|
||||
longName := strings.Repeat("a", 65)
|
||||
_, err = ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: longName})
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.ErrorContains(t, err, "Invalid key name")
|
||||
|
||||
// Uppercase name -> 400 (DB check constraint rejects non-lowercase).
|
||||
_, err = ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: "UPPER-CASE"})
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.ErrorContains(t, err, "Invalid key name")
|
||||
|
||||
// Duplicate name -> 400.
|
||||
name := uniqueName(t, "dup")
|
||||
_, err = ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: name})
|
||||
require.NoError(t, err)
|
||||
_, err = ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: name})
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
||||
require.ErrorContains(t, err, "must be unique")
|
||||
})
|
||||
|
||||
t.Run("DeleteValidation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerClient, _ := coderdenttest.New(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Invalid UUID -> 400 (raw request; SDK method accepts uuid.UUID).
|
||||
//nolint:gocritic // Managing AI Gateway keys is owner-only.
|
||||
resp, err := ownerClient.Request(ctx, http.MethodDelete, "/api/v2/aibridge/keys/not-a-uuid", nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = resp.Body.Close() })
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
// Existing id -> 204.
|
||||
created, err := ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{
|
||||
Name: uniqueName(t, "del"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// SDK returns no code on success, using raw request to check for 204.
|
||||
delResp, err := ownerClient.Request(ctx, http.MethodDelete, "/api/v2/aibridge/keys/"+created.ID.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer delResp.Body.Close()
|
||||
require.Equal(t, http.StatusNoContent, delResp.StatusCode)
|
||||
|
||||
// Not existing id -> 404.
|
||||
err = ownerClient.DeleteAIGatewayKey(ctx, uuid.New())
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("ReturnsForbiddenForNonOwners", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerClient, owner := coderdenttest.New(t, aibridgeOpts(t))
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
member, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
_, err := member.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{
|
||||
Name: uniqueName(t, "denied"),
|
||||
})
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
||||
|
||||
_, err = member.ListAIGatewayKeys(ctx)
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
||||
|
||||
err = member.DeleteAIGatewayKey(ctx, uuid.New())
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("LicenseEntitlement", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerClient, _ := coderdenttest.New(t, &coderdenttest.Options{
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{},
|
||||
},
|
||||
})
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
//nolint:gocritic // Managing AI Gateway keys is owner-only.
|
||||
_, err := ownerClient.ListAIGatewayKeys(ctx)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
||||
require.Contains(t, sdkErr.Message, "AI Gateway is a Premium feature")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAIGatewayKeyAudit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
auditor := entaudit.NewAuditor(
|
||||
db,
|
||||
entaudit.DefaultFilter,
|
||||
backends.NewPostgres(db, true),
|
||||
)
|
||||
opts := aibridgeOpts(t)
|
||||
opts.AuditLogging = true
|
||||
opts.Options.Database = db
|
||||
opts.Options.Pubsub = ps
|
||||
opts.Options.Auditor = auditor
|
||||
opts.LicenseOptions.Features[codersdk.FeatureAuditLog] = 1
|
||||
|
||||
ownerClient, _ := coderdenttest.New(t, opts)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancel()
|
||||
|
||||
name := uniqueName(t, "audit")
|
||||
//nolint:gocritic // Managing AI Gateway coderd keys is owner-only.
|
||||
created, err := ownerClient.CreateAIGatewayKey(ctx, codersdk.CreateAIGatewayKeyRequest{Name: name})
|
||||
require.NoError(t, err)
|
||||
//nolint:gocritic // Managing AI Gateway coderd keys is owner-only.
|
||||
require.NoError(t, ownerClient.DeleteAIGatewayKey(ctx, created.ID))
|
||||
|
||||
rows, err := db.GetAuditLogsOffset(
|
||||
dbauthz.AsSystemRestricted(ctx),
|
||||
database.GetAuditLogsOffsetParams{
|
||||
ResourceType: string(database.ResourceTypeAIGatewayKey),
|
||||
LimitOpt: 10,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 2, "expected one create and one delete audit row")
|
||||
|
||||
var createLog, deleteLog database.AuditLog
|
||||
for _, row := range rows {
|
||||
log := row.AuditLog
|
||||
switch log.Action {
|
||||
case database.AuditActionCreate:
|
||||
createLog = log
|
||||
case database.AuditActionDelete:
|
||||
deleteLog = log
|
||||
default:
|
||||
require.Failf(t, "unexpected audit action", "action: %s", log.Action)
|
||||
}
|
||||
}
|
||||
require.Equal(t, database.AuditActionCreate, createLog.Action)
|
||||
require.Equal(t, database.AuditActionDelete, deleteLog.Action)
|
||||
require.Equal(t, http.StatusCreated, int(createLog.StatusCode))
|
||||
require.Equal(t, http.StatusNoContent, int(deleteLog.StatusCode))
|
||||
|
||||
for _, log := range []database.AuditLog{createLog, deleteLog} {
|
||||
require.Equal(t, database.ResourceTypeAIGatewayKey, log.ResourceType)
|
||||
require.Equal(t, created.ID, log.ResourceID)
|
||||
require.Equal(t, name, log.ResourceTarget)
|
||||
}
|
||||
|
||||
var createDiff audit.Map
|
||||
require.NoError(t, json.Unmarshal(createLog.Diff, &createDiff))
|
||||
require.Contains(t, createDiff, "name")
|
||||
require.Equal(t, "", createDiff["name"].Old)
|
||||
require.Equal(t, name, createDiff["name"].New)
|
||||
require.Contains(t, createDiff, "secret_prefix")
|
||||
require.Equal(t, "", createDiff["secret_prefix"].Old)
|
||||
require.Equal(t, created.KeyPrefix, createDiff["secret_prefix"].New)
|
||||
require.NotContains(t, createDiff, "hashed_secret")
|
||||
|
||||
var deleteDiff audit.Map
|
||||
require.NoError(t, json.Unmarshal(deleteLog.Diff, &deleteDiff))
|
||||
require.Contains(t, deleteDiff, "name")
|
||||
require.Equal(t, name, deleteDiff["name"].Old)
|
||||
require.Equal(t, "", deleteDiff["name"].New)
|
||||
require.NotContains(t, deleteDiff, "hashed_secret")
|
||||
}
|
||||
|
||||
func uniqueName(t *testing.T, prefix string) string {
|
||||
t.Helper()
|
||||
return strings.ToLower(fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()))
|
||||
}
|
||||
|
||||
// aiGatewayKeyErrorStore wraps a database.Store and forces specific
|
||||
// methods to return errors, allowing tests to exercise error paths.
|
||||
type aiGatewayKeyErrorStore struct {
|
||||
database.Store
|
||||
insertErr error
|
||||
listErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func (s *aiGatewayKeyErrorStore) InsertAIGatewayKey(ctx context.Context, arg database.InsertAIGatewayKeyParams) (database.InsertAIGatewayKeyRow, error) {
|
||||
if s.insertErr != nil {
|
||||
return database.InsertAIGatewayKeyRow{}, s.insertErr
|
||||
}
|
||||
return s.Store.InsertAIGatewayKey(ctx, arg)
|
||||
}
|
||||
|
||||
func (s *aiGatewayKeyErrorStore) ListAIGatewayKeys(ctx context.Context) ([]database.ListAIGatewayKeysRow, error) {
|
||||
if s.listErr != nil {
|
||||
return nil, s.listErr
|
||||
}
|
||||
return s.Store.ListAIGatewayKeys(ctx)
|
||||
}
|
||||
|
||||
func (s *aiGatewayKeyErrorStore) DeleteAIGatewayKey(ctx context.Context, id uuid.UUID) (database.DeleteAIGatewayKeyRow, error) {
|
||||
if s.deleteErr != nil {
|
||||
return database.DeleteAIGatewayKeyRow{}, s.deleteErr
|
||||
}
|
||||
return s.Store.DeleteAIGatewayKey(ctx, id)
|
||||
}
|
||||
|
||||
func TestAIGatewayKeysDatabaseErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbErr := xerrors.New("internal db failure")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
errStore aiGatewayKeyErrorStore
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
wantStatus int
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "CreateDBError",
|
||||
errStore: aiGatewayKeyErrorStore{insertErr: dbErr},
|
||||
method: http.MethodPost,
|
||||
path: "/api/v2/aibridge/keys",
|
||||
body: codersdk.CreateAIGatewayKeyRequest{Name: "db-err-create"},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantMsg: "Failed to create key. Please retry.",
|
||||
},
|
||||
{
|
||||
name: "ListDBError",
|
||||
errStore: aiGatewayKeyErrorStore{listErr: dbErr},
|
||||
method: http.MethodGet,
|
||||
path: "/api/v2/aibridge/keys",
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantMsg: "Failed to list keys.",
|
||||
},
|
||||
{
|
||||
name: "DeleteDBError",
|
||||
errStore: aiGatewayKeyErrorStore{deleteErr: dbErr},
|
||||
method: http.MethodDelete,
|
||||
path: "/api/v2/aibridge/keys/" + uuid.New().String(),
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantMsg: "Failed to delete key.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
errStore := tc.errStore
|
||||
errStore.Store = db
|
||||
|
||||
opts := aibridgeOpts(t)
|
||||
opts.Options.Database = &errStore
|
||||
opts.Options.Pubsub = ps
|
||||
|
||||
ownerClient, _ := coderdenttest.New(t, opts)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
//nolint:gocritic // Managing AI Gateway keys is owner-only.
|
||||
resp, err := ownerClient.Request(ctx, tc.method, tc.path, tc.body)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, tc.wantStatus, resp.StatusCode)
|
||||
|
||||
var sdkResp codersdk.Response
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&sdkResp))
|
||||
require.Equal(t, tc.wantMsg, sdkResp.Message)
|
||||
require.Empty(t, sdkResp.Detail, "response must not leak internal error details")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -298,6 +298,18 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
r.Route("/aibridge/proxy", aibridgeproxyHandler(api, apiKeyMiddleware))
|
||||
})
|
||||
|
||||
api.AGPL.APIHandler.Group(func(r chi.Router) {
|
||||
r.Route("/aibridge/keys", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
api.RequireFeatureMW(codersdk.FeatureAIBridge),
|
||||
)
|
||||
r.Get("/", api.aiGatewayKeys)
|
||||
r.Post("/", api.postAIGatewayKey)
|
||||
r.Delete("/{key}", api.deleteAIGatewayKey)
|
||||
})
|
||||
})
|
||||
|
||||
api.AGPL.APIHandler.Group(func(r chi.Router) {
|
||||
r.Get("/entitlements", api.serveEntitlements)
|
||||
// /regions overrides the AGPL /regions endpoint
|
||||
|
||||
Generated
+34
@@ -304,6 +304,19 @@ export interface AIConfig {
|
||||
readonly chat?: ChatConfig;
|
||||
}
|
||||
|
||||
// From codersdk/aigatewaykeys.go
|
||||
/**
|
||||
* AIGatewayKey is a shared secret used by a standalone AI Gateway
|
||||
* to authenticate into coderd.
|
||||
*/
|
||||
export interface AIGatewayKey {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly key_prefix: string;
|
||||
readonly created_at: string;
|
||||
readonly last_used_at?: string;
|
||||
}
|
||||
|
||||
// From codersdk/aiproviders.go
|
||||
/**
|
||||
* AIProvider represents an AI provider configuration row as returned
|
||||
@@ -3244,6 +3257,27 @@ export interface ConvertLoginRequest {
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
// From codersdk/aigatewaykeys.go
|
||||
/**
|
||||
* CreateAIGatewayKeyRequest requests a new AI Gateway key.
|
||||
*/
|
||||
export interface CreateAIGatewayKeyRequest {
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
// From codersdk/aigatewaykeys.go
|
||||
/**
|
||||
* CreateAIGatewayKeyResponse returns all key information.
|
||||
* Key value is only returned here and cannot be recovered afterwards.
|
||||
*/
|
||||
export interface CreateAIGatewayKeyResponse {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly key: string;
|
||||
readonly key_prefix: string;
|
||||
readonly created_at: string;
|
||||
}
|
||||
|
||||
// From codersdk/aiproviders.go
|
||||
/**
|
||||
* CreateAIProviderRequest is the payload for creating a new AI
|
||||
|
||||
Reference in New Issue
Block a user