diff --git a/coderd/aibridge/factory.go b/coderd/aibridge/factory.go index 3db9dc8a1d7d8..41f25fb454ecd 100644 --- a/coderd/aibridge/factory.go +++ b/coderd/aibridge/factory.go @@ -31,6 +31,27 @@ func SourceFromContext(ctx context.Context) Source { return src } +type delegatedAPIKeyIDCtxKey struct{} + +// WithDelegatedAPIKeyID returns a copy of ctx carrying an API key ID on whose +// behalf the request is being made. The in-process aibridge transport requires +// this on every RoundTrip and rejects calls whose context lacks it. +// +// The caller is responsible for having established that the user owning this +// key authorized the request: aibridged validates only that the key exists, +// has not expired, and belongs to a non-deleted, non-system user. It does not +// verify the key secret, because the caller never has it. +func WithDelegatedAPIKeyID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, delegatedAPIKeyIDCtxKey{}, id) +} + +// DelegatedAPIKeyIDFromContext returns the API key ID attached by +// [WithDelegatedAPIKeyID] and whether a non-empty value was set. +func DelegatedAPIKeyIDFromContext(ctx context.Context) (string, bool) { + id, ok := ctx.Value(delegatedAPIKeyIDCtxKey{}).(string) + return id, ok && id != "" +} + // TransportFactory returns an [http.RoundTripper] that dispatches an aibridge // request in-process for a given ai_providers row. // diff --git a/coderd/aibridged/aibridged_test.go b/coderd/aibridged/aibridged_test.go index b2444be0d6112..229ff260a5e76 100644 --- a/coderd/aibridged/aibridged_test.go +++ b/coderd/aibridged/aibridged_test.go @@ -174,6 +174,220 @@ func TestServeHTTP_FailureModes(t *testing.T) { } } +// When the request context carries a delegated API key ID (set by the +// in-process transport on behalf of a trusted caller like chatd), the handler +// must authenticate via the key_id field, skipping the header-based key +// extraction entirely. Validation succeeds or fails exactly as it would for a +// real API key. Delegation is orthogonal to BYOK: in BYOK mode the user's own +// LLM credentials must still be forwarded upstream while the Coder governance +// token is stripped. +func TestServeHTTP_DelegatedAPIKey(t *testing.T) { + t.Parallel() + + const testKeyID = "abcdef1234" + + tests := []struct { + name string + reqHeaders map[string]string + applyMocks func(t *testing.T, client *mock.MockDRPCClient, pool *mock.MockPooler, mockH *mockHandler) + expectStatus int + expectHandled bool + expectPresent map[string]string + expectAbsent []string + }{ + { + name: "valid centralized", + applyMocks: func(t *testing.T, client *mock.MockDRPCClient, pool *mock.MockPooler, mockH *mockHandler) { + client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, in *proto.IsAuthorizedRequest) (*proto.IsAuthorizedResponse, error) { + assert.Equal(t, testKeyID, in.GetKeyId(), "handler must use KeyId for delegated requests") + assert.Empty(t, in.GetKey(), "handler must not set Key for delegated requests") + return &proto.IsAuthorizedResponse{ + OwnerId: uuid.NewString(), + ApiKeyId: testKeyID, + Username: "u", + }, nil + }) + pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockH, nil) + }, + expectStatus: http.StatusOK, + expectHandled: true, + expectAbsent: []string{ + "Authorization", + "X-Api-Key", + agplaibridge.HeaderCoderToken, + }, + }, + { + name: "valid BYOK preserves user credentials", + reqHeaders: map[string]string{ + // Marks BYOK; this header must be stripped before + // forwarding upstream. + agplaibridge.HeaderCoderToken: "should-not-be-present", + // The user's own LLM credential; must be preserved. + "Authorization": "Bearer sk-ant-oat01-user-token", + }, + applyMocks: func(_ *testing.T, client *mock.MockDRPCClient, pool *mock.MockPooler, mockH *mockHandler) { + client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).Return(&proto.IsAuthorizedResponse{ + OwnerId: uuid.NewString(), + ApiKeyId: testKeyID, + Username: "u", + }, nil) + pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockH, nil) + }, + expectStatus: http.StatusOK, + expectHandled: true, + expectPresent: map[string]string{ + "Authorization": "Bearer sk-ant-oat01-user-token", + }, + expectAbsent: []string{ + agplaibridge.HeaderCoderToken, + }, + }, + { + name: "invalid", + applyMocks: func(_ *testing.T, client *mock.MockDRPCClient, _ *mock.MockPooler, _ *mockHandler) { + client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).Return(nil, xerrors.New("unknown key")) + }, + expectStatus: http.StatusForbidden, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + srv, client, pool := newTestServer(t) + conn := &mockDRPCConn{} + client.EXPECT().DRPCConn().AnyTimes().Return(conn) + mockH := &mockHandler{} + tc.applyMocks(t, client, pool, mockH) + + ctx := agplaibridge.WithDelegatedAPIKeyID(testutil.Context(t, testutil.WaitShort), testKeyID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://aibridge/openai/v1/chat/completions", nil) + require.NoError(t, err) + for k, v := range tc.reqHeaders { + req.Header.Set(k, v) + } + + rw := httptest.NewRecorder() + srv.ServeHTTP(rw, req) + + require.Equal(t, tc.expectStatus, rw.Code) + if tc.expectHandled { + require.NotNil(t, mockH.headersReceived, "downstream handler must be invoked") + for h, v := range tc.expectPresent { + require.Equal(t, v, mockH.headersReceived.Get(h), "header %q must be preserved", h) + } + for _, h := range tc.expectAbsent { + require.Empty(t, mockH.headersReceived.Get(h), "header %q must be stripped", h) + } + } else { + require.Nil(t, mockH.headersReceived, "downstream handler must not be invoked on auth failure") + } + }) + } +} + +// End-to-end: a real transport factory wired to a real server, with BYOK in +// effect. The delegated key ID identifies the user (no Coder token over the +// wire) while the user's own LLM credentials in Authorization must flow +// through to the downstream handler. The Coder governance token, if set by +// the caller, must be stripped. +func TestServeHTTP_DelegatedAPIKey_BYOK_Integration(t *testing.T) { + t.Parallel() + + const ( + testKeyID = "abcdef1234" + // nolint:gosec // Fake LLM credential for assertion comparison. + userLLMToken = "Bearer sk-ant-oat01-user-byok-token" + ) + + srv, client, pool := newTestServer(t) + conn := &mockDRPCConn{} + client.EXPECT().DRPCConn().AnyTimes().Return(conn) + mockH := &mockHandler{} + + client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, in *proto.IsAuthorizedRequest) (*proto.IsAuthorizedResponse, error) { + assert.Equal(t, testKeyID, in.GetKeyId(), "delegated identity must be carried in KeyId") + assert.Empty(t, in.GetKey(), "Key must not be set on delegated requests") + return &proto.IsAuthorizedResponse{ + OwnerId: uuid.NewString(), + ApiKeyId: testKeyID, + Username: "u", + }, nil + }) + pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockH, nil) + + factory := aibridged.NewTransportFactory(srv) + rt, err := factory.TransportFor(uuid.New(), agplaibridge.SourceAgents) + require.NoError(t, err) + + ctx := agplaibridge.WithDelegatedAPIKeyID(testutil.Context(t, testutil.WaitShort), testKeyID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://aibridge/anthropic/v1/messages", nil) + require.NoError(t, err) + // HeaderCoderToken marks the request as BYOK. Its value is irrelevant on + // the delegated path (identity comes from context) and it must be + // stripped before forwarding upstream. + req.Header.Set(agplaibridge.HeaderCoderToken, "ignored-on-delegated-path") + // The user's own LLM credential; must reach the downstream handler. + req.Header.Set("Authorization", userLLMToken) + + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + require.NotNil(t, mockH.headersReceived, "downstream handler must be invoked") + require.Equal(t, userLLMToken, mockH.headersReceived.Get("Authorization"), + "user's BYOK credential must be preserved end-to-end") + require.Empty(t, mockH.headersReceived.Get(agplaibridge.HeaderCoderToken), + "Coder governance token must be stripped before forwarding upstream") +} + +// End-to-end: a real transport factory wired to a real server. Verifies the +// delegated key ID survives the in-memory round-trip and is treated as the +// authoritative caller identity by the handler, without any HTTP-layer header +// extraction. +func TestServeHTTP_DelegatedAPIKey_Integration(t *testing.T) { + t.Parallel() + + const testKeyID = "abcdef1234" + + srv, client, pool := newTestServer(t) + conn := &mockDRPCConn{} + client.EXPECT().DRPCConn().AnyTimes().Return(conn) + mockH := &mockHandler{} + + client.EXPECT().IsAuthorized(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, in *proto.IsAuthorizedRequest) (*proto.IsAuthorizedResponse, error) { + assert.Equal(t, testKeyID, in.GetKeyId()) + assert.Empty(t, in.GetKey()) + return &proto.IsAuthorizedResponse{ + OwnerId: uuid.NewString(), + ApiKeyId: testKeyID, + Username: "u", + }, nil + }) + pool.EXPECT().Acquire(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mockH, nil) + + factory := aibridged.NewTransportFactory(srv) + rt, err := factory.TransportFor(uuid.New(), agplaibridge.SourceAgents) + require.NoError(t, err) + + ctx := agplaibridge.WithDelegatedAPIKeyID(testutil.Context(t, testutil.WaitShort), testKeyID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://aibridge/openai/v1/chat/completions", nil) + require.NoError(t, err) + + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, mockH.headersReceived, "downstream handler must observe the delegated request") +} + func TestServeHTTP_StripCoderToken(t *testing.T) { t.Parallel() diff --git a/coderd/aibridged/http.go b/coderd/aibridged/http.go index 6ba0455cfff27..5d75fa623f070 100644 --- a/coderd/aibridged/http.go +++ b/coderd/aibridged/http.go @@ -56,33 +56,56 @@ func (s *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) { authMode = "byok" } - key := strings.TrimSpace(agplaibridge.ExtractAuthToken(r.Header)) - if key == "" { - // Some clients (e.g. Claude) send a HEAD request - // without credentials to check connectivity. - if r.Method == http.MethodHead { - logger.Info(ctx, "unauthenticated HEAD request") - } else { - logger.Warn(ctx, "no auth key provided") + // When the request arrived via the in-process transport, the caller + // has placed a delegated API key ID on the context. We trust that the + // caller already established the user's identity and only validate + // liveness; the caller does not have (and cannot send) the key secret. + // Delegation is orthogonal to BYOK: a delegated request still carries + // the user's own LLM credentials in Authorization/X-Api-Key when BYOK + // is in effect. + var ( + authReq *proto.IsAuthorizedRequest + sessionKey string + delegated bool + ) + if delegatedID, ok := agplaibridge.DelegatedAPIKeyIDFromContext(ctx); ok { + authReq = &proto.IsAuthorizedRequest{KeyId: delegatedID} + delegated = true + // SessionKey is consumed only by the injected MCP path, which is + // not available to delegated callers (they have no secret). + } else { + key := strings.TrimSpace(agplaibridge.ExtractAuthToken(r.Header)) + if key == "" { + // Some clients (e.g. Claude) send a HEAD request + // without credentials to check connectivity. + if r.Method == http.MethodHead { + logger.Info(ctx, "unauthenticated HEAD request") + } else { + logger.Warn(ctx, "no auth key provided") + } + http.Error(rw, ErrNoAuthKey.Error(), http.StatusBadRequest) + return } - http.Error(rw, ErrNoAuthKey.Error(), http.StatusBadRequest) - return + authReq = &proto.IsAuthorizedRequest{Key: key} + sessionKey = key } - // Strip every header that may carry the Coder token so it is - // never forwarded to upstream providers. After stripping, the - // aibridge library can treat the request as a normal LLM API call - // with no Coder-specific information. + // Strip every header that may carry the Coder token so it is never + // forwarded to upstream providers. Runs for both header-auth and + // delegated requests: a delegated caller may forward the user's BYOK + // headers, and we still want to scrub any Coder-specific credentials + // that may have leaked through. After stripping, the aibridge library + // can treat the request as a normal LLM API call with no + // Coder-specific information. if byok { - // In BYOK mode the token is in X-Coder-AI-Governance-Token; - // Authorization and X-Api-Key carry the user's own LLM credentials - // and must be preserved. + // In BYOK mode the Coder token is in X-Coder-AI-Governance-Token; + // Authorization and X-Api-Key carry the user's own LLM + // credentials and must be preserved. r.Header.Del(agplaibridge.HeaderCoderToken) } else { - // In centralized mode the token may be in Authorization (the - // documented path) or X-Api-Key (legacy clients that set - // ANTHROPIC_API_KEY to their Coder token). Both are - // stripped. + // In centralized mode the Coder token may be in Authorization + // (the documented path) or X-Api-Key (legacy clients that set + // ANTHROPIC_API_KEY to their Coder token). Both are stripped. r.Header.Del("Authorization") r.Header.Del("X-Api-Key") } @@ -94,9 +117,19 @@ func (s *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) { return } - resp, err := client.IsAuthorized(ctx, &proto.IsAuthorizedRequest{Key: key}) + // Attach auth attributes used by all log lines below. "source" is the + // transport origin (e.g., "agents" for in-process callers, empty for + // network callers); "auth_delegated" distinguishes header-based from + // context-delegated authentication. + logger = logger.With( + slog.F("source", string(agplaibridge.SourceFromContext(ctx))), + slog.F("auth_mode", authMode), + slog.F("auth_delegated", delegated), + ) + + resp, err := client.IsAuthorized(ctx, authReq) if err != nil { - logger.Warn(ctx, "key authorization check failed", slog.Error(err), slog.F("auth_mode", authMode)) + logger.Warn(ctx, "key authorization check failed", slog.Error(err)) http.Error(rw, ErrUnauthorized.Error(), http.StatusForbidden) return } @@ -118,7 +151,7 @@ func (s *Server) ServeHTTP(rw http.ResponseWriter, r *http.Request) { } handler, err := s.GetRequestHandler(ctx, Request{ - SessionKey: key, + SessionKey: sessionKey, APIKeyID: resp.ApiKeyId, InitiatorID: id, }) diff --git a/coderd/aibridged/mcp.go b/coderd/aibridged/mcp.go index a5ec28c509c18..72e1ed0f5e6ba 100644 --- a/coderd/aibridged/mcp.go +++ b/coderd/aibridged/mcp.go @@ -79,12 +79,20 @@ func (m *MCPProxyFactory) retrieveMCPServerConfigs(ctx context.Context, req Requ proxiers := make(map[string]mcp.ServerProxier, len(mcpSrvCfgs.GetExternalAuthMcpConfigs())+1) // Extra one for Coder MCP server. if mcpSrvCfgs.GetCoderMcpConfig() != nil { - // Setup the Coder MCP server proxy. - coderMCPProxy, err := m.newStreamableHTTPServerProxy(mcpSrvCfgs.GetCoderMcpConfig(), req.SessionKey) // The session key is used to auth against our internal MCP server. - if err != nil { - m.logger.Warn(ctx, "failed to create MCP server proxy", slog.F("mcp_server_id", mcpSrvCfgs.GetCoderMcpConfig().GetId()), slog.Error(err)) + // Delegated callers (e.g., chatd) do not hold the user's API key + // secret and so cannot authenticate against the Coder MCP server. + // Skip the proxy in that case rather than attempting a connection + // with an empty bearer token, which will fail upstream. + if req.SessionKey == "" { + m.logger.Debug(ctx, "skipping Coder MCP server proxy: no session key (delegated request)", slog.F("mcp_server_id", mcpSrvCfgs.GetCoderMcpConfig().GetId())) } else { - proxiers[InternalMCPServerID] = coderMCPProxy + // Setup the Coder MCP server proxy. + coderMCPProxy, err := m.newStreamableHTTPServerProxy(mcpSrvCfgs.GetCoderMcpConfig(), req.SessionKey) // The session key is used to auth against our internal MCP server. + if err != nil { + m.logger.Warn(ctx, "failed to create MCP server proxy", slog.F("mcp_server_id", mcpSrvCfgs.GetCoderMcpConfig().GetId()), slog.Error(err)) + } else { + proxiers[InternalMCPServerID] = coderMCPProxy + } } } diff --git a/coderd/aibridged/proto/aibridged.pb.go b/coderd/aibridged/proto/aibridged.pb.go index 890d5b5e619c5..c364aeda40559 100644 --- a/coderd/aibridged/proto/aibridged.pb.go +++ b/coderd/aibridged/proto/aibridged.pb.go @@ -1118,7 +1118,16 @@ type IsAuthorizedRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields + // key is the full "-" API token presented over HTTP. + // Mutually exclusive with key_id. Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // key_id authenticates a request without the secret. Used for delegated + // calls from in-process callers (e.g., chatd) that have already + // established the user's identity out-of-band and have only the API key + // ID, not the secret. When set, the server validates only that the key + // exists, has not expired, and belongs to a non-deleted non-system user. + // Mutually exclusive with key. + KeyId string `protobuf:"bytes,2,opt,name=key_id,json=keyId,proto3" json:"key_id,omitempty"` } func (x *IsAuthorizedRequest) Reset() { @@ -1160,6 +1169,13 @@ func (x *IsAuthorizedRequest) GetKey() string { return "" } +func (x *IsAuthorizedRequest) GetKeyId() string { + if x != nil { + return x.KeyId + } + return "" +} + type IsAuthorizedResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1451,76 +1467,77 @@ var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{ 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x27, + 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x3e, 0x0a, 0x13, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x22, 0x6b, 0x0a, 0x14, 0x49, 0x73, 0x41, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61, 0x70, - 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, - 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, - 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, - 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, - 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, - 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, - 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, - 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, - 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, + 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x15, 0x0a, 0x06, 0x6b, 0x65, 0x79, 0x5f, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6b, 0x65, 0x79, 0x49, 0x64, 0x22, 0x6b, + 0x0a, 0x14, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x49, + 0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x49, 0x64, 0x12, + 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x32, 0xa9, 0x04, 0x0a, 0x08, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, + 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, + 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, + 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x12, 0x25, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, - 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, - 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, - 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, - 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, - 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, - 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, - 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, - 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, - 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, - 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, - 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, - 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, - 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, - 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, - 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, - 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x55, - 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x0c, - 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, - 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, - 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, - 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64, 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, - 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, + 0x63, 0x6f, 0x72, 0x64, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, + 0x45, 0x6e, 0x64, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x53, 0x0a, + 0x10, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, + 0x65, 0x12, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x56, 0x0a, 0x11, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, + 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, + 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x52, 0x65, + 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, + 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, + 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x59, 0x0a, 0x12, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, + 0x68, 0x74, 0x12, 0x20, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, + 0x6f, 0x72, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x54, 0x68, 0x6f, 0x75, 0x67, 0x68, 0x74, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xeb, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5c, 0x0a, 0x13, 0x47, + 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x73, 0x12, 0x21, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, + 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, + 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7a, 0x0a, 0x1d, 0x47, 0x65, 0x74, + 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x12, 0x2b, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, + 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, + 0x47, 0x65, 0x74, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x41, 0x63, 0x63, 0x65, + 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x42, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x55, 0x0a, 0x0a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x12, 0x47, 0x0a, 0x0c, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x64, 0x12, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x32, 0x5a, 0x30, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x64, + 0x2f, 0x61, 0x69, 0x62, 0x72, 0x69, 0x64, 0x67, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/coderd/aibridged/proto/aibridged.proto b/coderd/aibridged/proto/aibridged.proto index 90d1c90e00f9e..cd614115168dc 100644 --- a/coderd/aibridged/proto/aibridged.proto +++ b/coderd/aibridged/proto/aibridged.proto @@ -134,7 +134,16 @@ message GetMCPServerAccessTokensBatchResponse{ } message IsAuthorizedRequest { + // key is the full "-" API token presented over HTTP. + // Mutually exclusive with key_id. string key = 1; + // key_id authenticates a request without the secret. Used for delegated + // calls from in-process callers (e.g., chatd) that have already + // established the user's identity out-of-band and have only the API key + // ID, not the secret. When set, the server validates only that the key + // exists, has not expired, and belongs to a non-deleted non-system user. + // Mutually exclusive with key. + string key_id = 2; } message IsAuthorizedResponse { diff --git a/coderd/aibridged/transport.go b/coderd/aibridged/transport.go index 391f3b09c44c7..214e3d6399a39 100644 --- a/coderd/aibridged/transport.go +++ b/coderd/aibridged/transport.go @@ -45,6 +45,14 @@ type inMemoryRoundTripper struct { } func (t *inMemoryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // The in-process transport requires the caller to have placed the + // delegated API key ID on the context. Without it, aibridged has no + // identity to act under. Fail fast at the transport boundary so the + // handler can assume the invariant. + if _, ok := aibridge.DelegatedAPIKeyIDFromContext(req.Context()); !ok { + return nil, xerrors.New("aibridged in-memory transport requires WithDelegatedAPIKeyID on the request context") + } + pr, pw := io.Pipe() rw := &pipeResponseWriter{ header: http.Header{}, diff --git a/coderd/aibridged/transport_test.go b/coderd/aibridged/transport_test.go index 9d2d39864ddea..9dcae475fe3cc 100644 --- a/coderd/aibridged/transport_test.go +++ b/coderd/aibridged/transport_test.go @@ -50,7 +50,7 @@ func TestTransportFactory_TransportFor(t *testing.T) { rt, err := aibridged.NewTransportFactory(handler).TransportFor(uuid.New(), aibridge.SourceAgents) require.NoError(t, err) - ctx := testutil.Context(t, testutil.WaitShort) + ctx := aibridge.WithDelegatedAPIKeyID(testutil.Context(t, testutil.WaitShort), "test-key-id") req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://aibridge/v1/test", nil) require.NoError(t, err) @@ -75,7 +75,7 @@ func TestInMemoryRoundTripper_PassesHeadersAndStatus(t *testing.T) { rt, err := aibridged.NewTransportFactory(handler).TransportFor(uuid.New(), aibridge.SourceAgents) require.NoError(t, err) - ctx := testutil.Context(t, testutil.WaitShort) + ctx := aibridge.WithDelegatedAPIKeyID(testutil.Context(t, testutil.WaitShort), "test-key-id") req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://aibridge/v1/test", nil) require.NoError(t, err) @@ -125,7 +125,7 @@ func TestInMemoryRoundTripper_Streams(t *testing.T) { rt, err := aibridged.NewTransportFactory(handler).TransportFor(uuid.New(), aibridge.SourceAgents) require.NoError(t, err) - ctx := testutil.Context(t, testutil.WaitShort) + ctx := aibridge.WithDelegatedAPIKeyID(testutil.Context(t, testutil.WaitShort), "test-key-id") req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://aibridge/stream", nil) require.NoError(t, err) @@ -166,6 +166,7 @@ func TestInMemoryRoundTripper_CancelCloses(t *testing.T) { parentCtx := testutil.Context(t, testutil.WaitShort) ctx, cancel := context.WithCancel(parentCtx) + ctx = aibridge.WithDelegatedAPIKeyID(ctx, "test-key-id") req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://aibridge/stream", nil) require.NoError(t, err) @@ -207,7 +208,7 @@ func TestInMemoryRoundTripper_ConcurrentRequests(t *testing.T) { for i := range n { wg.Go(func() { payload := fmt.Sprintf("payload-%d", i) - ctx := testutil.Context(t, testutil.WaitShort) + ctx := aibridge.WithDelegatedAPIKeyID(testutil.Context(t, testutil.WaitShort), "test-key-id") req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://aibridge/echo", strings.NewReader(payload)) if err != nil { errs <- err @@ -250,7 +251,7 @@ func TestInMemoryRoundTripper_HandlerPanic(t *testing.T) { rt, err := aibridged.NewTransportFactory(handler).TransportFor(uuid.New(), aibridge.SourceAgents) require.NoError(t, err) - ctx := testutil.Context(t, testutil.WaitShort) + ctx := aibridge.WithDelegatedAPIKeyID(testutil.Context(t, testutil.WaitShort), "test-key-id") req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://aibridge/panic", nil) require.NoError(t, err) @@ -264,6 +265,74 @@ func TestInMemoryRoundTripper_HandlerPanic(t *testing.T) { require.Contains(t, err.Error(), "handler panicked") } +// The in-memory transport must reject any RoundTrip whose context does not +// carry a delegated API key ID. The handler relies on this invariant to know +// the request has a delegated identity attached. +func TestInMemoryRoundTripper_RequiresDelegatedAPIKeyID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + withCtx func(context.Context) context.Context + wantErr bool + }{ + { + name: "missing delegated key ID", + withCtx: func(ctx context.Context) context.Context { return ctx }, + wantErr: true, + }, + { + name: "empty delegated key ID", + withCtx: func(ctx context.Context) context.Context { + return aibridge.WithDelegatedAPIKeyID(ctx, "") + }, + wantErr: true, + }, + { + name: "valid delegated key ID", + withCtx: func(ctx context.Context) context.Context { + return aibridge.WithDelegatedAPIKeyID(ctx, "test-key-id") + }, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + handlerCalled := make(chan struct{}, 1) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled <- struct{}{} + w.WriteHeader(http.StatusOK) + }) + + rt, err := aibridged.NewTransportFactory(handler).TransportFor(uuid.New(), aibridge.SourceAgents) + require.NoError(t, err) + + ctx := tc.withCtx(testutil.Context(t, testutil.WaitShort)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://aibridge/v1/test", nil) + require.NoError(t, err) + + resp, err := rt.RoundTrip(req) + if tc.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), "WithDelegatedAPIKeyID") + // Handler must not have been invoked. + select { + case <-handlerCalled: + t.Fatal("handler invoked despite transport rejecting the request") + default: + } + return + } + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + }) + } +} + // A handler that returns without writing must not block RoundTrip; the caller // gets a zero-length 200 OK. func TestInMemoryRoundTripper_HandlerReturnsWithoutWriting(t *testing.T) { @@ -274,7 +343,7 @@ func TestInMemoryRoundTripper_HandlerReturnsWithoutWriting(t *testing.T) { rt, err := aibridged.NewTransportFactory(handler).TransportFor(uuid.New(), aibridge.SourceAgents) require.NoError(t, err) - ctx := testutil.Context(t, testutil.WaitShort) + ctx := aibridge.WithDelegatedAPIKeyID(testutil.Context(t, testutil.WaitShort), "test-key-id") req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://aibridge/noop", nil) require.NoError(t, err) diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index ade84b34ff669..c593b18f798a3 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -38,12 +38,13 @@ var ( // matching. // TODO: return these errors to the client in a more structured/comparable // way. - ErrInvalidKey = xerrors.New("invalid key") - ErrUnknownKey = xerrors.New("unknown key") - ErrExpired = xerrors.New("expired") - ErrUnknownUser = xerrors.New("unknown user") - ErrDeletedUser = xerrors.New("deleted user") - ErrSystemUser = xerrors.New("system user") + ErrInvalidKey = xerrors.New("invalid key") + ErrUnknownKey = xerrors.New("unknown key") + ErrExpired = xerrors.New("expired") + ErrUnknownUser = xerrors.New("unknown user") + ErrDeletedUser = xerrors.New("deleted user") + ErrSystemUser = xerrors.New("system user") + ErrAmbiguousAuth = xerrors.New("both key and key_id set; exactly one required") ErrNoExternalAuthLinkFound = xerrors.New("no external auth link found") ) @@ -550,6 +551,15 @@ externalAuthLoop: // IsAuthorized validates a given Coder API key and returns the user ID to which it belongs (if valid). // +// SECURITY: when in.KeyId is set (the "delegated" path), this method trusts the +// caller's claim of identity and skips the key-secret check. This is safe only +// because the DRPCServer is reachable solely via the in-process +// [aibridged.MemTransportPipe]; the handler itself cannot tell whether it was +// invoked over the in-memory pipe or a network socket. If this RPC is ever +// exposed over a network boundary, any caller who knows a valid 10-char key ID +// (which is not secret) could authenticate as the key's owner without the +// secret. Do not bind this DRPCServer to a network listener. +// // NOTE: this should really be using the code from [httpmw.ExtractAPIKey]. That function not only validates the key // but handles many other cases like updating last used, expiry, etc. This code does not currently use it for // a few reasons: @@ -565,10 +575,26 @@ func (s *Server) IsAuthorized(ctx context.Context, in *proto.IsAuthorizedRequest //nolint:gocritic // AIBridged has specific authz rules. ctx = dbauthz.AsAIBridged(ctx) - // Key matches expected format. - keyID, keySecret, err := httpmw.SplitAPIToken(in.GetKey()) - if err != nil { - return nil, ErrInvalidKey + var ( + keyID string + keySecret string + // delegated requests skip the secret check: the caller never + // has the secret. Trust is established at the in-process + // transport boundary, not in this RPC. + delegated bool + ) + switch { + case in.GetKey() != "" && in.GetKeyId() != "": + return nil, ErrAmbiguousAuth + case in.GetKeyId() != "": + keyID = in.GetKeyId() + delegated = true + default: + var err error + keyID, keySecret, err = httpmw.SplitAPIToken(in.GetKey()) + if err != nil { + return nil, ErrInvalidKey + } } // Key exists. @@ -584,8 +610,8 @@ func (s *Server) IsAuthorized(ctx context.Context, in *proto.IsAuthorizedRequest return nil, ErrExpired } - // Key secret matches. - if !apikey.ValidateHash(key.HashedSecret, keySecret) { + // Key secret matches (skipped for delegated callers). + if !delegated && !apikey.ValidateHash(key.HashedSecret, keySecret) { return nil, ErrInvalidKey } diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index cb153a401f8e4..eb2f413e1ed5b 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -199,6 +199,148 @@ func TestAuthorization(t *testing.T) { } } +// When IsAuthorizedRequest carries KeyId instead of Key, the server skips +// the secret check and validates only that the key exists, is unexpired, and +// belongs to a non-deleted non-system user. This is the path used by +// in-process delegated callers (e.g., chatd) that hold only the key ID. +func TestAuthorization_Delegated(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + mocksFn func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) + bothFields bool + expectedErr error + }{ + { + name: "valid", + mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) { + db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(user, nil) + }, + }, + { + name: "unknown key", + expectedErr: aibridgedserver.ErrUnknownKey, + mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, _ database.User) { + db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(database.APIKey{}, sql.ErrNoRows) + }, + }, + { + name: "expired", + expectedErr: aibridgedserver.ErrExpired, + mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, _ database.User) { + apiKey.ExpiresAt = dbtime.Now().Add(-time.Hour) + db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) + }, + }, + { + // Sending both Key and KeyId is an API misuse and must be + // rejected to avoid ambiguity about which path was taken. + name: "both fields set", + bothFields: true, + expectedErr: aibridgedserver.ErrAmbiguousAuth, + }, + { + // A bogus secret has no effect on the delegated path because + // the secret is never checked. This is the load-bearing + // security property: trust is established out-of-band, not in + // this RPC. + name: "secret hash mismatch is ignored", + mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) { + apiKey.HashedSecret = []byte("not-the-real-hash") + db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(user, nil) + }, + }, + { + // The delegated path must still reject keys whose owner has + // been deleted; trust at the transport boundary does not + // extend to bypassing user-status checks. + name: "deleted user", + expectedErr: aibridgedserver.ErrDeletedUser, + mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) { + db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(database.User{ID: user.ID, Deleted: true}, nil) + }, + }, + { + // Likewise, a system user must never be authenticated through + // the delegated path. + name: "system user", + expectedErr: aibridgedserver.ErrSystemUser, + mocksFn: func(db *dbmock.MockStore, apiKey database.APIKey, user database.User) { + db.EXPECT().GetAPIKeyByID(gomock.Any(), apiKey.ID).Times(1).Return(apiKey, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Times(1).Return(database.User{ID: user.ID, IsSystem: true}, nil) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + db := dbmock.NewMockStore(ctrl) + logger := testutil.Logger(t) + + now := dbtime.Now() + user := database.User{ + ID: uuid.New(), + Email: "test@coder.com", + Username: "test", + Name: "Test User", + CreatedAt: now, + UpdatedAt: now, + RBACRoles: []string{}, + LoginType: database.LoginTypePassword, + Status: database.UserStatusActive, + LastSeenAt: now, + } + keyID, _ := cryptorand.String(10) + _, keySecretHashed, _ := apikey.GenerateSecret(22) + apiKey := database.APIKey{ + ID: keyID, + LifetimeSeconds: 86400, + HashedSecret: keySecretHashed, + UserID: user.ID, + LastUsed: now, + ExpiresAt: now.Add(time.Hour), + CreatedAt: now, + UpdatedAt: now, + LoginType: database.LoginTypePassword, + Scopes: []database.APIKeyScope{database.ApiKeyScopeCoderAll}, + } + + if tc.mocksFn != nil { + tc.mocksFn(db, apiKey, user) + } + + srv, err := aibridgedserver.NewServer(t.Context(), db, logger, "/", codersdk.AIBridgeConfig{}, nil, requiredExperiments, agplaiseats.Noop{}) + require.NoError(t, err) + require.NotNil(t, srv) + + req := &proto.IsAuthorizedRequest{KeyId: keyID} + if tc.bothFields { + req.Key = "anything-anything" + } + + resp, err := srv.IsAuthorized(t.Context(), req) + if tc.expectedErr != nil { + require.Error(t, err) + require.ErrorIs(t, err, tc.expectedErr) + return + } + require.NoError(t, err) + require.Equal(t, &proto.IsAuthorizedResponse{ + OwnerId: user.ID.String(), + ApiKeyId: keyID, + Username: user.Username, + }, resp) + }) + } +} + func TestGetMCPServerConfigs(t *testing.T) { t.Parallel()