From af0cc8d61adc8a1ce7e50202dc0771c9b6271509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Wed, 20 May 2026 08:20:02 +0000 Subject: [PATCH] feat: add AI Gateway coderd key CRUD endpoints Adds POST / GET / DELETE handlers for /api/v2/aibridge/coderd-keys under the existing AI Bridge feature gate. Create returns the plaintext secret exactly once (cgw_... format); list returns metadata plus a short non-secret token_prefix so admins can correlate keys with the daemons presenting them. Delete identifies keys by UUID in the path and is idempotent. --- .../aigatewaycoderdkey/aigatewaycoderdkey.go | 45 +++++ coderd/apidoc/docs.go | 150 ++++++++++++++ coderd/apidoc/swagger.json | 136 +++++++++++++ codersdk/aigatewaycoderdkeys.go | 81 ++++++++ docs/reference/api/enterprise.md | 126 ++++++++++++ docs/reference/api/schemas.md | 58 ++++++ enterprise/coderd/aigatewaycoderdkeys.go | 182 +++++++++++++++++ enterprise/coderd/aigatewaycoderdkeys_test.go | 191 ++++++++++++++++++ enterprise/coderd/coderd.go | 12 ++ site/src/api/typesGenerated.ts | 31 +++ 10 files changed, 1012 insertions(+) create mode 100644 coderd/aigatewaycoderdkey/aigatewaycoderdkey.go create mode 100644 codersdk/aigatewaycoderdkeys.go create mode 100644 enterprise/coderd/aigatewaycoderdkeys.go create mode 100644 enterprise/coderd/aigatewaycoderdkeys_test.go diff --git a/coderd/aigatewaycoderdkey/aigatewaycoderdkey.go b/coderd/aigatewaycoderdkey/aigatewaycoderdkey.go new file mode 100644 index 0000000000..fbe229de29 --- /dev/null +++ b/coderd/aigatewaycoderdkey/aigatewaycoderdkey.go @@ -0,0 +1,45 @@ +package aigatewaycoderdkey + +import ( + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/apikey" + "github.com/coder/coder/v2/coderd/database" +) + +const ( + visiblePrefixLength = 7 + privateSuffixLength = 32 + + // KeyTypePrefix marks a key as belonging to the Coder AI Gateway. + KeyTypePrefix = "cgw_" + + // KeyPrefixLength is the total length of the visible key prefix. + KeyPrefixLength = len(KeyTypePrefix) + visiblePrefixLength + + // KeyLength is the total length of the plaintext key returned to + // the user on Create. + KeyLength = KeyPrefixLength + privateSuffixLength +) + +// New generates an AI Gateway Coderd key. Returns InsertParams ready +// for the database query. +// +// Key shape: "cgw_" + 7 random chars + 32 random chars = 43 chars total. +func New(name string) (database.InsertAIGatewayCoderdKeyParams, string, error) { + secret, hashed, err := apikey.GenerateSecret(KeyLength) + if err != nil { + return database.InsertAIGatewayCoderdKeyParams{}, "", xerrors.Errorf("generate secret: %w", err) + } + + secret = KeyTypePrefix + secret + visiblePrefix := secret[:KeyPrefixLength] + + return database.InsertAIGatewayCoderdKeyParams{ + ID: uuid.New(), + Name: name, + SecretPrefix: visiblePrefix, + HashedSecret: hashed, + }, secret, nil +} diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7aca308f9d..4793cdd487 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -64,6 +64,100 @@ const docTemplate = `{ } } }, + "/aibridge/coderd-keys": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "List AI Gateway coderd keys", + "operationId": "list-ai-gateway-coderd-keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIGatewayCoderdKey" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Create AI Gateway coderd key", + "operationId": "create-ai-gateway-coderd-key", + "parameters": [ + { + "description": "Create AI Gateway coderd key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateAIGatewayCoderdKeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.CreateAIGatewayCoderdKeyResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/aibridge/coderd-keys/{key}": { + "delete": { + "tags": [ + "Enterprise" + ], + "summary": "Delete AI Gateway coderd key", + "operationId": "delete-ai-gateway-coderd-key", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Key ID", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/experimental/chats": { "get": { "description": "Experimental: this endpoint is subject to change.", @@ -15048,6 +15142,29 @@ const docTemplate = `{ } } }, + "codersdk.AIGatewayCoderdKey": { + "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.CreateAIGatewayCoderdKeyRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "codersdk.CreateAIGatewayCoderdKeyResponse": { + "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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 842eac0c08..665bc1f211 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -49,6 +49,88 @@ } } }, + "/aibridge/coderd-keys": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "List AI Gateway coderd keys", + "operationId": "list-ai-gateway-coderd-keys", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIGatewayCoderdKey" + } + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "post": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Create AI Gateway coderd key", + "operationId": "create-ai-gateway-coderd-key", + "parameters": [ + { + "description": "Create AI Gateway coderd key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateAIGatewayCoderdKeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.CreateAIGatewayCoderdKeyResponse" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, + "/aibridge/coderd-keys/{key}": { + "delete": { + "tags": ["Enterprise"], + "summary": "Delete AI Gateway coderd key", + "operationId": "delete-ai-gateway-coderd-key", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Key ID", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/experimental/chats": { "get": { "description": "Experimental: this endpoint is subject to change.", @@ -13440,6 +13522,29 @@ } } }, + "codersdk.AIGatewayCoderdKey": { + "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.CreateAIGatewayCoderdKeyRequest": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + } + } + }, + "codersdk.CreateAIGatewayCoderdKeyResponse": { + "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": { diff --git a/codersdk/aigatewaycoderdkeys.go b/codersdk/aigatewaycoderdkeys.go new file mode 100644 index 0000000000..2b33052bbf --- /dev/null +++ b/codersdk/aigatewaycoderdkeys.go @@ -0,0 +1,81 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" +) + +// AIGatewayCoderdKey is a shared secret used by an standalone AI Gateway +// to authenticate into coderd. +type AIGatewayCoderdKey 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"` +} + +type CreateAIGatewayCoderdKeyRequest struct { + Name string `json:"name" validate:"required"` +} + +// CreateAIGatewayCoderdKeyResponse returns all key information. +// Key value is only returned here and cannot be recovered afterwards. +type CreateAIGatewayCoderdKeyResponse 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"` +} + +// CreateAIGatewayCoderdKey creates a new AI Gateway coderd key. +func (c *Client) CreateAIGatewayCoderdKey(ctx context.Context, req CreateAIGatewayCoderdKeyRequest) (CreateAIGatewayCoderdKeyResponse, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/aibridge/coderd-keys", req) + if err != nil { + return CreateAIGatewayCoderdKeyResponse{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusCreated { + return CreateAIGatewayCoderdKeyResponse{}, ReadBodyAsError(res) + } + var resp CreateAIGatewayCoderdKeyResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// ListAIGatewayCoderdKeys lists all AI Gateway coderd keys. +func (c *Client) ListAIGatewayCoderdKeys(ctx context.Context) ([]AIGatewayCoderdKey, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/aibridge/coderd-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 []AIGatewayCoderdKey + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// DeleteAIGatewayCoderdKey deletes an AI Gateway coderd key by ID. +func (c *Client) DeleteAIGatewayCoderdKey(ctx context.Context, id uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, + fmt.Sprintf("/api/v2/aibridge/coderd-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 +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index ed1ce268e7..a49dc15c48 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -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 coderd keys + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/aibridge/coderd-keys \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /aibridge/coderd-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.AIGatewayCoderdKey](schemas.md#codersdkaigatewaycoderdkey) | + +

Response Schema

+ +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 coderd key + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/aibridge/coderd-keys \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /aibridge/coderd-keys` + +> Body parameter + +```json +{ + "name": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------------------------------|----------|--------------------------------------| +| `body` | body | [codersdk.CreateAIGatewayCoderdKeyRequest](schemas.md#codersdkcreateaigatewaycoderdkeyrequest) | true | Create AI Gateway coderd 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.CreateAIGatewayCoderdKeyResponse](schemas.md#codersdkcreateaigatewaycoderdkeyresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Delete AI Gateway coderd key + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/aibridge/coderd-keys/{key} \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /aibridge/coderd-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 diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 47268ba974..56a0bf6454 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1248,6 +1248,28 @@ | `bridge` | [codersdk.AIBridgeConfig](#codersdkaibridgeconfig) | false | | | | `chat` | [codersdk.ChatConfig](#codersdkchatconfig) | false | | | +## codersdk.AIGatewayCoderdKey + +```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.CreateAIGatewayCoderdKeyRequest + +```json +{ + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|--------|--------|----------|--------------|-------------| +| `name` | string | true | | | + +## codersdk.CreateAIGatewayCoderdKeyResponse + +```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 diff --git a/enterprise/coderd/aigatewaycoderdkeys.go b/enterprise/coderd/aigatewaycoderdkeys.go new file mode 100644 index 0000000000..898c14b409 --- /dev/null +++ b/enterprise/coderd/aigatewaycoderdkeys.go @@ -0,0 +1,182 @@ +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/aigatewaycoderdkey" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +// maxKeyInsertAttempts caps retries when a generated secret collides. +// Collisions are astronomically unlikely; this is a safety net. +const maxKeyInsertAttempts = 7 + +// 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 coderd key +// @ID create-ai-gateway-coderd-key +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Enterprise +// @Param request body codersdk.CreateAIGatewayCoderdKeyRequest true "Create AI Gateway coderd key request" +// @Success 201 {object} codersdk.CreateAIGatewayCoderdKeyResponse +// @Router /aibridge/coderd-keys [post] +func (api *API) postAIGatewayCoderdKey(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req codersdk.CreateAIGatewayCoderdKeyRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + row, secret, err := api.generateAndInsertKey(ctx, req.Name) + for attempt := 1; isRetryableKeyInsertErr(err) && attempt < maxKeyInsertAttempts; attempt++ { + row, secret, err = api.generateAndInsertKey(ctx, req.Name) + } + if err != nil { + writeKeyInsertError(ctx, rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateAIGatewayCoderdKeyResponse{ + 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.InsertAIGatewayCoderdKeyRow, string, error) { + params, key, err := aigatewaycoderdkey.New(name) + if err != nil { + return database.InsertAIGatewayCoderdKeyRow{}, "", err + } + row, err := api.Database.InsertAIGatewayCoderdKey(ctx, params) + if err != nil { + return database.InsertAIGatewayCoderdKeyRow{}, "", err + } + return row, key, nil +} + +// isRetryableKeyInsertErr returns true for generated-secret collisions. +func isRetryableKeyInsertErr(err error) bool { + return database.IsUniqueViolation(err, + database.UniqueAiGatewayCoderdKeysSecretPrefixIndex, + database.UniqueAiGatewayCoderdKeysHashedSecretIndex, + ) +} + +// 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.CheckAiGatewayCoderdKeysNameCheck): + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid key name.", + Validations: []codersdk.ValidationError{ + {Field: "name", Detail: nameFormatDetail}, + }, + }) + case database.IsUniqueViolation(err, database.UniqueAiGatewayCoderdKeysNameIndex): + 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: + httpapi.InternalServerError(rw, err) + } +} + +// @Summary List AI Gateway coderd keys +// @ID list-ai-gateway-coderd-keys +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Success 200 {array} codersdk.AIGatewayCoderdKey +// @Router /aibridge/coderd-keys [get] +func (api *API) aiGatewayCoderdKeys(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + rows, err := api.Database.ListAIGatewayCoderdKeys(ctx) + if httpapi.IsUnauthorizedError(err) { + httpapi.Forbidden(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + out := make([]codersdk.AIGatewayCoderdKey, 0, len(rows)) + for _, row := range rows { + out = append(out, convertAIGatewayCoderdKey(row)) + } + + httpapi.Write(ctx, rw, http.StatusOK, out) +} + +// @Summary Delete AI Gateway coderd key +// @ID delete-ai-gateway-coderd-key +// @Security CoderSessionToken +// @Tags Enterprise +// @Param key path string true "Key ID" format(uuid) +// @Success 204 +// @Router /aibridge/coderd-keys/{key} [delete] +func (api *API) deleteAIGatewayCoderdKey(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + 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 + } + + if _, err := api.Database.DeleteAIGatewayCoderdKey(ctx, id); err != nil { + if httpapi.IsUnauthorizedError(err) { + httpapi.Forbidden(rw) + return + } + if errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusNoContent, nil) +} + +func convertAIGatewayCoderdKey(row database.ListAIGatewayCoderdKeysRow) codersdk.AIGatewayCoderdKey { + var lastUsed *time.Time + if row.LastUsedAt.Valid { + t := row.LastUsedAt.Time + lastUsed = &t + } + return codersdk.AIGatewayCoderdKey{ + ID: row.ID, + Name: row.Name, + KeyPrefix: row.SecretPrefix, + CreatedAt: row.CreatedAt, + LastUsedAt: lastUsed, + } +} diff --git a/enterprise/coderd/aigatewaycoderdkeys_test.go b/enterprise/coderd/aigatewaycoderdkeys_test.go new file mode 100644 index 0000000000..9114f6476a --- /dev/null +++ b/enterprise/coderd/aigatewaycoderdkeys_test.go @@ -0,0 +1,191 @@ +package coderd_test + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/aigatewaycoderdkey" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" +) + +// aibridgeEnabledOpts returns coderdenttest options that fully enable AI +// Bridge: feature entitlement + deployment config flag. +func aibridgeEnabledOpts(t *testing.T) *coderdenttest.Options { + t.Helper() + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = serpent.Bool(true) + return &coderdenttest.Options{ + Options: &coderdtest.Options{DeploymentValues: dv}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAIBridge: 1}, + }, + } +} + +func TestAIGatewayCoderdKeys(t *testing.T) { + t.Parallel() + + // Single instance shared by all subtests (except FeatureGate). + // Subtests run sequentially because they share server state. + ctx := testutil.Context(t, testutil.WaitLong) + ownerClient, owner := coderdenttest.New(t, aibridgeEnabledOpts(t)) + + //nolint:paralleltest // Subtests share a single coderdenttest instance. + t.Run("CRUD", func(t *testing.T) { + keys, err := ownerClient.ListAIGatewayCoderdKeys(ctx) + require.NoError(t, err) + require.Empty(t, keys) + + name := uniqueName(t, "happy") + + created, err := ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{Name: name}) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, created.ID) + require.Equal(t, name, created.Name) + require.Len(t, created.KeyPrefix, aigatewaycoderdkey.KeyPrefixLength) + require.Len(t, created.Key, aigatewaycoderdkey.KeyLength) + require.True(t, strings.HasPrefix(created.KeyPrefix, aigatewaycoderdkey.KeyTypePrefix), "key_prefix must start with %q, got %q", aigatewaycoderdkey.KeyTypePrefix, created.KeyPrefix) + 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.ListAIGatewayCoderdKeys(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.DeleteAIGatewayCoderdKey(ctx, created.ID)) + + keys, err = ownerClient.ListAIGatewayCoderdKeys(ctx) + require.NoError(t, err) + require.Empty(t, keys) + }) + + //nolint:paralleltest // Subtests share a single coderdenttest instance. + t.Run("ListResponseDoesNotLeakSecrets", func(t *testing.T) { + created, err := ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{ + Name: uniqueName(t, "leak"), + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = ownerClient.DeleteAIGatewayCoderdKey(ctx, created.ID) + }) + + // Raw HTTP read of LIST to confirm the JSON shape. + resp, err := ownerClient.Request(ctx, http.MethodGet, "/api/v2/aibridge/coderd-keys", nil) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var raw []map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&raw)) + require.NotEmpty(t, raw) + _, hasSecret := raw[0]["secret"] + _, hasHashed := raw[0]["hashed_secret"] + require.False(t, hasSecret, "LIST response leaked plaintext secret") + require.False(t, hasHashed, "LIST response leaked hashed_secret") + }) + + //nolint:paralleltest // Subtests share a single coderdenttest instance. + t.Run("CreateValidation", func(t *testing.T) { + // Empty name -> 400 (validate:"required" on request struct). + _, err := ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{Name: ""}) + require.ErrorContains(t, err, "Validation failed") + + // >64 char name -> 400 (DB check constraint). + longName := strings.Repeat("a", 65) + _, err = ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{Name: longName}) + require.ErrorContains(t, err, "Invalid key name") + + // Uppercase name -> 400 (DB check constraint rejects non-lowercase). + _, err = ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{Name: "UPPER-CASE"}) + require.ErrorContains(t, err, "Invalid key name") + + // Duplicate name -> 400. + name := uniqueName(t, "dup") + created, err := ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{Name: name}) + require.NoError(t, err) + t.Cleanup(func() { + _ = ownerClient.DeleteAIGatewayCoderdKey(ctx, created.ID) + }) + _, err = ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{Name: name}) + require.ErrorContains(t, err, "must be unique") + }) + + //nolint:paralleltest // Subtests share a single coderdenttest instance. + t.Run("DeleteValidation", func(t *testing.T) { + // Invalid UUID -> 400 (raw request; SDK method accepts uuid.UUID). + resp, err := ownerClient.Request(ctx, http.MethodDelete, "/api/v2/aibridge/coderd-keys/not-a-uuid", nil) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + + // Delete existing key -> 204 (SDK returns nil error on 204). + created, err := ownerClient.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{ + Name: uniqueName(t, "del"), + }) + require.NoError(t, err) + require.NoError(t, ownerClient.DeleteAIGatewayCoderdKey(ctx, created.ID)) + + // Unknown UUID -> 404. + err = ownerClient.DeleteAIGatewayCoderdKey(ctx, uuid.New()) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + //nolint:paralleltest // Subtests share a single coderdenttest instance. + t.Run("ReturnsForbiddenForNonOwners", func(t *testing.T) { + member, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + _, err := member.CreateAIGatewayCoderdKey(ctx, codersdk.CreateAIGatewayCoderdKeyRequest{ + Name: uniqueName(t, "denied"), + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + + _, err = member.ListAIGatewayCoderdKeys(ctx) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + + err = member.DeleteAIGatewayCoderdKey(ctx, uuid.New()) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + }) + + // FeatureGate needs a separate instance without the AI Bridge entitlement. + t.Run("FeatureGate", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + ownerClient, _ := coderdenttest.New(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{}, + }, + }) + + //nolint:gocritic // Managing AI Gateway coderd keys is owner-only. + _, err := ownerClient.ListAIGatewayCoderdKeys(ctx) + require.Error(t, err) + }) +} + +func uniqueName(t *testing.T, prefix string) string { + t.Helper() + return strings.ToLower(fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())) +} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 33732eea3d..e42bca4367 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -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/coderd-keys", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + api.RequireFeatureMW(codersdk.FeatureAIBridge), + ) + r.Get("/", api.aiGatewayCoderdKeys) + r.Post("/", api.postAIGatewayCoderdKey) + r.Delete("/{key}", api.deleteAIGatewayCoderdKey) + }) + }) + api.AGPL.APIHandler.Group(func(r chi.Router) { r.Get("/entitlements", api.serveEntitlements) // /regions overrides the AGPL /regions endpoint diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a0aad00206..edb3de1a6a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -304,6 +304,19 @@ export interface AIConfig { readonly chat?: ChatConfig; } +// From codersdk/aigatewaycoderdkeys.go +/** + * AIGatewayCoderdKey is a shared secret used by an standalone AI Gateway + * to authenticate into coderd. + */ +export interface AIGatewayCoderdKey { + 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,24 @@ export interface ConvertLoginRequest { readonly password: string; } +// From codersdk/aigatewaycoderdkeys.go +export interface CreateAIGatewayCoderdKeyRequest { + readonly name: string; +} + +// From codersdk/aigatewaycoderdkeys.go +/** + * CreateAIGatewayCoderdKeyResponse returns all key information. + * Key value is only returned here and cannot be recovered afterwards. + */ +export interface CreateAIGatewayCoderdKeyResponse { + 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