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
+212
View File
@@ -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,
}
}
+387
View File
@@ -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")
})
}
}
+12
View File
@@ -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