mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(coderd): accept delegated API key ID from in-process aibridge callers (#25625)
Allows an `api_key_id` to be passed from a trusted in-memory transport (currently: `chatd`) to `aibridged` for use in authenticating LLM requests. This value can _only_ be passed via context, and all users of the in-memory transport _must_ provide it. It can be used in conjunction with BYOK headers. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
814386dda7
commit
eddd4a8c2f
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+57
-24
@@ -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,
|
||||
})
|
||||
|
||||
+13
-5
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1118,7 +1118,16 @@ type IsAuthorizedRequest struct {
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
// key is the full "<id>-<secret>" 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 (
|
||||
|
||||
@@ -134,7 +134,16 @@ message GetMCPServerAccessTokensBatchResponse{
|
||||
}
|
||||
|
||||
message IsAuthorizedRequest {
|
||||
// key is the full "<id>-<secret>" 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 {
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user