Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions coderd/aibridged/proto/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package proto

import "github.com/coder/coder/v2/apiversion"

// Version history:
//
// API v1.0:
// - Initial version. Serves the Recorder, MCPConfigurator, and Authorizer
// services to embedded and standalone AI Gateway daemons.
const (
CurrentMajor = 1
CurrentMinor = 0
)

// CurrentVersion is the current aibridged API version.
// Breaking changes to the aibridged API **MUST** increment CurrentMajor above.
// Non-breaking changes to the aibridged API **MUST** increment CurrentMinor
// above.
var CurrentVersion = apiversion.New(CurrentMajor, CurrentMinor)
97 changes: 97 additions & 0 deletions coderd/httpmw/aigatewaykey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package httpmw

import (
"context"
"crypto/subtle"
"net/http"

"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
)

type aiGatewayKeyContextKey struct{}

// AIGatewayKeyAuthOptional returns the AI Gateway key that authenticated the
// request, if any. The key is used by the /serve handler to record liveness
// against the authenticating key.
func AIGatewayKeyAuthOptional(r *http.Request) (database.AIGatewayKey, bool) {
key, ok := r.Context().Value(aiGatewayKeyContextKey{}).(database.AIGatewayKey)
return key, ok
}

// ExtractAIGatewayKeyConfig configures ExtractAIGatewayKeyAuthenticated.
type ExtractAIGatewayKeyConfig struct {
DB database.Store
// Optional, when true, allows the request to proceed unauthenticated. The
// next handler can detect authentication via AIGatewayKeyAuthOptional.
Optional bool
}

// ExtractAIGatewayKeyAuthenticated authenticates a request as a standalone AI
// Gateway replica using the X-AI-Governance-Gateway-Key header. The header
// value is hashed and compared against the ai_gateway_keys table, mirroring the
// external provisioner daemon key flow in
// ExtractProvisionerDaemonAuthenticated.
//
// One key may authenticate many replicas at once; keys are not unique per
// connection. A deleted key fails the next reconnect because the row no longer
// exists, but does not disconnect sessions already established.
func ExtractAIGatewayKeyAuthenticated(opts ExtractAIGatewayKeyConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

handleOptional := func(code int, response codersdk.Response) {
if opts.Optional {
next.ServeHTTP(w, r)
return
}
httpapi.Write(ctx, w, code, response)
}

key := r.Header.Get(codersdk.AIGatewayKeyHeader)
if key == "" {
handleOptional(http.StatusUnauthorized, codersdk.Response{
Message: "AI Gateway key required.",
})
return
}

hashedKey := apikey.HashSecret(key)
// nolint:gocritic // System must look up the AI Gateway key to authenticate the request.
gatewayKey, err := opts.DB.GetAIGatewayKeyByHashedSecret(dbauthz.AsSystemRestricted(ctx), hashedKey)
if err != nil {
if httpapi.Is404Error(err) {
handleOptional(http.StatusUnauthorized, codersdk.Response{
Message: "AI Gateway key invalid.",
})
return
}
handleOptional(http.StatusInternalServerError, codersdk.Response{
Message: "Failed to look up AI Gateway key.",
Detail: err.Error(),
})
return
}

// Defense in depth: the lookup already matches on hashed_secret, but
// confirm equality in constant time to avoid relying solely on the
// query.
if subtle.ConstantTimeCompare(gatewayKey.HashedSecret, hashedKey) != 1 {
handleOptional(http.StatusUnauthorized, codersdk.Response{
Message: "AI Gateway key invalid.",
})
return
}

ctx = context.WithValue(ctx, aiGatewayKeyContextKey{}, gatewayKey)
// nolint:gocritic // Authenticating as an AI Gateway replica, which
// acts as the AI Bridge daemon.
ctx = dbauthz.AsAIBridged(ctx)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
117 changes: 117 additions & 0 deletions coderd/httpmw/aigatewaykey_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package httpmw_test

import (
"database/sql"
"net/http"
"net/http/httptest"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"

"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/codersdk"
)

func TestExtractAIGatewayKeyAuthenticated(t *testing.T) {
t.Parallel()

const secret = "this-is-a-test-gateway-key"

t.Run("MissingHeader", func(t *testing.T) {
t.Parallel()
db := dbmock.NewMockStore(gomock.NewController(t))
rw := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
newGatewayHandler(db, false).ServeHTTP(rw, r)
require.Equal(t, http.StatusUnauthorized, rw.Code)
})

t.Run("InvalidKey", func(t *testing.T) {
t.Parallel()
db := dbmock.NewMockStore(gomock.NewController(t))
db.EXPECT().GetAIGatewayKeyByHashedSecret(gomock.Any(), apikey.HashSecret(secret)).
Return(database.AIGatewayKey{}, sql.ErrNoRows)
rw := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set(codersdk.AIGatewayKeyHeader, secret)
newGatewayHandler(db, false).ServeHTTP(rw, r)
require.Equal(t, http.StatusUnauthorized, rw.Code)
})

t.Run("LookupError", func(t *testing.T) {
t.Parallel()
db := dbmock.NewMockStore(gomock.NewController(t))
db.EXPECT().GetAIGatewayKeyByHashedSecret(gomock.Any(), apikey.HashSecret(secret)).
Return(database.AIGatewayKey{}, xerrors.New("boom"))
rw := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set(codersdk.AIGatewayKeyHeader, secret)
newGatewayHandler(db, false).ServeHTTP(rw, r)
require.Equal(t, http.StatusInternalServerError, rw.Code)
})

t.Run("Success", func(t *testing.T) {
t.Parallel()
db := dbmock.NewMockStore(gomock.NewController(t))
key := database.AIGatewayKey{
ID: uuid.New(),
Name: "test-key",
HashedSecret: apikey.HashSecret(secret),
}
db.EXPECT().GetAIGatewayKeyByHashedSecret(gomock.Any(), apikey.HashSecret(secret)).
Return(key, nil)

var (
gotKey database.AIGatewayKey
gotOK bool
)
handler := httpmw.ExtractAIGatewayKeyAuthenticated(httpmw.ExtractAIGatewayKeyConfig{DB: db})(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey, gotOK = httpmw.AIGatewayKeyAuthOptional(r)
w.WriteHeader(http.StatusOK)
}),
)

rw := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set(codersdk.AIGatewayKeyHeader, secret)
handler.ServeHTTP(rw, r)

require.Equal(t, http.StatusOK, rw.Code)
require.True(t, gotOK)
require.Equal(t, key.ID, gotKey.ID)
})

t.Run("OptionalPassesThrough", func(t *testing.T) {
t.Parallel()
db := dbmock.NewMockStore(gomock.NewController(t))
var called bool
handler := httpmw.ExtractAIGatewayKeyAuthenticated(httpmw.ExtractAIGatewayKeyConfig{DB: db, Optional: true})(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, ok := httpmw.AIGatewayKeyAuthOptional(r)
require.False(t, ok)
called = true
w.WriteHeader(http.StatusOK)
}),
)
rw := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
handler.ServeHTTP(rw, r)
require.Equal(t, http.StatusOK, rw.Code)
require.True(t, called)
})
}

func newGatewayHandler(db database.Store, optional bool) http.Handler {
return httpmw.ExtractAIGatewayKeyAuthenticated(httpmw.ExtractAIGatewayKeyConfig{DB: db, Optional: optional})(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}),
)
}
4 changes: 4 additions & 0 deletions codersdk/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ const (
// ProvisionerDaemonKey contains the authentication key for an external provisioner daemon
ProvisionerDaemonKey = "Coder-Provisioner-Daemon-Key"

// AIGatewayKeyHeader contains the authentication key for a standalone AI
// Gateway replica connecting to coderd.
AIGatewayKeyHeader = "X-AI-Governance-Gateway-Key"

// BuildVersionHeader contains build information of Coder.
BuildVersionHeader = "X-Coder-Build-Version"

Expand Down
7 changes: 7 additions & 0 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading